Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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}"
    );
}