//! 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 { 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 = 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 = 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()); }