Files
figcraft/crates/engine/src/plugin_directory_tests.rs
T
bflatastic 94349d86b0 M9: live sessions per document
Live sessions are per document instead of one for the whole app.

- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
  installed link, the invite-link registration) plus one `Live` per session. One place resolves
  "the session of this document": `Session::live()` (the active document's; status off when it
  has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
  spotlight and presence all go through the session of the document they are about, so edits and
  presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
  `(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
  link keep them in a shared table with one mailbox per session
  (`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
  `collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
  one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
  peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
  touching other sessions; until then (or if it fails) it shows on the tab it was started from.
  Starting a session for a document replaces only that document's own session. Closing a document
  ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
  banner, follow frame and read-only reason read the session of the tab in front, per window. What
  belongs to one session in the window (cursors shown, your chat box, a report being written) is
  dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
  `sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
  `docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
2026-10-12 17:54:18 +07:00

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, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
Err("no".into())
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Err("no".into())
}
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, _: ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, 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);
}