crates/guest-host/tests/no_alloc_before_bounds.rs
//! ★ **A guest's length is checked against its memory BEFORE the host allocates
//! for it** (code-smell review 2026-10-10, stinky #16): `read_mem` and the answer's
//! `read_bytes` made `vec![0u8; len]` from the guest's `len` and only then asked the
//! memory, so any guest could make the host allocate up to 4 GiB per call. A counting
//! allocator records the largest single request the host makes.
#![cfg(feature = "wasmtime")]
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
use guest_host::module::{read_arg, write_to_wasm, GuestState, LoadedModule};
use guest_host::{exports, BoundedLimiter, ModuleTrust, PluginLimits};
use wasmtime::Linker;
struct Counting;
static LARGEST: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
LARGEST.fetch_max(l.size(), Ordering::Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
LARGEST.fetch_max(l.size(), Ordering::Relaxed);
unsafe { System.alloc_zeroed(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 {
LARGEST.fetch_max(n, Ordering::Relaxed);
unsafe { System.realloc(p, l, n) }
}
}
#[global_allocator]
static A: Counting = Counting;
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
}
}
/// Op 8 hands the host import a ~4 GiB argument; op 9 answers a ~4 GiB result.
const LIAR: &str = r#"(module
(import "env" "host_echo" (func $echo (param i32 i32) (result i64)))
(memory (export "memory") 1)
(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)
(if (i32.eq (local.get $op) (i32.const 8))
(then (return (call $echo (i32.const 16) (i32.const 0xFFFFFF00)))))
(i64.or (i64.shl (i64.const 16) (i64.const 32)) (i64.const 0xFFFFFF00)))
)"#;
const LIMIT: usize = 64 << 20;
#[test]
fn a_length_beyond_the_guests_memory_is_refused_before_anything_is_allocated() {
let dir = std::env::temp_dir().join(format!("guest-host-liar-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("liar.wasm");
std::fs::write(&path, wat::parse_str(LIAR).unwrap()).unwrap();
let m = LoadedModule::load(&path, &PluginLimits::default(), &ModuleTrust::NotRequired, exports::ALL).unwrap();
let mut l: Linker<State> = 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 };
write_to_wasm(&mut caller, &arg)
})
.unwrap();
let state = || State { limits: m.limits().limiter(), faults: Vec::new() };
LARGEST.store(0, Ordering::Relaxed);
let mut inst = m.instantiate(state(), &l).unwrap();
let _ = m.call_op(&mut inst, 8, b"x");
let after_arg = LARGEST.load(Ordering::Relaxed);
assert!(after_arg < LIMIT, "a host import's argument: the host asked for {after_arg} bytes");
LARGEST.store(0, Ordering::Relaxed);
let mut inst = m.instantiate(state(), &l).unwrap();
let err = m.call_op(&mut inst, 9, b"x").unwrap_err();
let after_answer = LARGEST.load(Ordering::Relaxed);
assert!(after_answer < LIMIT, "a guest's answer: the host asked for {after_answer} bytes");
assert!(err.to_string().contains("outside linear memory"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}