crates/guest-host/tests/bounds_and_trust.rs
//! The bounds are REAL: hand-written modules that loop forever and grow
//! memory without end reach the real host and are refused BY NAME. A bound
//! asserted against a mock is a bound nothing has ever fired.
#![cfg(feature = "wasmtime")]
use std::path::PathBuf;
use std::time::{Duration, Instant};
use guest_host::module::{read_arg, write_to_wasm, GuestState, LoadError, LoadedModule};
use guest_host::{exports, Bound, BoundedLimiter, CallError, ModuleTrust, PluginLimits};
use wasmtime::Linker;
struct State {
limits: BoundedLimiter,
faults: Vec<String>,
}
impl GuestState for State {
fn limiter(&mut self) -> &mut BoundedLimiter {
&mut self.limits
}
fn faults(&mut self) -> &mut Vec<String> {
&mut self.faults
}
}
/// A complete `plugin_call` guest in WAT: `plugin_call` runs `body`, then
/// answers a packed word pointing at 4 bytes ("pong") at offset 16.
fn module_wat(body: &str) -> String {
format!(
r#"(module
(import "env" "host_echo" (func $echo (param i32 i32) (result i64)))
(memory (export "memory") 1)
(data (i32.const 16) "pong")
(global $bump (mut i32) (i32.const 1024))
(func (export "alloc") (param $n i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get $n)))
(local.get $p))
(func (export "dealloc") (param i32 i32))
(func (export "result_len") (result i32) (i32.const 4))
(func (export "plugin_abi") (result i64) (i64.const 0x100000001))
(func (export "plugin_manifest") (result i32) (i32.const 16))
(func (export "plugin_call") (param $op i32) (param $ptr i32) (param $len i32) (result i64)
{body}
(if (i32.eq (local.get $op) (i32.const 7))
(then (return (call $echo (local.get $ptr) (local.get $len)))))
(i64.or (i64.shl (i64.const 16) (i64.const 32)) (i64.const 4)))
)"#
)
}
const QUIET: &str = "";
const FOREVER: &str = "(loop $l (br $l))";
const MEMORY_BOMB: &str = "(drop (memory.grow (i32.const 1000)))";
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("guest-host-{name}-{}-{:?}", std::process::id(), std::thread::current().id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn write_module(dir: &PathBuf, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, wat::parse_str(module_wat(body)).expect("valid wat")).unwrap();
path
}
fn linker(m: &LoadedModule) -> Linker<State> {
let mut l = Linker::new(m.engine());
l.func_wrap("env", "host_echo", |mut caller: wasmtime::Caller<'_, State>, ptr: u32, len: u32| -> u64 {
let Some(arg) = read_arg(&mut caller, ptr, len, "host_echo") else { return 0 };
let mut out = b"echo:".to_vec();
out.extend(arg);
write_to_wasm(&mut caller, &out)
})
.unwrap();
l
}
fn state(m: &LoadedModule) -> State {
State { limits: m.limits().limiter(), faults: Vec::new() }
}
#[test]
fn a_quiet_guest_answers_every_call_shape_and_the_census_names_it() {
let dir = scratch("quiet");
let path = write_module(&dir, "quiet.wasm", QUIET);
let m = LoadedModule::load(&path, &PluginLimits::default(), &ModuleTrust::NotRequired, exports::ALL).unwrap();
assert_eq!(m.digest_hex(), guest_host::digest_hex(&std::fs::read(&path).unwrap()));
assert_eq!(m.signer(), None, "no provenance was required");
let l = linker(&m);
let mut inst = m.instantiate(state(&m), &l).unwrap();
assert_eq!(m.call_u64(&mut inst, exports::PLUGIN_ABI).unwrap(), 0x1_0000_0001);
let mut inst = m.instantiate(state(&m), &l).unwrap();
assert_eq!(m.call_ptr(&mut inst, exports::MANIFEST).unwrap(), b"pong");
let mut inst = m.instantiate(state(&m), &l).unwrap();
assert_eq!(m.call_op(&mut inst, 1, b"hello").unwrap(), b"pong");
assert!(m.last_call_fuel() > 0 && m.peak_call_fuel() >= m.last_call_fuel());
// A host import round-trips through the guest's own alloc.
let mut inst = m.instantiate(state(&m), &l).unwrap();
let echoed = m.call_op(&mut inst, 7, b"ping").unwrap();
assert_eq!(echoed, b"echo:ping");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_export_is_named_before_anything_runs() {
let dir = scratch("missing");
let path = write_module(&dir, "m.wasm", QUIET);
let err = LoadedModule::load(&path, &PluginLimits::default(), &ModuleTrust::NotRequired, &["plugin_call", "not_there"]).unwrap_err();
match &err {
LoadError::MissingExports { missing, .. } => assert_eq!(missing, &["not_there".to_string()]),
other => panic!("{other}"),
}
assert!(err.to_string().contains("m.wasm") && err.to_string().contains("not_there"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_runaway_is_refused_as_fuel_when_fuel_is_the_tighter_bound() {
let dir = scratch("fuel");
let path = write_module(&dir, "forever.wasm", FOREVER);
let limits = PluginLimits { fuel: 1_000_000, call_timeout: Duration::from_secs(60), ..Default::default() };
let m = LoadedModule::load(&path, &limits, &ModuleTrust::NotRequired, exports::ALL).unwrap();
let l = linker(&m);
let mut inst = m.instantiate(state(&m), &l).unwrap();
let err = m.call_op(&mut inst, 1, &[]).unwrap_err();
assert_eq!(err.bound(), Some(Bound::Fuel), "{err}");
let msg = err.to_string();
assert!(msg.contains("1000000") && msg.contains("plugin_call") && msg.contains("forever.wasm"), "{msg}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_runaway_is_refused_as_deadline_when_the_clock_is_the_tighter_bound() {
let dir = scratch("deadline");
let path = write_module(&dir, "forever.wasm", FOREVER);
let limits = PluginLimits { fuel: u64::MAX / 2, call_timeout: Duration::from_millis(400), ..Default::default() };
let m = LoadedModule::load(&path, &limits, &ModuleTrust::NotRequired, exports::ALL).unwrap();
let l = linker(&m);
let mut inst = m.instantiate(state(&m), &l).unwrap();
let started = Instant::now();
let err = m.call_op(&mut inst, 1, &[]).unwrap_err();
let elapsed = started.elapsed();
assert_eq!(err.bound(), Some(Bound::Deadline), "{err}");
assert!(elapsed < Duration::from_secs(5), "the deadline took {elapsed:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_memory_bomb_is_refused_as_memory_even_though_the_call_returns() {
let dir = scratch("memory");
let path = write_module(&dir, "bomb.wasm", MEMORY_BOMB);
let limits = PluginLimits { max_memory_bytes: 4 * 65_536, ..Default::default() };
let m = LoadedModule::load(&path, &limits, &ModuleTrust::NotRequired, exports::ALL).unwrap();
let l = linker(&m);
let mut inst = m.instantiate(state(&m), &l).unwrap();
let err = m.call_op(&mut inst, 1, &[]).unwrap_err();
assert_eq!(err.bound(), Some(Bound::Memory), "the swallowed -1 was not named: {err}");
assert!(err.to_string().contains(&(4 * 65_536).to_string()), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_zero_limit_never_loads_anything() {
let dir = scratch("zero");
let path = write_module(&dir, "m.wasm", QUIET);
let err = LoadedModule::load(&path, &PluginLimits { fuel: 0, ..Default::default() }, &ModuleTrust::NotRequired, exports::ALL).unwrap_err();
assert!(matches!(err, LoadError::Limits(_)) && err.to_string().contains("fuel"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_host_import_is_a_fault_not_an_empty_answer() {
let dir = scratch("fault");
let path = write_module(&dir, "m.wasm", QUIET);
let m = LoadedModule::load(&path, &PluginLimits::default(), &ModuleTrust::NotRequired, exports::ALL).unwrap();
let mut l = Linker::new(m.engine());
l.func_wrap("env", "host_echo", |mut caller: wasmtime::Caller<'_, State>, _p: u32, _l: u32| -> u64 {
guest_host::module::fault(&mut caller, "the disk is gone")
})
.unwrap();
let mut inst = m.instantiate(state(&m), &l).unwrap();
let err = m.call_op(&mut inst, 7, b"x").unwrap_err();
assert!(matches!(err, CallError::Faults { .. }) && err.to_string().contains("the disk is gone"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "verify-modules")]
#[test]
fn under_a_trust_policy_only_a_signed_module_loads_and_the_signer_is_named() {
use guest_host::verify::sign;
let dir = scratch("trust");
let path = write_module(&dir, "signed.wasm", QUIET);
let (ca_key, ca_der) = sign::dev::mint_ca("guest-host-test-ca").unwrap();
let root = dir.join("root.der");
std::fs::write(&root, &ca_der).unwrap();
let trust = ModuleTrust::required([root]);
let err = LoadedModule::load(&path, &PluginLimits::default(), &trust, exports::ALL).unwrap_err();
assert!(matches!(err, LoadError::Trust(guest_host::TrustError::Unsigned { .. })), "{err}");
let signer = sign::dev::new_ed25519_signer(&ca_key, &ca_der, "release-door").unwrap();
sign::sign_module_file(&path, &signer).unwrap();
let m = LoadedModule::load(&path, &PluginLimits::default(), &trust, exports::ALL).unwrap();
assert_eq!(m.signer(), Some("release-door"));
let _ = std::fs::remove_dir_all(&dir);
}