plugin/host/tests/resource_bounds_are_named_refusals.rs
//! **A module that never returns must be REFUSED, by name — not hang the host.**
//!
//! This file is the guard for the defect it was written against: the wasm host
//! built its runtime with `Engine::default()` and set no bound of any kind, so a
//! `.wasm` in the plugin directory could `loop {}` inside a verb and hold a
//! request thread forever, or `memory.grow` until the OOM reaper took the whole
//! server down. Neither failure had a name, and neither had a test, because a
//! test for it would not have terminated.
//!
//! Every assertion here runs a REAL module through the REAL host. A bound
//! asserted against a mock is a bound that has never fired.
//!
//! The modules are hand-written WAT rather than built from a handler crate on
//! purpose: the point is to express the two things a well-behaved handler never
//! does, and no `PackageHandler` implementation can be made to do them.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use holger_plugin_host::store::DirStore;
use holger_plugin_host::{PluginLimits, PluginPolicy, SharedStore, WasmHandler};
/// A module exporting the whole handler ABI, whose `plugin_manifest` runs `body`.
///
/// Built around the manifest read because that is the FIRST thing
/// `WasmHandler::load` calls: a module that hangs there hangs the boot, which is
/// the worst version of this defect and therefore the one worth asserting.
fn module_wat(body: &str) -> String {
format!(
r#"(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) (i32.const 0))
(func (export "result_len") (result i32) (i32.const 0))
(func (export "plugin_manifest") (result i32)
{body}
(i32.const 0))
(func (export "plugin_fetch") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_put") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_list") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_coordinate_for_path") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_http") (param i32 i32) (result i32) (i32.const 0))
)"#
)
}
/// Never returns. The shape the host had no answer to.
const FOREVER: &str = "(loop $l (br $l))";
/// Asks for 1000 pages (64 MiB) in one go — past any sane ceiling, and past the
/// one this test configures.
const MEMORY_BOMB: &str = "(drop (memory.grow (i32.const 1000)))";
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"holger-plugin-bounds-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).expect("scratch dir");
p
}
/// Write a real `.wasm` for `body` and return its path plus a store to load it
/// over.
fn module(name: &str, body: &str) -> (PathBuf, SharedStore) {
let dir = scratch(name);
let bytes = wat::parse_str(module_wat(body)).expect("the test module does not assemble");
let path = dir.join(format!("{name}.wasm"));
std::fs::write(&path, &bytes).expect("write module");
let store: SharedStore = Arc::new(DirStore::new(dir.join("data")));
(path, store)
}
/// **THE red test.** With no configuration at all, a module that never returns
/// is refused by name in bounded time.
///
/// Before the bounds landed this test did not fail — it never finished, which is
/// exactly why the defect survived: an unbounded host cannot be caught by a test
/// that waits for it.
#[test]
fn a_module_that_loops_forever_is_refused_by_name_under_the_default_policy() {
let (path, store) = module("default-forever", FOREVER);
let started = Instant::now();
let err = WasmHandler::load(&path, store).expect_err("an endless module loaded");
let elapsed = started.elapsed();
// One of the two CPU bounds caught it. WHICH one is a race between the fuel
// tank and the clock and is asserted separately below; what this test
// asserts is that the DEFAULT — the configuration nobody writes — catches it
// at all, and says so.
assert!(
err.contains(holger_errcode::PLUGIN_FUEL_EXHAUSTED.code)
|| err.contains(holger_errcode::PLUGIN_DEADLINE_EXCEEDED.code),
"an endless module was refused, but not by a NAMED resource bound: {err}"
);
assert!(
err.contains("plugin_manifest"),
"the refusal does not name the export that ran away: {err}"
);
assert!(
elapsed < PluginLimits::default().call_timeout + Duration::from_secs(30),
"the refusal took {elapsed:?} — longer than the default deadline plus slack, \
so something other than the bound ended it"
);
}
/// Fuel is the DETERMINISTIC bound: given a small tank and a distant deadline,
/// the refusal is the fuel one, every time, on any machine.
#[test]
fn a_small_fuel_tank_refuses_with_the_fuel_code() {
let (path, store) = module("fuel", FOREVER);
let policy = PluginPolicy::new().with_limits(PluginLimits {
fuel: 1_000_000,
call_timeout: Duration::from_secs(120),
..Default::default()
});
let err = WasmHandler::load_with(&path, store, &policy).expect_err("an endless module loaded");
assert!(
err.contains(holger_errcode::PLUGIN_FUEL_EXHAUSTED.code),
"{err}"
);
assert!(
err.contains("1000000"),
"the refusal does not quote the limit that was hit: {err}"
);
}
/// The wall clock is the BACKSTOP: with fuel it could never exhaust, the
/// deadline is what ends the call — and it ends it under its own name.
///
/// This is the half a fuel-only host would have missed, because fuel does not
/// tick while a module is parked inside a host import.
#[test]
fn a_short_deadline_refuses_with_the_deadline_code() {
let (path, store) = module("deadline", FOREVER);
let policy = PluginPolicy::new().with_limits(PluginLimits {
fuel: u64::MAX,
call_timeout: Duration::from_millis(400),
..Default::default()
});
let started = Instant::now();
let err = WasmHandler::load_with(&path, store, &policy).expect_err("an endless module loaded");
let elapsed = started.elapsed();
assert!(
err.contains(holger_errcode::PLUGIN_DEADLINE_EXCEEDED.code),
"{err}"
);
assert!(
elapsed < Duration::from_secs(20),
"the deadline was 400ms but the call took {elapsed:?}"
);
}
/// A module that grows past its ceiling is refused by name — NOT left to read
/// the `-1` from `memory.grow` and answer "no such artifact".
#[test]
fn a_memory_bomb_is_refused_by_name_rather_than_seen_as_an_empty_answer() {
let (path, store) = module("memory", MEMORY_BOMB);
let policy = PluginPolicy::new().with_limits(PluginLimits {
max_memory_bytes: 1024 * 1024,
..Default::default()
});
let err = WasmHandler::load_with(&path, store, &policy).expect_err("a memory bomb loaded");
assert!(
err.contains(holger_errcode::PLUGIN_MEMORY_LIMIT.code),
"{err}"
);
assert!(
err.contains("1048576"),
"the refusal does not quote the ceiling the operator would have to raise: {err}"
);
}
/// The same bomb under a ceiling that accommodates it is NOT refused — so the
/// test above is measuring the ceiling and not merely the `memory.grow`.
///
/// Without this, a limiter that refused everything would pass every assertion in
/// this file.
#[test]
fn the_same_module_under_a_sufficient_ceiling_is_not_refused_for_memory() {
let (path, store) = module("memory-ok", MEMORY_BOMB);
let policy = PluginPolicy::new().with_limits(PluginLimits {
max_memory_bytes: 256 * 1024 * 1024,
..Default::default()
});
// It still fails to LOAD — `plugin_manifest` returns zero bytes, which is not
// a decodable manifest — but it must not fail for the memory ceiling.
let err = WasmHandler::load_with(&path, store, &policy)
.expect_err("a module returning an empty manifest loaded");
assert!(
!err.contains(holger_errcode::PLUGIN_MEMORY_LIMIT.code),
"a 64 MiB growth was refused under a 256 MiB ceiling: {err}"
);
}
/// A zero in the configuration is refused before anything is compiled. Zero is
/// the shape an unset value arrives in, and reading it as "unlimited" is the one
/// mistake that would put the unbounded host straight back.
#[test]
fn a_zero_limit_is_refused_before_a_module_is_even_read() {
let (path, store) = module("zero", FOREVER);
let policy = PluginPolicy::new().with_limits(PluginLimits {
fuel: 0,
..Default::default()
});
let err = WasmHandler::load_with(&path, store, &policy).expect_err("fuel: 0 was accepted");
assert!(
err.contains(holger_errcode::PLUGIN_LIMIT_MISCONFIGURED.code),
"{err}"
);
}
/// A well-behaved module is unaffected: the bounds refuse runaways, not work.
///
/// Asserted through a module that does the ABI's job honestly enough to be
/// refused for a REASON OF ITS OWN (an undecodable manifest), which proves the
/// call ran to completion instead of being cut off.
#[test]
fn an_ordinary_call_is_not_touched_by_the_bounds() {
let (path, store) = module("ordinary", "(nop)");
let err = WasmHandler::load(&path, store).expect_err("a zero-byte manifest loaded");
for bound in [
holger_errcode::PLUGIN_FUEL_EXHAUSTED.code,
holger_errcode::PLUGIN_DEADLINE_EXCEEDED.code,
holger_errcode::PLUGIN_MEMORY_LIMIT.code,
holger_errcode::PLUGIN_TABLE_LIMIT.code,
] {
assert!(!err.contains(bound), "an ordinary call hit {bound}: {err}");
}
assert!(
err.contains("plugin_manifest"),
"the failure is not the manifest decode it should be: {err}"
);
}