Files
Marcos Paulo e26672c98c feat(engine-plugins): wave 9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 00:32:04 -03:00

206 lines
11 KiB
Rust

//! The WebAssembly plugin host inside a real session: the bridge, the consent
//! screen, the registry and the settings round trip (SPEC 7, 7.4).
mod common;
mod plugin_support;
use common::{on_disk, seed, temp_config, temp_vault};
use notescraft_engine::{AppSettings, Error, Session, adversarial_params, load_at};
use notescraft_plugins::Permission;
use plugin_support::{EMPTY, approved, install, module, registry, settings_for};
use serde_json::{Value, json};
/// A module that reads `source.md` and writes `copied.md` with fixed content.
fn copier() -> Vec<u8> {
let imports = r#"(import "ns" "vault_read" (func $read (param i32 i32) (result i64)))
(import "ns" "vault_write" (func $write (param i32 i32 i32 i32) (result i64)))"#;
let body = "(drop (call $read (i32.const 128) (i32.const 9)))\n\
(drop (call $write (i32.const 160) (i32.const 9) (i32.const 192) (i32.const 25)))\n\
(i64.const 68719476738)";
module(imports, body)
}
#[test]
fn a_plugin_reads_and_writes_through_the_bridge() {
let vault = temp_vault("plugin-copy");
seed(vault.path(), &[("source.md", "# Original\n")]);
install(vault.path(), "copy", &[Permission::VaultRead, Permission::VaultWrite], &copier());
let mut session = Session::open(vault.path()).expect("session");
approved(&mut session, "copy", &[Permission::VaultRead, Permission::VaultWrite]);
let outcome = session.run_plugin_command("plugin.copy.run", "{}").expect("the plugin runs");
assert_eq!(outcome, "{}", "the guest answers its own JSON");
assert_eq!(on_disk(vault.path(), "copied.md"), "# Copy\n[[source]] #bridge", "the write went to disk");
assert_eq!(session.index().note("copied.md").map(|note| note.links.len()), Some(1), "the index follows the write");
assert!(session.search("bridge", 20).expect("search").iter().any(|hit| hit.path == "copied.md"));
let entries: Vec<_> = std::fs::read_dir(vault.path()).expect("list").map(|entry| entry.expect("entry").file_name()).collect();
assert_eq!(entries.len(), 3, "an atomic write leaves no temporary file behind");
}
#[test]
fn a_read_comes_back_as_json() {
let vault = temp_vault("plugin-read");
seed(vault.path(), &[("source.md", "# Original\n")]);
let wasm = module(r#"(import "ns" "vault_read" (func $read (param i32 i32) (result i64)))"#, "(call $read (i32.const 128) (i32.const 9))");
install(vault.path(), "reader", &[Permission::VaultRead], &wasm);
let mut session = Session::open(vault.path()).expect("session");
approved(&mut session, "reader", &[Permission::VaultRead]);
let outcome = session.run_plugin_command("plugin.reader.run", "{}").expect("the plugin runs");
let value: Value = serde_json::from_str(&outcome).expect("the answer is JSON");
assert_eq!(value.get("path"), Some(&json!("source.md")));
assert_eq!(value.get("content"), Some(&json!("# Original\n")));
}
#[test]
fn permissions_need_the_users_approval() {
let vault = temp_vault("plugin-consent");
seed(vault.path(), &[("source.md", "text")]);
let wasm = module(r#"(import "ns" "vault_read" (func $read (param i32 i32) (result i64)))"#, "(call $read (i32.const 128) (i32.const 9))");
install(vault.path(), "reader", &[Permission::VaultRead], &wasm);
let mut session = Session::open(vault.path()).expect("session");
// Enabled but nothing approved: the host call is refused before the trait runs.
session.apply_app_settings(&settings_for("reader", &[])).expect("settings");
session.load_plugins().expect("load");
let refused = session.run_plugin_command("plugin.reader.run", "{}").expect_err("no approval, no read");
assert!(refused.to_string().contains("not allowed to call"), "{refused}");
// Approving is what the settings document is for.
session.apply_app_settings(&settings_for("reader", &[Permission::VaultRead])).expect("settings");
assert!(session.run_plugin_command("plugin.reader.run", "{}").is_ok());
// Revoking takes effect on the next call.
session.apply_app_settings(&settings_for("reader", &[])).expect("settings");
assert!(session.run_plugin_command("plugin.reader.run", "{}").is_err());
}
#[test]
fn plugin_commands_are_registered_and_run_through_the_registry() {
let vault = temp_vault("plugin-registry");
install(vault.path(), "demo", &[], &module("", EMPTY));
let mut session = Session::open(vault.path()).expect("session");
approved(&mut session, "demo", &[]);
let mut reg = registry();
assert!(!reg.list(&session).iter().any(|info| info.id.starts_with("plugin.")), "nothing is registered before load");
assert_eq!(session.register_plugin_commands(&mut reg).expect("register"), 2);
let listed: Vec<String> = reg.list(&session).into_iter().map(|info| info.id).filter(|id| id.starts_with("plugin.")).collect();
assert_eq!(listed, vec!["plugin.demo.other".to_owned(), "plugin.demo.run".to_owned()]);
assert!(reg.get("plugin.demo.run").is_some_and(|spec| spec.label == "Run"));
let outcome = reg.run("plugin.demo.run", &mut session, &json!({ "args": { "value": 7 } })).expect("run");
assert_eq!(outcome.get("command"), Some(&json!("plugin.demo.run")));
assert_eq!(outcome.get("result"), Some(&json!({})));
// Disabling drops the commands again.
session.disable_plugin("demo").expect("disable");
assert_eq!(session.unregister_plugin_commands(&mut reg, "demo"), 2);
assert!(matches!(reg.run("plugin.demo.run", &mut session, &Value::Null), Err(Error::UnknownCommand(_))));
}
#[test]
fn enable_and_disable_survive_a_restart() {
let vault = temp_vault("plugin-settings");
let config = temp_config("plugin-settings");
install(vault.path(), "demo", &[], &module("", EMPTY));
let mut session = Session::open(vault.path()).expect("session");
session.use_settings_dir(config.path());
session.load_plugins().expect("load plugins");
let mut reg = registry();
reg.run("plugins.enable", &mut session, &json!({ "id": "demo" })).expect("enable");
assert!(session.is_plugin_enabled("demo"));
assert!(load_at(config.path()).0.plugins.enabled.contains("demo"), "the choice is written down");
assert_eq!(session.register_plugin_commands(&mut reg).expect("register"), 2);
reg.run("plugins.disable", &mut session, &json!({ "id": "demo" })).expect("disable");
assert!(!session.is_plugin_enabled("demo"));
assert!(!load_at(config.path()).0.plugins.enabled.contains("demo"));
let _ = session.unregister_plugin_commands(&mut reg, "demo");
assert!(matches!(reg.run("plugin.demo.run", &mut session, &Value::Null), Err(Error::UnknownCommand(_))));
// Enabling an id nobody installed is a plain not-found.
assert!(matches!(session.enable_plugin("ghost"), Err(Error::NotFound(_))));
let listed = reg.run("plugins.list", &mut session, &Value::Null).expect("list");
let plugin = listed.get("plugins").and_then(|items| items.get(0)).expect("one plugin");
assert_eq!(plugin.get("id"), Some(&json!("demo")));
assert_eq!(plugin.get("version"), Some(&json!("1.2.3")));
assert_eq!(plugin.get("enabled"), Some(&json!(false)));
assert_eq!(plugin.get("state"), Some(&json!("disabled")));
}
#[test]
fn unloading_forgets_the_plugin() {
let vault = temp_vault("plugin-unload");
install(vault.path(), "demo", &[], &module("", EMPTY));
let mut session = Session::open(vault.path()).expect("session");
approved(&mut session, "demo", &[]);
assert_eq!(session.plugin_infos().len(), 1);
assert!(session.unload_plugin("demo"));
assert!(!session.unload_plugin("demo"));
assert_eq!(session.plugin_infos().len(), 0);
}
#[test]
fn every_plugin_command_survives_adversarial_params() {
let vault = temp_vault("plugin-params");
install(vault.path(), "safe", &[], &module("", EMPTY));
let mut session = Session::open(vault.path()).expect("session");
approved(&mut session, "safe", &[]);
let mut reg = registry();
assert_eq!(session.register_plugin_commands(&mut reg).expect("register"), 2);
for id in ["plugin.safe.run", "plugin.safe.other", "plugin.safe.gone"] {
for params in adversarial_params() {
assert!(!matches!(reg.run(id, &mut session, &params), Err(Error::Panicked(_))), "{id} panicked");
}
}
assert!(reg.run("vault.info", &mut session, &Value::Null).is_ok(), "the session still works");
}
#[test]
fn active_note_and_insert_text_act_on_the_open_buffer() {
let vault = temp_vault("plugin-editor");
seed(vault.path(), &[("source.md", "hello")]);
let imports = r#"(import "ns" "active_note" (func $active (result i64)))
(import "ns" "insert_text" (func $insert (param i32 i32) (result i64)))"#;
let body = "(drop (call $active))\n\
(drop (call $insert (i32.const 16) (i32.const 2)))\n\
(call $active)";
install(vault.path(), "editor", &[Permission::Editor], &module(imports, body));
let mut session = Session::open(vault.path()).expect("session");
approved(&mut session, "editor", &[Permission::Editor]);
// Nothing focused yet: the editor calls are refused, the plugin still answers.
let unfocused = session.run_plugin_command("plugin.editor.run", "{}");
assert!(unfocused.is_ok(), "{unfocused:?} diagnostics={:?}", session.diagnostics());
session.open_buffer("source.md").expect("buffer");
session.focus_note("source.md").expect("focus");
let outcome = session.run_plugin_command("plugin.editor.run", "{}").expect("insert");
assert_eq!(outcome, r#"{"path":"source.md"}"#, "the guest gets the focused note back");
assert_eq!(session.buffer("source.md").map(notescraft_engine::Buffer::text), Some("hello{}"));
assert!(session.buffer("source.md").is_some_and(notescraft_engine::Buffer::is_dirty));
}
#[test]
fn a_broken_plugin_folder_is_only_a_diagnostic() {
let vault = temp_vault("plugin-broken");
install(vault.path(), "good", &[], &module("", EMPTY));
let dir = vault.path().join(".notescraft/plugins/bad");
std::fs::create_dir_all(&dir).expect("folder");
std::fs::write(dir.join("manifest.json"), "{ not json").expect("manifest");
std::fs::write(dir.join("plugin.wasm"), b"not a module").expect("wasm");
let dir = vault.path().join(".notescraft/plugins/wrong-name");
std::fs::create_dir_all(&dir).expect("folder");
std::fs::write(dir.join("manifest.json"), r#"{"id":"other","name":"n","version":"1","min_app_version":"1"}"#).expect("manifest");
std::fs::write(dir.join("plugin.wasm"), module("", EMPTY)).expect("wasm");
let mut session = Session::open(vault.path()).expect("session");
let mut settings = AppSettings::default();
settings.plugins.enabled.insert("good".to_owned());
session.apply_app_settings(&settings).expect("settings");
session.load_plugins().expect("load");
let ids: Vec<String> = session.plugin_infos().into_iter().map(|info| info.id).collect();
assert_eq!(ids, vec!["good".to_owned()]);
assert_eq!(session.diagnostics().len(), 2, "{:?}", session.diagnostics());
}