e26672c98c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
151 lines
8.0 KiB
Rust
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");
|
|
}
|