mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 01:12:49 -06:00
A self-hosted plugin directory, with no third-party service: `figcraft-server` keeps plugin bundles,
the app browses, installs and updates from it, and `figcraft-cli` publishes to it.
- **Bundles** (`figcraft_plugin::bundle`): a plugin's manifest, module and up to 32 plain-named
assets in one uncompressed, length-prefixed binary form (nothing can expand beyond the bytes
sent). Its SHA-256 names it. `Bundle::decode` never allocates beyond its input and refuses
trailing bytes; `Bundle::check` parses the manifest (refusing a plugin API this build doesn't
run), requires a version of 1-4 numbers, compiles the module without running it (only the
sandbox's imports allowed) and checks asset names (no paths, no dot files, no Windows device
names, unique ignoring case). Manifests gain an optional `author`.
- **Protocol**: a new connection kind next to managing and team-library connections:
`OpenPlugins {key}` then `Plugins {list | fetch | publish | unpublish}`. Browsing needs no
credential; publishing and unpublishing need the server key (a wrong key is refused). Older
servers refuse the connection and the app says the server has no plugin directory. The protocol
version is unchanged; old clients are unaffected.
- **Server** (`apps/figcraft-server/src/directory.rs`): `<data>/plugins/index.json` plus
`<data>/plugins/<sha256>.bin`, so no publisher-chosen name reaches the file system. Every publish
is checked in full and must be a newer version; caps `max_plugins` (versions, 0 turns it off),
`max_plugin_mb` (<= 32), `max_plugins_mb`. Fetches re-hash the file before sending (a damaged
bundle is never sent) and share a download budget (~32 MiB/s) so a flood is refused, not queued.
Damaged index entries are skipped and reported, never deleted; an unreadable index lists nothing
and refuses writes until the operator fixes it. Operator moderation:
`figcraft-server plugins [list]` and `plugins remove ID [--version V]` (offline or through the
running server). Logs name ids and versions, never keys.
- **App**: Plugins > Manage plugins gets Installed / Browse tabs. Browse lists a server's plugins
(version, author as typed, size, access) with a search; nothing is fetched until Browse is
pressed. Install / Update open a confirmation listing the permissions asked for, the source and
the SHA-256; only its button downloads. The download must match the listed SHA-256 (and the one
shown), then is checked like an imported plugin before anything is written. Installed plugins
live per user under `<settings>/FigCraft/Plugins/<hash of id>/` and run in the same sandbox;
Remove deletes the folder.
- **Commands**: `plugin.directory.list {server?, query?}`, `plugin.directory.install {id, version?,
confirm, server?, sha256?}` (refused without `confirm: true`; never opens a dialog),
`plugin.directory.publish {path | plugin, server}`, `plugin.directory.unpublish {id, version?,
server}`. `ui.set {pluginsTab, pluginConfirm}` on the control channel.
- **CLI**: `figcraft-cli plugin publish FOLDER --server ws://host:port/` with the key from
`FIGCRAFT_SERVER_KEY` (or `?key=`), never printed.
- Docs: plugins.md (directories, publishing, commands, manifest), server.md (operating the
directory: access, checks, caps, moderation, backups, security), collab-protocol.md, mcp.md,
cli.md, control-protocol.md, target-app-parity area 18 and Ecosystem, ROADMAP, roadmap.md M11,
gaps.md gap 6. Plugins & widgets 55%/32% -> 60%/35%, 10-30 h -> 8-26 h.
255 lines
12 KiB
Rust
255 lines
12 KiB
Rust
//! Installing from and publishing to a plugin directory, against a scripted directory (the real
|
|
//! server, over the network, is tested in `figcraft-server`): nothing is fetched without
|
|
//! `confirm: true`, a download that doesn't match its SHA-256 or its entry is never installed,
|
|
//! installed plugins run in the sandbox, update and are removed with their folder, and publishing
|
|
//! sends a plugin folder as a checked bundle.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use figcraft_collab::message::{ClientMsg, PluginEntry, PluginReply, PluginReq};
|
|
use figcraft_plugin::bundle::{Asset, Bundle, sha256_hex};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::live::{Event, HostOptions, LiveLink, Presence, SessionInfo};
|
|
use crate::{EngineError, Session};
|
|
|
|
/// A scripted directory: its bundles by entry, the requests it got, and damage to apply.
|
|
#[derive(Default)]
|
|
struct Dir {
|
|
plugins: Vec<(PluginEntry, Vec<u8>)>,
|
|
requests: Vec<(String, PluginReq)>,
|
|
/// Flip a byte of every bundle sent.
|
|
tamper: bool,
|
|
/// Send this id's bundle whatever was asked for.
|
|
swap: Option<String>,
|
|
}
|
|
|
|
struct Link(Arc<Mutex<Dir>>);
|
|
|
|
impl LiveLink for Link {
|
|
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
|
Err("no".into())
|
|
}
|
|
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
|
|
Err("no".into())
|
|
}
|
|
fn leave(&mut self) {}
|
|
fn presence(&mut self, _: &Presence) {}
|
|
fn send(&mut self, _: ClientMsg) {}
|
|
fn poll(&mut self) -> Vec<Event> {
|
|
Vec::new()
|
|
}
|
|
fn plugins(&mut self, server: &str, req: PluginReq) -> Result<PluginReply, String> {
|
|
let mut d = self.0.lock().unwrap();
|
|
d.requests.push((server.to_string(), req.clone()));
|
|
Ok(match req {
|
|
PluginReq::List { .. } => PluginReply::List(d.plugins.iter().map(|(e, _)| e.clone()).collect()),
|
|
PluginReq::Fetch { id, version } => {
|
|
let want = d.swap.clone().unwrap_or(id);
|
|
match d.plugins.iter().rev().find(|(e, _)| e.id == want && version.as_ref().is_none_or(|v| *v == e.version)) {
|
|
Some((entry, bytes)) => {
|
|
let mut bundle = bytes.clone();
|
|
if d.tamper {
|
|
let last = bundle.len() - 1;
|
|
bundle[last] ^= 1;
|
|
}
|
|
PluginReply::Bundle { entry: entry.clone(), bundle }
|
|
}
|
|
None => PluginReply::Failed { reason: "no such plugin".into() },
|
|
}
|
|
}
|
|
PluginReq::Publish { bundle } => {
|
|
let b = Bundle::decode(&bundle, usize::MAX).map_err(|e| e.to_string())?;
|
|
let m = b.check()?;
|
|
let e = entry(&m.id, &m.version, &bundle);
|
|
d.plugins.push((e.clone(), bundle));
|
|
PluginReply::Published(e)
|
|
}
|
|
PluginReq::Unpublish { id, .. } => {
|
|
d.plugins.retain(|(e, _)| e.id != id);
|
|
PluginReply::Removed(vec!["all".into()])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A module that does nothing and succeeds.
|
|
fn wasm() -> Vec<u8> {
|
|
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap()
|
|
}
|
|
|
|
fn manifest(id: &str, version: &str) -> String {
|
|
json!({"id": id, "name": "Tiny", "version": version, "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "read"}}).to_string()
|
|
}
|
|
|
|
fn bundle(id: &str, version: &str) -> Vec<u8> {
|
|
Bundle { manifest: manifest(id, version), wasm: wasm(), assets: vec![Asset { name: "LICENSE".into(), data: b"MIT".to_vec() }] }.encode()
|
|
}
|
|
|
|
fn entry(id: &str, version: &str, bytes: &[u8]) -> PluginEntry {
|
|
PluginEntry {
|
|
id: id.into(),
|
|
name: "Tiny".into(),
|
|
version: version.into(),
|
|
author: "Ada".into(),
|
|
access: "read".into(),
|
|
bytes: bytes.len() as u64,
|
|
sha256: sha256_hex(bytes),
|
|
..PluginEntry::default()
|
|
}
|
|
}
|
|
|
|
fn offer(d: &Arc<Mutex<Dir>>, id: &str, version: &str) {
|
|
let b = bundle(id, version);
|
|
d.lock().unwrap().plugins.push((entry(id, version, &b), b));
|
|
}
|
|
|
|
fn session() -> (Session, Arc<Mutex<Dir>>) {
|
|
let mut s = Session::new();
|
|
let d = Arc::new(Mutex::new(Dir::default()));
|
|
s.live.install(Box::new(Link(d.clone())));
|
|
(s, d)
|
|
}
|
|
|
|
fn fetches(d: &Arc<Mutex<Dir>>) -> usize {
|
|
d.lock().unwrap().requests.iter().filter(|(_, r)| matches!(r, PluginReq::Fetch { .. })).count()
|
|
}
|
|
|
|
fn temp(tag: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("figcraft-plugdir-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
const ID: &str = "org.example.tiny";
|
|
|
|
#[test]
|
|
fn listing_remembers_the_server_without_its_key() {
|
|
let (mut s, d) = session();
|
|
offer(&d, ID, "1.0.0");
|
|
let r = s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/?key=secret"})).unwrap();
|
|
assert_eq!(r["server"], "ws://127.0.0.1:9/");
|
|
assert_eq!(r["plugins"][0]["id"], ID);
|
|
assert_eq!(r["plugins"][0]["installed"], Value::Null);
|
|
assert_eq!(s.prefs.plugin_directory.as_deref(), Some("ws://127.0.0.1:9/"));
|
|
assert!(!serde_json::to_string(&s.prefs).unwrap().contains("secret"), "the key is never kept");
|
|
// The server browsed last is the default.
|
|
s.execute("plugin.directory.list", &json!({})).unwrap();
|
|
assert_eq!(d.lock().unwrap().requests.last().unwrap().0, "ws://127.0.0.1:9/");
|
|
// Without a link (the browser, a bare session) it's a clear error.
|
|
let e = Session::new().execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap_err().to_string();
|
|
assert!(e.contains("desktop app"), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn installing_needs_confirm_and_a_matching_download() {
|
|
let (mut s, d) = session();
|
|
offer(&d, ID, "1.0.0");
|
|
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
|
|
for confirm in [json!(null), json!(false), json!("true"), json!(1)] {
|
|
let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": confirm})).unwrap_err();
|
|
assert!(matches!(e, EngineError::BadParams { .. }) && e.to_string().contains("confirm"), "{e}");
|
|
}
|
|
assert_eq!(fetches(&d), 0, "nothing is downloaded before the user confirms");
|
|
// A damaged download, or one that isn't what was shown, is never installed.
|
|
d.lock().unwrap().tamper = true;
|
|
let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
|
|
assert!(e.contains("SHA-256"), "{e}");
|
|
d.lock().unwrap().tamper = false;
|
|
let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true, "sha256": "00ff"})).unwrap_err().to_string();
|
|
assert!(e.contains("shown"), "{e}");
|
|
offer(&d, "org.example.other", "1.0.0");
|
|
d.lock().unwrap().swap = Some("org.example.other".into());
|
|
let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
|
|
assert!(e.contains("another plugin"), "{e}");
|
|
d.lock().unwrap().swap = None;
|
|
// A bundle whose manifest disagrees with its entry.
|
|
let b = bundle("org.example.liar", "9.9.9");
|
|
d.lock().unwrap().plugins.push((entry(ID, "2.0.0", &b), b));
|
|
let e = s.execute("plugin.directory.install", &json!({"id": ID, "version": "2.0.0", "confirm": true})).unwrap_err().to_string();
|
|
assert!(e.contains("doesn't match"), "{e}");
|
|
assert!(s.prefs.plugins.is_empty(), "nothing got installed");
|
|
// The real thing: installed for the session (no install folder), and it runs.
|
|
let r = s.execute("plugin.directory.install", &json!({"id": ID, "version": "1.0.0", "confirm": true})).unwrap();
|
|
assert_eq!((r["version"].as_str(), r["path"].as_str(), r["replaced"].as_bool()), (Some("1.0.0"), None, Some(false)));
|
|
assert_eq!(s.prefs.plugins[0].source.as_deref(), Some("ws://127.0.0.1:9/"));
|
|
s.execute("plugin.run", &json!({"id": ID})).unwrap();
|
|
let listed = s.execute("plugin.directory.list", &json!({})).unwrap();
|
|
let mine = listed["plugins"].as_array().unwrap().iter().find(|p| p["version"] == "1.0.0").unwrap().clone();
|
|
assert_eq!((mine["installed"].as_str(), mine["update"].as_bool()), (Some("1.0.0"), Some(false)));
|
|
}
|
|
|
|
#[test]
|
|
fn installed_plugins_live_in_their_folder_update_and_go() {
|
|
let (mut s, d) = session();
|
|
let root = temp("install");
|
|
s.plugins.install_dir = Some(root.clone());
|
|
offer(&d, ID, "1.0.0");
|
|
let r = s.execute("plugin.directory.install", &json!({"id": ID, "server": "ws://127.0.0.1:9/", "confirm": true})).unwrap();
|
|
let manifest = PathBuf::from(r["path"].as_str().unwrap());
|
|
let folder = manifest.parent().unwrap().to_path_buf();
|
|
assert_eq!(folder.parent().unwrap(), root.as_path());
|
|
assert!(!folder.file_name().unwrap().to_string_lossy().contains("example"), "the folder is named by a hash, not the id");
|
|
assert_eq!(std::fs::read(folder.join("module.wasm")).unwrap(), wasm());
|
|
assert_eq!(std::fs::read(folder.join("assets").join("LICENSE")).unwrap(), b"MIT");
|
|
assert!(std::fs::read_to_string(&manifest).unwrap().contains("module.wasm"));
|
|
s.execute("plugin.run", &json!({"id": ID})).unwrap();
|
|
// A newer version is an update; the same folder holds it.
|
|
offer(&d, ID, "1.1.0");
|
|
let listed = s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
|
|
assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["version"] == "1.1.0" && p["update"] == true));
|
|
let r = s.execute("plugin.directory.install", &json!({"id": ID, "version": "1.1.0", "confirm": true})).unwrap();
|
|
assert_eq!((r["replaced"].as_bool(), r["path"].as_str()), (Some(true), manifest.to_str()));
|
|
assert_eq!(s.prefs.plugins.len(), 1);
|
|
assert_eq!(s.prefs.plugins[0].manifest.version, "1.1.0");
|
|
s.execute("plugin.run", &json!({"id": ID})).unwrap();
|
|
// Removing deletes its folder (and only its folder).
|
|
s.execute("plugin.remove", &json!({"id": ID})).unwrap();
|
|
assert!(!folder.exists() && root.exists());
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn publishing_sends_a_checked_folder() {
|
|
let (mut s, d) = session();
|
|
let dir = temp("publish");
|
|
std::fs::create_dir_all(dir.join("build")).unwrap();
|
|
std::fs::create_dir_all(dir.join("assets")).unwrap();
|
|
std::fs::write(dir.join("build").join("tiny.wasm"), wasm()).unwrap();
|
|
let text = json!({"id": ID, "name": "Tiny", "version": "1.0.0", "main": "build/tiny.wasm"}).to_string();
|
|
std::fs::write(dir.join("figcraft-plugin.json"), &text).unwrap();
|
|
std::fs::write(dir.join("assets").join("icon.png"), [1, 2, 3]).unwrap();
|
|
let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy()})).unwrap_err().to_string();
|
|
assert!(e.contains("server"), "{e}");
|
|
let r = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://127.0.0.1:9/?key=k"})).unwrap();
|
|
assert_eq!((r["published"]["version"].as_str(), r["server"].as_str()), (Some("1.0.0"), Some("ws://127.0.0.1:9/")));
|
|
let sent = d.lock().unwrap().plugins.last().unwrap().1.clone();
|
|
let b = Bundle::decode(&sent, usize::MAX).unwrap();
|
|
assert_eq!((b.manifest.as_str(), b.wasm.clone()), (text.as_str(), wasm()));
|
|
assert_eq!(b.assets, vec![Asset { name: "icon.png".into(), data: vec![1, 2, 3] }]);
|
|
// Refused before anything is sent: a hidden asset, a version a directory can't compare.
|
|
let count = d.lock().unwrap().requests.len();
|
|
std::fs::write(dir.join("assets").join(".secret"), b"x").unwrap();
|
|
assert!(
|
|
s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"}))
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("asset")
|
|
);
|
|
std::fs::remove_file(dir.join("assets").join(".secret")).unwrap();
|
|
std::fs::write(dir.join("figcraft-plugin.json"), text.replace("1.0.0", "next")).unwrap();
|
|
assert!(
|
|
s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"}))
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("version")
|
|
);
|
|
assert_eq!(d.lock().unwrap().requests.len(), count);
|
|
// Unpublishing names it.
|
|
let r = s.execute("plugin.directory.unpublish", &json!({"id": ID, "server": "ws://127.0.0.1:9/?key=k"})).unwrap();
|
|
assert_eq!(r["id"], ID);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|