Files
Marcos Paulo a31eb59678 feat(plugins): sandboxed wasmi plugin host
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:24:35 -03:00

159 lines
5.9 KiB
Rust

//! Hostile modules: every one of them must end as an error, quickly, without touching the host.
mod common;
use std::sync::Arc;
use std::time::{Duration, Instant};
use common::{StubHost, manifest_for, wasm};
use notescraft_plugins::{Error, Limits, Plugin, Result};
const READ: [&str; 1] = ["vault.read"];
fn load(name: &str, limits: Limits) -> Result<Plugin> {
Plugin::load(manifest_for("hostile", &READ, &["go"]), &wasm(name), Arc::new(StubHost::new()), limits)
}
fn run(name: &str, limits: Limits) -> (Error, Duration) {
let started = Instant::now();
let err = load(name, limits).and_then(|mut plugin| plugin.run_command("go", "{}")).unwrap_err();
(err, started.elapsed())
}
#[test]
fn an_infinite_loop_runs_out_of_fuel() {
let (err, took) = run("infinite_loop", Limits::default());
assert!(matches!(&err, Error::PluginLimit(budget) if budget == "fuel"), "{err}");
assert!(took < Duration::from_secs(20), "took {took:?}");
}
#[test]
fn an_infinite_loop_also_honours_a_zero_wall_clock() {
let (err, _) = run("infinite_loop", Limits { wall_time_ms: 0, ..Limits::default() });
assert!(matches!(&err, Error::PluginLimit(budget) if budget == "wall-clock"), "{err}");
}
#[test]
fn a_huge_memory_grow_is_refused() {
let (err, took) = run("memory_bomb", Limits::default());
assert!(matches!(&err, Error::PluginLimit(budget) if budget == "memory"), "{err}");
assert!(took < Duration::from_secs(20), "took {took:?}");
}
#[test]
fn a_pointer_outside_memory_is_rejected() {
let (err, _) = run("oob_pointer", Limits::default());
assert!(matches!(&err, Error::Abi(message) if message.contains("outside memory")), "{err}");
}
#[test]
fn a_result_over_the_cap_is_rejected() {
let (err, _) = run("big_result", Limits::default());
assert!(matches!(err, Error::PluginLimit(_)), "{err}");
}
#[test]
fn a_missing_export_is_rejected_at_load() {
let err = load("missing_export", Limits::default()).unwrap_err();
assert!(matches!(&err, Error::Abi(message) if message.contains("ns_run_command")), "{err}");
}
#[test]
fn a_wrong_abi_version_is_rejected_at_load() {
let err = load("bad_abi", Limits::default()).unwrap_err();
assert!(matches!(&err, Error::Abi(message) if message.contains("version")), "{err}");
}
#[test]
fn a_foreign_import_is_rejected_at_load() {
let err = load("extra_import", Limits::default()).unwrap_err();
assert!(matches!(&err, Error::Abi(message) if message.contains("wasi_snapshot_preview1")), "{err}");
}
#[test]
fn an_ns_import_outside_the_abi_is_rejected_at_load() {
let err = load("extra_ns_import", Limits::default()).unwrap_err();
assert!(matches!(&err, Error::Abi(message) if message.contains("open_url")), "{err}");
}
#[test]
fn arguments_over_the_size_cap_never_reach_the_guest() {
let host: Arc<StubHost> = Arc::new(StubHost::new());
let mut plugin =
Plugin::load(manifest_for("hostile", &READ, &["go"]), &wasm("good"), Arc::clone(&host) as Arc<_>, Limits::default()).unwrap_or_else(|e| panic!("{e}"));
let err = plugin.run_command("go", &"x".repeat(5 << 20)).unwrap_err();
assert!(matches!(&err, Error::PluginLimit(budget) if budget.starts_with("input size")), "{err}");
assert_eq!(host.calls(), Vec::<String>::new());
}
#[test]
fn a_trap_is_reported_as_a_trap() {
let (err, _) = run("trap", Limits::default());
assert!(matches!(err, Error::PluginTrap(_)), "{err}");
}
#[test]
fn unbounded_recursion_is_stopped() {
let (err, took) = run("recursion", Limits::default());
assert!(matches!(&err, Error::PluginTrap(message) if message.contains("call stack")), "{err}");
assert!(took < Duration::from_secs(20), "took {took:?}");
}
#[test]
fn a_negative_return_is_the_guests_error_code() {
let (err, _) = run("guest_error", Limits::default());
assert!(matches!(&err, Error::Abi(message) if message.contains("-7")), "{err}");
}
#[test]
fn a_host_call_storm_hits_the_rate_limit() {
let (err, took) = run("host_storm", Limits::default());
assert!(matches!(&err, Error::PluginLimit(budget) if budget.contains("host-call rate")), "{err}");
assert!(took < Duration::from_secs(20), "took {took:?}");
}
#[test]
fn three_failures_disable_the_plugin() {
let mut plugin = load("trap", Limits::default()).unwrap_or_else(|e| panic!("{e}"));
for failure in 1..=3 {
assert!(plugin.run_command("go", "{}").is_err(), "call {failure} should fail");
assert_eq!(plugin.is_disabled(), failure == 3, "after {failure} failures");
}
assert!(plugin.is_disabled());
let err = plugin.run_command("go", "{}").unwrap_err();
assert!(matches!(err, Error::Disabled { .. }), "{err}");
assert!(matches!(plugin.on_load(), Err(Error::Disabled { .. })));
assert!(matches!(plugin.on_unload(), Err(Error::Disabled { .. })));
assert!(matches!(plugin.send_event("{}"), Err(Error::Disabled { .. })));
}
#[test]
fn a_success_resets_the_failure_streak() {
let mut plugin = load("good", Limits::default()).unwrap_or_else(|e| panic!("{e}"));
for _ in 0..4 {
plugin.run_command("go", "{}").unwrap_or_else(|e| panic!("{e}"));
}
assert!(!plugin.is_disabled());
}
#[test]
fn bytes_that_are_not_wasm_are_rejected() {
let err = Plugin::load(manifest_for("x", &[], &[]), b"not a wasm module at all", Arc::new(StubHost::new()), Limits::default()).unwrap_err();
assert!(matches!(err, Error::Module(_)), "{err}");
}
#[test]
fn a_module_over_the_size_cap_is_rejected() {
let limits = Limits { max_module_bytes: 8, ..Limits::default() };
let err = load("good", limits).unwrap_err();
assert!(matches!(err, Error::PluginLimit(_)), "{err}");
}
#[test]
fn a_truncated_module_is_rejected() {
let bytes = wasm("good");
let cut = bytes.get(..bytes.len() / 2).map(<[u8]>::to_vec).unwrap_or_default();
let err = Plugin::load(manifest_for("x", &[], &[]), &cut, Arc::new(StubHost::new()), Limits::default()).unwrap_err();
assert!(matches!(err, Error::Module(_)), "{err}");
}