e26672c98c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
206 lines
11 KiB
Rust
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, ¶ms), 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());
|
|
}
|