figcraft/crates/engine/src/plugin_directory.rs

311 lines
14 KiB
Rust

//! 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<PluginEntry>,
}
/// 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> {
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<PluginReply> {
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<Value> {
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<Value> = 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<Value> {
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<Option<String>, 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<Bundle> {
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<Bundle> {
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<Value> {
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<Value> {
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<CommandSpec> {
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
),
]
}