//! Plugins from a server's plugin directory (`figcraft-server`): listing it, installing and updating //! plugins from it, and publishing and unpublishing them with the server's key. See //! `docs/plugins.md` and `docs/server.md`. //! //! Everything a server sends is untrusted. An install needs the user's say-so (`confirm: true`, //! which the app passes only from its confirmation step; nothing here opens a dialog), and the //! download must match the SHA-256 the directory lists before anything else is read: then the //! bundle is decoded within a size cap, its manifest and module are checked like an imported //! plugin's (the module compiled, never run) and it runs in the same sandbox with its manifest's //! permissions. Nothing here reaches the network except these commands (the UI runs them without //! waiting: [`crate::background`]). use std::cmp::Ordering; pub use figcraft_collab::message::PluginEntry; use figcraft_collab::message::{PluginReply, PluginReq}; pub use figcraft_plugin::bundle; pub use figcraft_plugin::bundle::newer as newer_version; use figcraft_plugin::bundle::{Asset, Bundle, MAX_ASSETS, check_asset_name, compare_versions, newer, sha256_hex}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::live::{NetReply, NetRequest}; use crate::params::{get, string}; use crate::plugins::{InstalledPlugin, MANIFEST_FILE, Manifest, installed, register}; use crate::{EngineError, Result, Session, bad, spec}; /// Why there's no plugin directory here (the browser build, sessions without the network). pub const NO_DIRECTORY: &str = "plugin directories on a server need the desktop app, figcraft-cli or the MCP server"; /// Largest bundle downloaded or published (a server's own cap is at most this). pub const MAX_BUNDLE: usize = 32 << 20; /// The module's file name in an installed plugin's folder, and the folder its assets go in. const MODULE: &str = "module.wasm"; const ASSETS: &str = "assets"; /// A directory as it was listed last. #[derive(Clone, Debug, Default, PartialEq)] pub struct Listing { /// Its address (`ws://host:port/`, without a key). pub server: String, pub entries: Vec, } /// A server address without its query (the key), as it's shown and remembered. pub fn address(server: &str) -> String { server.trim().split('?').next().unwrap_or_default().to_string() } /// The installed version of plugin `id`, if it is installed. pub fn installed_version<'a>(s: &'a Session, id: &str) -> Option<&'a str> { s.prefs.plugins.iter().find(|p| p.manifest.id == id).map(|p| p.manifest.version.as_str()) } /// Entry `e` with whether it's installed (its version) and whether that's older. fn describe(s: &Session, e: &PluginEntry) -> Value { let mut v = serde_json::to_value(e).unwrap_or_else(|_| json!({"id": e.id})); let have = installed_version(s, &e.id); v["installed"] = json!(have); v["update"] = json!(have.is_some_and(|h| newer(&e.version, h))); v } /// `server`, or the directory browsed last. fn server(s: &Session, cmd: &str, p: &Value) -> Result { string(p, "server") .filter(|v| !v.trim().is_empty()) .or_else(|| s.prefs.plugin_directory.clone()) .ok_or_else(|| bad(cmd, "`server` required: the server's address, like ws://host:47900/")) } /// One request to the directory at `server`; a refusal is an error. fn request(s: &mut Session, server: &str, req: PluginReq) -> Result { match s.net(NetRequest::Plugins { server: server.to_string(), req })?.0 { NetReply::Plugins(Ok(PluginReply::Failed { reason }) | Err(reason)) => Err(EngineError::Other(reason)), NetReply::Plugins(Ok(r)) => Ok(r), _ => Err(EngineError::Other("the server answered something else".into())), } } fn unexpected(cmd: &str) -> EngineError { EngineError::Other(format!("{cmd}: the server answered something else")) } fn list(s: &mut Session, p: &Value) -> Result { let cmd = "plugin.directory.list"; let server = server(s, cmd, p)?; let query = string(p, "query").unwrap_or_default(); let PluginReply::List(entries) = request(s, &server, PluginReq::List { query })? else { return Err(unexpected(cmd)) }; let server = address(&server); let plugins: Vec = entries.iter().map(|e| describe(s, e)).collect(); s.prefs.plugin_directory = Some(server.clone()); s.plugins.directory = Some(Listing { server: server.clone(), entries }); Ok(json!({"server": server, "plugins": plugins})) } /// Download, check and install a plugin (see the module docs). fn install(s: &mut Session, p: &Value) -> Result { let cmd = "plugin.directory.install"; let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (see plugin.directory.list)"))?; if get(p, "confirm") != Some(&Value::Bool(true)) { return Err(bad( cmd, "installing a plugin runs its code on this machine: check the permissions it asks for (plugin.directory.list) and pass `confirm: true`", )); } let server = server(s, cmd, p)?; let version = string(p, "version"); let PluginReply::Bundle { entry, bundle: bytes } = request(s, &server, PluginReq::Fetch { id: id.clone(), version: version.clone() })? else { return Err(unexpected(cmd)); }; let fail = |why: &str| EngineError::Other(format!("{id} wasn't installed: {why}")); if entry.id != id || version.as_deref().is_some_and(|v| compare_versions(&entry.version, v) != Some(Ordering::Equal)) { return Err(fail("the server sent another plugin than the one asked for")); } if bytes.len() > MAX_BUNDLE { return Err(fail("the download is too large")); } let sha = sha256_hex(&bytes); if sha != entry.sha256 { return Err(fail("the download doesn't match the SHA-256 the directory lists")); } if string(p, "sha256").is_some_and(|want| !want.trim().eq_ignore_ascii_case(&sha)) { return Err(fail("the download isn't the version that was shown (its SHA-256 differs)")); } let bundle = Bundle::decode(&bytes, MAX_BUNDLE).map_err(|e| fail(&e))?; let mut manifest = bundle.check().map_err(|e| fail(&e))?; if manifest.id != entry.id || manifest.version != entry.version { return Err(fail("its manifest doesn't match the directory's entry")); } let source = address(&server); let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?; let out = json!({ "installed": manifest.id, "name": manifest.name, "version": manifest.version, "permissions": manifest.permissions, "sha256": sha, "path": path, "source": source, }); let replaced = register(s, InstalledPlugin { manifest, path, source: Some(source) }, bundle.wasm); let mut out = out; out["replaced"] = json!(replaced); Ok(out) } /// Keep an installed plugin's files in its own folder under the install folder (named by the hash /// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without /// an install folder (the browser, the CLI and tests) the plugin lasts for the session. fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result, String> { let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) }; #[cfg(not(target_arch = "wasm32"))] { use std::fs; let hash = sha256_hex(m.id.as_bytes()); let folder = root.join(hash.get(..32).unwrap_or(&hash)); let partial = folder.with_extension("partial"); let _ = fs::remove_dir_all(&partial); m.main = MODULE.into(); let write = || -> std::io::Result<()> { fs::create_dir_all(&partial)?; fs::write(partial.join(MODULE), &b.wasm)?; if !b.assets.is_empty() { fs::create_dir_all(partial.join(ASSETS))?; for a in &b.assets { // Checked plain names (`Bundle::check`): never a path. fs::write(partial.join(ASSETS).join(&a.name), &a.data)?; } } fs::write(partial.join(MANIFEST_FILE), serde_json::to_vec_pretty(&*m).map_err(std::io::Error::other)?) }; let done = write().and_then(|()| { if folder.exists() { fs::remove_dir_all(&folder)?; } fs::rename(&partial, &folder) }); if let Err(e) = done { let _ = fs::remove_dir_all(&partial); return Err(format!("couldn't write it to {}: {e}", root.display())); } Ok(Some(folder.join(MANIFEST_FILE).to_string_lossy().into_owned())) } #[cfg(target_arch = "wasm32")] { let _ = (root, m, b); Ok(None) } } /// Delete the folder of a plugin installed from a directory (only one inside the install folder). pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) { #[cfg(not(target_arch = "wasm32"))] if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir) && let Some(folder) = std::path::Path::new(path).parent() && folder.parent() == Some(root.as_path()) && let Err(e) = std::fs::remove_dir_all(folder) { log::warn!("plugins: couldn't delete {}: {e}", folder.display()); } #[cfg(target_arch = "wasm32")] let _ = (s, p); } /// The bundle of the plugin folder (or manifest) at `path`: its manifest as written, its module and /// the files in its `assets` folder. #[cfg(not(target_arch = "wasm32"))] fn bundle_from_disk(cmd: &str, path: &str) -> Result { use crate::plugins::{module_file, read_file}; let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim())); if file.is_dir() { file.push(MANIFEST_FILE); } let text = String::from_utf8(read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?).map_err(|_| bad(cmd, "the manifest isn't UTF-8 text"))?; let m = Manifest::parse(&text).map_err(|e| bad(cmd, e))?; let wasm = module_file(&file, &m)?; let mut assets = Vec::new(); let dir = file.parent().map(|d| d.join(ASSETS)); if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) { let mut names: Vec<(String, std::path::PathBuf)> = entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect(); names.sort(); if names.len() > MAX_ASSETS { return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files"))); } for (name, path) in names { check_asset_name(&name).map_err(|e| bad(cmd, e))?; assets.push(Asset { name, data: read_file(&path, MAX_BUNDLE)? }); } } Ok(Bundle { manifest: text, wasm, assets }) } #[cfg(target_arch = "wasm32")] fn bundle_from_disk(cmd: &str, _path: &str) -> Result { Err(bad(cmd, "there are no files in the browser: publish an installed plugin with `plugin`")) } /// Publish a plugin folder (or an installed plugin) as a new version (needs the server's key). fn publish(s: &mut Session, p: &Value) -> Result { let cmd = "plugin.directory.publish"; let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=… (`figcraft-server key` prints it)"))?; let bundle = match (string(p, "path"), string(p, "plugin")) { (Some(path), _) => bundle_from_disk(cmd, &path)?, (None, Some(id)) => { let ip = installed(s, cmd, &id)?.clone(); match &ip.path { Some(path) => bundle_from_disk(cmd, path)?, None => { let wasm = crate::plugins::module_of(s, &ip)?; let manifest = serde_json::to_string(&ip.manifest).map_err(|e| EngineError::Other(e.to_string()))?; Bundle { manifest, wasm: wasm.to_vec(), assets: Vec::new() } } } } (None, None) => return Err(bad(cmd, "pass `path` (a plugin folder or its figcraft-plugin.json) or `plugin` (an installed plugin's id)")), }; // Checked here too, for a clear message before anything is sent. bundle.check().map_err(|e| bad(cmd, e))?; let bytes = bundle.encode(); if bytes.len() > MAX_BUNDLE { return Err(bad(cmd, format!("the plugin is larger than {} MiB", MAX_BUNDLE >> 20))); } let PluginReply::Published(entry) = request(s, &server, PluginReq::Publish { bundle: bytes })? else { return Err(unexpected(cmd)) }; Ok(json!({"published": entry, "server": address(&server)})) } fn unpublish(s: &mut Session, p: &Value) -> Result { let cmd = "plugin.directory.unpublish"; let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?; let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=…"))?; let PluginReply::Removed(versions) = request(s, &server, PluginReq::Unpublish { id: id.clone(), version: string(p, "version") })? else { return Err(unexpected(cmd)); }; Ok(json!({"id": id, "removed": versions})) } pub fn specs() -> Vec { vec![ spec!( "plugin.directory.list", "Browse plugin directory", "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", false, list ), spec!( "plugin.directory.install", "Install plugin from directory", "{id, version? (default: the latest), confirm: true (required: installing runs the plugin's code), server?, sha256? (the one shown)} downloads, checks the SHA-256 and installs or updates it → {installed, name, version, permissions, sha256, path, source, replaced}", false, install ), spec!( "plugin.directory.publish", "Publish plugin to directory", "{path (a plugin folder or its figcraft-plugin.json) | plugin (an installed plugin's id), server (ws://host:port/?key=…)} → {published: entry, server}", false, publish ), spec!( "plugin.directory.unpublish", "Unpublish plugin from directory", "{id, version? (default: every version), server (ws://host:port/?key=…)} → {id, removed: [versions]}", false, unpublish ), ] }