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