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

151 lines
8.0 KiB
Rust

//! 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");
}