//! Hostile guests cannot escape the vault guards or wedge the session //! (SPEC 7.3: traps, limits and auto-disable are errors, never panics). mod common; mod plugin_support; use std::time::{Duration, Instant}; use common::{seed, temp_vault}; use notescraft_engine::Session; use notescraft_plugins::Permission; use plugin_support::{approved, install, module, module_with_path, registry}; use serde_json::{Value, json}; #[test] fn reserved_paths_and_traversal_never_write() { for path in [".obsidian/x.md", ".notescraft/x.md", "../escape.md", "notes.txt"] { let vault = temp_vault("plugin-path"); let body = format!("(call $write (i32.const 512) (i32.const {}) (i32.const 16) (i32.const 2))", path.len()); let imports = r#"(import "ns" "vault_write" (func $write (param i32 i32 i32 i32) (result i64)))"#; let wasm = module_with_path(imports, &body, path); install(vault.path(), "writer", &[Permission::VaultWrite], &wasm); let mut session = Session::open(vault.path()).expect("session"); approved(&mut session, "writer", &[Permission::VaultWrite]); // The guest always finishes and returns what the host answered. let answer = session.run_plugin_command("plugin.writer.run", "{}").expect("the guest finishes"); assert!(answer.contains("error"), "the host refused the write: {answer}"); assert!(!vault.path().join(path).exists(), "{path} must not exist"); assert!(!vault.path().parent().unwrap_or(vault.path()).join("escape.md").exists()); assert_eq!(session.index().len(), 0, "nothing new entered the index"); assert_eq!(session.plugin_infos().len(), 1); } } #[test] fn a_loop_hits_its_budget_and_three_failures_auto_disable() { let vault = temp_vault("plugin-loop"); let wasm = module("", "(loop $again (br $again)) (i64.const 0)"); install(vault.path(), "loop", &[], &wasm); let mut session = Session::open(vault.path()).expect("session"); approved(&mut session, "loop", &[]); let mut reg = registry(); assert_eq!(session.register_plugin_commands(&mut reg).expect("register"), 2); for attempt in 1..=3 { let started = Instant::now(); let error = reg.run("plugin.loop.run", &mut session, &Value::Null).expect_err("a loop is not a result"); assert!(started.elapsed() < Duration::from_secs(3), "attempt {attempt} took too long"); assert_ne!(error.to_string(), String::new(), "the failure says why"); assert!(reg.run("vault.info", &mut session, &Value::Null).is_ok(), "the session stays usable"); } 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("enabled"), Some(&json!(false))); assert_eq!(plugin.get("state"), Some(&json!("auto_disabled"))); assert!(plugin.get("detail").and_then(Value::as_str).is_some_and(|why| why.contains("fuel")), "{plugin}"); assert!(!session.plugin_settings().enabled.contains("loop")); assert!(session.notices().iter().any(|notice| notice.contains("disabled after repeated failures"))); assert!(matches!(reg.run("plugin.loop.run", &mut session, &Value::Null), Err(notescraft_engine::Error::Disabled(_)))); } #[test] fn a_trap_is_an_error_and_never_a_panic() { let vault = temp_vault("plugin-trap"); install(vault.path(), "boom", &[], &module("", "(unreachable)")); let mut session = Session::open(vault.path()).expect("session"); approved(&mut session, "boom", &[]); let error = session.run_plugin_command("plugin.boom.run", "{}").expect_err("a trap is not a result"); assert!(matches!(error, notescraft_engine::Error::Plugin(_)), "{error}"); assert!(session.run_plugin_command("plugin.ghost.run", "{}").is_err(), "an unknown plugin is not found"); assert!(session.run_plugin_command("demo.run", "{}").is_err(), "a core id is not a plugin command"); assert!(session.run_plugin_command("plugin.boom", "{}").is_err(), "an id without a command is refused"); } #[test] fn oversize_replies_and_results_are_refused() { let vault = temp_vault("plugin-big"); seed(vault.path(), &[("source.md", &"x".repeat((4 << 20) + 1))]); let reads = module(r#"(import "ns" "vault_read" (func $read (param i32 i32) (result i64)))"#, "(call $read (i32.const 128) (i32.const 9))"); install(vault.path(), "large", &[Permission::VaultRead], &reads); let mut session = Session::open(vault.path()).expect("session"); approved(&mut session, "large", &[Permission::VaultRead]); let error = session.run_plugin_command("plugin.large.run", "{}").expect_err("a 4 MiB answer is over the cap"); assert!(error.to_string().contains("cap") || error.to_string().contains("limit"), "{error}"); // A result pointer outside guest memory is refused by the plugin host. install(vault.path(), "pointer", &[], &module("", "(i64.const 4294967296000000004)")); approved(&mut session, "pointer", &[]); let pointer = session.run_plugin_command("plugin.pointer.run", "{}"); assert!(pointer.is_err(), "an invalid result block is refused: {pointer:?}"); assert!(registry().run("vault.info", &mut session, &Value::Null).is_ok(), "the session still works"); } #[test] fn a_thousand_host_calls_finish_without_deadlock() { let vault = temp_vault("plugin-thousand"); let body = "(loop $again (drop (call $now))\n\ (global.set $count (i32.add (global.get $count) (i32.const 1)))\n\ (br_if $again (i32.lt_u (global.get $count) (i32.const 1000))))\n\ (i64.const 68719476738)"; install(vault.path(), "storm", &[], &module(r#"(import "ns" "now" (func $now (result i64)))"#, body)); let mut session = Session::open(vault.path()).expect("session"); approved(&mut session, "storm", &[]); let mut reg = registry(); assert_eq!(session.register_plugin_commands(&mut reg).expect("register"), 2); let started = Instant::now(); let outcome = reg.run("plugin.storm.run", &mut session, &Value::Null); assert!(started.elapsed() < Duration::from_secs(5), "the call must end on its own"); // Either the 1000 calls went through inside the budget, or the host-call // rate limit of SPEC 7.3 stopped them: both are errors-as-data, never a hang. if let Err(error) = outcome { assert!(error.to_string().contains("rate"), "{error}"); } assert!(reg.run("vault.info", &mut session, &Value::Null).is_ok()); } #[test] fn a_plugin_cannot_flood_the_notices() { let vault = temp_vault("plugin-notice"); let body = "(loop $again (drop (call $notice (i32.const 16) (i32.const 2)))\n\ (global.set $count (i32.add (global.get $count) (i32.const 1)))\n\ (br_if $again (i32.lt_u (global.get $count) (i32.const 400))))\n\ (i64.const 68719476738)"; install(vault.path(), "chatty", &[], &module(r#"(import "ns" "notice" (func $notice (param i32 i32) (result i64)))"#, body)); let mut session = Session::open(vault.path()).expect("session"); approved(&mut session, "chatty", &[]); let _ = session.run_plugin_command("plugin.chatty.run", "{}"); assert!(session.notices().len() <= 256, "the notices stay capped: {}", session.notices().len()); } /// A read-only session refuses a write without touching the vault. #[test] fn a_read_only_session_refuses_a_write() { let vault = temp_vault("plugin-readonly"); let body = "(drop (call $write (i32.const 160) (i32.const 9) (i32.const 16) (i32.const 2)))\n(i64.const 68719476738)"; install( vault.path(), "writer", &[Permission::VaultWrite], &module(r#"(import "ns" "vault_write" (func $write (param i32 i32 i32 i32) (result i64)))"#, body), ); let mut session = Session::open_read_only(vault.path()).expect("session"); approved(&mut session, "writer", &[Permission::VaultWrite]); let outcome = session.run_plugin_command("plugin.writer.run", "{}"); assert!(outcome.is_ok(), "the guest still finishes: {outcome:?}"); assert!(!vault.path().join("copied.md").exists(), "a read-only session writes nothing"); }