feat(mod-host): Implement native module sandboxing with memory enforcement, OOM handling, and signing enforcement for Minecraft modding simulator

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 06:32:16 -07:00
parent 7af2f53148
commit c52ec4c3f2
11 changed files with 1194 additions and 64 deletions

View file

@ -125,16 +125,6 @@ the manifest parser but rejected at load time with
## Open items deferred to later stages
- **Stage 5c**: native sandbox, signature scheme, pubkey distribution.
- **Stage 5c (carry-over from 5b)**: wire `wasmtime::ResourceLimiter` to
enforce the 16 MiB memory cap. Constant `MEMORY_LIMIT_BYTES` already
defined in `mc-mod-host/src/abi.rs` — limiter just needs hookup at
Store construction.
- **Stage 5b follow-up**: allocator-mode `-2 BufferTooSmall` retry.
`BufferLayout::Allocator` already holds the `alloc`/`dealloc` typed
funcs, so the retry-with-`out_cap * 2` loop is a localized change.
Static-mode cannot retry (globals are immutable) — its `-2` will
remain "log + empty actions" forever; this is acceptable since
static-mode mods control their own buffer size at compile time.
- **Stage 5d**: hot-reload (watch mod dir, re-instantiate on change).
- **Stage 6**: actual learned-mod build pipeline + the OSS reference repo URL.
- **Stage 9**: Steam Workshop integration (out of v1).

View file

@ -71,8 +71,10 @@ pub const EPOCH_DEADLINE_TICKS: u64 = 1;
/// Maximum linear memory the guest may grow to, in bytes (16 MiB).
///
/// Currently advisory — wired through `wasmtime::ResourceLimiter` in a
/// later patch (TODO Stage 5c).
/// Enforced via [`crate::MemoryLimiter`], a `wasmtime::ResourceLimiter`
/// wired onto every per-module [`wasmtime::Store`] in
/// [`crate::WasmHost::load_module`]. Guests requesting more land a
/// `memory.grow == -1`.
pub const MEMORY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
/// Validated `"<name> <semver>"` identity string read from the guest's

View file

@ -3,12 +3,18 @@
//! Stage 5a established the proof-of-load pipeline (`call_proof`). Stage
//! 5b adds [`WasmAiController`], a [`mc_player_api::controllers::AiController`]
//! implementation that loads a guest WASM module and dispatches each AI
//! turn through it. See `docs/modding/abi-decisions.md` for the locked
//! turn through it. Stage 5c adds the native (`.so`/`.dylib`/`.dll`)
//! sandbox path via [`NativeAiController`] + ed25519 signature
//! verification. See `docs/modding/abi-decisions.md` for the locked
//! contract this implements.
pub mod abi;
pub mod manifest;
pub mod native_loader;
pub mod signing;
pub mod wasm_controller;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
@ -21,8 +27,43 @@ pub use abi::{
parse_ident, DecideErrorCode, IdentFormat, DEFAULT_BUFFER_CAP, EPOCH_DEADLINE_TICKS,
FUEL_BUDGET, MAX_BUFFER_CAP, MEMORY_LIMIT_BYTES, STATE_VERSION_PREFIX,
};
pub use manifest::{Manifest, SandboxKindWire};
pub use native_loader::{load_native_mod, NativeAiController};
pub use signing::{verify_native_signature, ENGINE_PUBKEY};
pub use wasm_controller::WasmAiController;
/// `wasmtime::ResourceLimiter` impl that caps a guest store's linear memory
/// growth to [`MEMORY_LIMIT_BYTES`] (16 MiB) per
/// `docs/modding/abi-decisions.md` §"Memory cap".
///
/// Tables and instance counts are left at wasmtime defaults — only linear
/// memory is bounded, because that is the only resource the locked ABI
/// limits explicitly.
pub struct MemoryLimiter {
/// Maximum number of bytes the guest's linear memory may grow to.
pub max_bytes: usize,
}
impl wasmtime::ResourceLimiter for MemoryLimiter {
fn memory_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> wasmtime::Result<bool> {
Ok(desired <= self.max_bytes)
}
fn table_growing(
&mut self,
_current: usize,
_desired: usize,
_maximum: Option<usize>,
) -> wasmtime::Result<bool> {
Ok(true)
}
}
/// Errors that can arise while loading or invoking a guest module.
#[derive(Debug, Error)]
pub enum ModError {
@ -56,9 +97,34 @@ pub enum ModError {
#[error("failed to build wasmtime engine: {0}")]
EngineConfig(#[source] wasmtime::Error),
/// Native sandbox path is not implemented in Stage 5b (deferred to 5c).
#[error("native sandbox is not yet implemented")]
NativeSandboxNotYetImplemented,
/// Manifest claims `sandbox: "native"` but no `signature` field was set.
#[error("native sandbox mod is missing required signature field")]
SignatureRequired,
/// ed25519 signature did not verify, or the embedded public key
/// could not be decoded.
#[error("native sandbox signature verification failed: {0}")]
SignatureInvalid(#[source] ed25519_dalek::SignatureError),
/// `manifest.json` could not be parsed as JSON / did not match the
/// expected schema.
#[error("failed to parse manifest.json: {0}")]
ManifestParse(#[source] serde_json::Error),
/// Manifest parsed but a cross-field invariant failed (bad `kind`,
/// missing signature on a native mod, bad semver, ...).
#[error("invalid manifest: {0}")]
ManifestInvalid(String),
/// `libloading::Library::new` or symbol lookup failed for a native
/// mod payload.
#[error("failed to load native mod library: {0}")]
NativeLoadFailed(#[source] libloading::Error),
/// A required FFI symbol (`init`, `decide_turn`, `ident_str`) was
/// not exported by the native mod payload.
#[error("native mod is missing required symbol: {0}")]
MissingNativeSymbol(String),
}
/// Opaque handle to a loaded WASM module instance.
@ -68,7 +134,7 @@ pub enum ModError {
/// because `wasmtime` requires `&mut Store` for invocation, while we
/// want `&self` ergonomics on [`WasmHost`].
pub struct WasmModuleHandle {
pub(crate) store: Mutex<Store<()>>,
pub(crate) store: Mutex<Store<MemoryLimiter>>,
pub(crate) instance: Instance,
}
@ -146,7 +212,15 @@ impl WasmHost {
/// [`ModError::Instantiate`] if instantiation fails.
pub fn load_module(&self, wasm_bytes: &[u8]) -> Result<WasmModuleHandle, ModError> {
let module = Module::new(&self.engine, wasm_bytes).map_err(ModError::Compile)?;
let mut store = Store::new(&self.engine, ());
let mut store = Store::new(
&self.engine,
MemoryLimiter {
max_bytes: MEMORY_LIMIT_BYTES,
},
);
// Wire the resource limiter so `memory.grow` past 16 MiB returns -1
// instead of succeeding. Closure projects &mut T -> &mut dyn limiter.
store.limiter(|state| state);
// Pre-arm fuel/epoch so probe calls like proof_double don't fault.
store
.set_fuel(FUEL_BUDGET)
@ -198,6 +272,60 @@ pub fn register_wasm_mod(host: Arc<WasmHost>, wasm_bytes: &[u8]) -> Result<Strin
Ok(id)
}
/// Read `manifest_path` (a `manifest.json`), validate, signature-verify
/// the referenced native binary, `dlopen` it, and register the
/// resulting [`NativeAiController`] under its `controller_id`.
///
/// `manifest_path` must point at the `manifest.json` itself; the
/// payload binary is resolved relative to that file via
/// `manifest.entrypoint` (or `controller.so` if unset).
///
/// Returns the registered `controller_id` on success.
///
/// # Errors
/// Propagates any [`ModError`] from manifest parse / validation,
/// signature verification, library loading, symbol resolution, or
/// `init()` rejection.
pub fn register_native_mod(manifest_path: &Path) -> Result<String, ModError> {
let manifest_bytes = std::fs::read(manifest_path).map_err(|e| {
ModError::ManifestInvalid(format!("cannot read {manifest_path:?}: {e}"))
})?;
let manifest = Manifest::parse(&manifest_bytes)?;
manifest.validate()?;
let manifest_dir = manifest_path
.parent()
.ok_or_else(|| ModError::ManifestInvalid("manifest_path has no parent dir".into()))?;
let default_name = default_native_filename();
let entry = manifest
.entrypoint
.as_deref()
.unwrap_or(default_name);
let binary_path = manifest_dir.join(entry);
let controller = native_loader::load_native_mod(&binary_path, &manifest)?;
let id = controller.ident_id().to_owned();
register_controller(id.clone(), Box::new(controller));
Ok(id)
}
#[cfg(target_os = "linux")]
const fn default_native_filename() -> &'static str {
"controller.so"
}
#[cfg(target_os = "macos")]
const fn default_native_filename() -> &'static str {
"controller.dylib"
}
#[cfg(target_os = "windows")]
const fn default_native_filename() -> &'static str {
"controller.dll"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
const fn default_native_filename() -> &'static str {
"controller.so"
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -0,0 +1,221 @@
//! `manifest.json` parser and validator for AI-controller mods (Stage 5c).
//!
//! Companion to `docs/modding/ai-controller.md`. Every mod ships a
//! `manifest.json` next to its payload (`.wasm` or platform-native shared
//! library) describing the mod's identity, sandbox kind, and — when
//! `sandbox == "native"` — the ed25519 signature over the payload bytes.
//!
//! The parser is deliberately strict: unknown sandbox kinds are rejected
//! at deserialisation, native mods without a signature are rejected at
//! validation, and the `kind` discriminator must currently equal
//! `"ai_controller"` (other mod kinds will widen this later).
use serde::Deserialize;
use crate::ModError;
/// Sandbox the mod manifest requests the host load it under.
///
/// Wire shape only — the canonical runtime enum is
/// [`mc_player_api::controllers::SandboxKind`]. We keep a manifest-local
/// copy so the parser doesn't pull `mc-player-api` into its serde graph
/// (the runtime enum doesn't impl [`Deserialize`] today, and adding it
/// would propagate `serde` through every consumer).
#[derive(Deserialize, PartialEq, Eq, Debug, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum SandboxKindWire {
/// Load via `wasmtime` (Stage 5a/5b path).
Wasm,
/// Load via `libloading` after ed25519 signature verification (Stage 5c).
Native,
}
/// Parsed `manifest.json` payload.
///
/// Field set tracks `docs/modding/ai-controller.md` §"manifest schema".
/// Unknown JSON fields are accepted (forward-compat) and silently
/// dropped; required fields below MUST be present.
#[derive(Deserialize, Debug, Clone)]
pub struct Manifest {
/// Globally unique mod id (`"learned-duel"`, `"acme/megamod"`, ...).
pub id: String,
/// Human-readable mod name shown in UI listings.
pub name: String,
/// Author / maintainer string for credits + crash reports.
pub author: String,
/// Semver string (`"1.0.0"`, `"1.0.0-rc1"`). Validated by
/// [`Manifest::validate`].
pub version: String,
/// Mod kind discriminator. Currently must equal `"ai_controller"`.
pub kind: String,
/// Controller id registered into `mc_player_api::controllers`
/// (`"learned:duel-v1"`, `"mod:acme-rusher"`, ...).
pub controller_id: String,
/// Sandbox kind requested. `native` requires `signature` to be set.
pub sandbox: SandboxKindWire,
/// Base64-encoded 64-byte ed25519 signature over SHA-256 of the
/// payload bytes. Required when `sandbox == Native`; ignored
/// otherwise.
#[serde(default)]
pub signature: Option<String>,
/// Filename of the payload, relative to the manifest's directory.
/// Defaults to `controller.wasm` / `controller.so` if omitted by
/// the loader (manifest itself need not specify it).
#[serde(default)]
pub entrypoint: Option<String>,
}
impl Manifest {
/// Decode `bytes` as UTF-8 JSON into a [`Manifest`].
///
/// # Errors
/// Returns [`ModError::ManifestParse`] when `bytes` is not valid
/// JSON or does not match the manifest schema.
pub fn parse(bytes: &[u8]) -> Result<Self, ModError> {
serde_json::from_slice::<Self>(bytes).map_err(ModError::ManifestParse)
}
/// Validate cross-field invariants the deserializer can't catch
/// alone.
///
/// Currently enforces:
/// * `kind == "ai_controller"` — other mod kinds will land in
/// later milestones.
/// * `version` parses as `MAJOR.MINOR.PATCH(-prerelease)?` with
/// only the characters the ABI memo permits.
/// * `sandbox == Native` ⇒ `signature` is `Some(_)`.
/// * `id` and `controller_id` are non-empty.
///
/// # Errors
/// Returns [`ModError::ManifestInvalid`] or
/// [`ModError::SignatureRequired`] with a description of the first
/// failed check.
pub fn validate(&self) -> Result<(), ModError> {
if self.kind != "ai_controller" {
return Err(ModError::ManifestInvalid(format!(
"unsupported kind {:?} (expected \"ai_controller\")",
self.kind
)));
}
if self.id.trim().is_empty() {
return Err(ModError::ManifestInvalid("id must be non-empty".into()));
}
if self.controller_id.trim().is_empty() {
return Err(ModError::ManifestInvalid(
"controller_id must be non-empty".into(),
));
}
if !is_semver(&self.version) {
return Err(ModError::ManifestInvalid(format!(
"version {:?} is not a valid semver string",
self.version
)));
}
if self.sandbox == SandboxKindWire::Native && self.signature.is_none() {
return Err(ModError::SignatureRequired);
}
Ok(())
}
}
/// Minimal semver check matching the ident regex used by the WASM ABI
/// (`docs/modding/abi-decisions.md` §"__ident_ptr / __ident_len").
fn is_semver(s: &str) -> bool {
let mut parts = s.splitn(2, '-');
let core = parts.next().unwrap_or("");
let pre = parts.next();
let nums: Vec<&str> = core.split('.').collect();
if nums.len() != 3 {
return false;
}
for n in &nums {
if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) {
return false;
}
}
if let Some(p) = pre {
if p.is_empty()
|| !p
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-')
{
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
const WASM_MANIFEST: &str = r#"{
"id": "noop",
"name": "Noop",
"author": "tests",
"version": "0.1.0",
"kind": "ai_controller",
"controller_id": "mod:noop",
"sandbox": "wasm"
}"#;
const NATIVE_NO_SIG: &str = r#"{
"id": "rusher",
"name": "Rusher",
"author": "tests",
"version": "1.0.0",
"kind": "ai_controller",
"controller_id": "mod:rusher",
"sandbox": "native"
}"#;
const NATIVE_WITH_SIG: &str = r#"{
"id": "rusher",
"name": "Rusher",
"author": "tests",
"version": "1.0.0-rc1",
"kind": "ai_controller",
"controller_id": "mod:rusher",
"sandbox": "native",
"signature": "AAAA",
"entrypoint": "controller.so"
}"#;
#[test]
fn parses_minimal_wasm_manifest() {
let m = Manifest::parse(WASM_MANIFEST.as_bytes()).expect("parse");
m.validate().expect("validate");
assert_eq!(m.sandbox, SandboxKindWire::Wasm);
assert!(m.signature.is_none());
}
#[test]
fn rejects_native_without_signature() {
let m = Manifest::parse(NATIVE_NO_SIG.as_bytes()).expect("parse");
let err = m.validate().expect_err("must reject");
assert!(matches!(err, ModError::SignatureRequired));
}
#[test]
fn accepts_native_with_signature() {
let m = Manifest::parse(NATIVE_WITH_SIG.as_bytes()).expect("parse");
m.validate().expect("validate");
assert_eq!(m.sandbox, SandboxKindWire::Native);
assert_eq!(m.signature.as_deref(), Some("AAAA"));
assert_eq!(m.entrypoint.as_deref(), Some("controller.so"));
}
#[test]
fn rejects_bad_kind() {
let json = WASM_MANIFEST.replace("ai_controller", "world_mod");
let m = Manifest::parse(json.as_bytes()).expect("parse");
assert!(matches!(m.validate(), Err(ModError::ManifestInvalid(_))));
}
#[test]
fn rejects_bad_semver() {
let json = WASM_MANIFEST.replace("0.1.0", "0.1");
let m = Manifest::parse(json.as_bytes()).expect("parse");
assert!(matches!(m.validate(), Err(ModError::ManifestInvalid(_))));
}
}

View file

@ -0,0 +1,253 @@
//! Native-mode AI controller loader.
//!
//! Loads a signed `.so` / `.dylib` / `.dll` via [`libloading`] and
//! exposes it as an [`AiController`]. The wire surface mirrors the WASM
//! ABI (`docs/modding/abi-decisions.md` §"Native sandbox", pattern A):
//!
//! ```text
//! extern "C" {
//! fn init() -> i32;
//! fn ident_str() -> *const c_char;
//! fn decide_turn(
//! state_ptr: *const u8, state_len: usize,
//! slot: u8, seed: u64,
//! out_ptr: *mut u8, out_cap: usize,
//! ) -> isize;
//! }
//! ```
//!
//! Native mods run in-process with full host privileges. The host
//! refuses to `dlopen` an unsigned or tampered binary — see
//! [`crate::signing::verify_native_signature`].
use std::ffi::{c_char, CStr};
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use libloading::{Library, Symbol};
use mc_ai::evaluator::ScoringWeights;
use mc_ai::tactical::{Action, TacticalState};
use mc_player_api::controllers::{AiController, AiControllerIdent, SandboxKind};
use crate::abi::{self, STATE_VERSION_PREFIX};
use crate::manifest::{Manifest, SandboxKindWire};
use crate::signing::verify_native_signature;
use crate::ModError;
/// `init() -> i32`. Non-zero return ⇒ ready, zero ⇒ self-reject.
type InitFn = unsafe extern "C" fn() -> i32;
/// `ident_str() -> *const c_char`. Returns a NUL-terminated UTF-8
/// string formatted `"<name> <semver>"`. The pointer must live for the
/// lifetime of the library.
type IdentFn = unsafe extern "C" fn() -> *const c_char;
/// `decide_turn(state_ptr, state_len, slot, seed, out_ptr, out_cap) -> isize`.
///
/// Returns bytes written to `out_ptr` (positive), `0` for an empty plan,
/// or a negative [`crate::abi::DecideErrorCode`] discriminant on error.
type DecideFn = unsafe extern "C" fn(
state_ptr: *const u8,
state_len: usize,
slot: u8,
seed: u64,
out_ptr: *mut u8,
out_cap: usize,
) -> isize;
/// Static scratch capacity for `decide_turn` output, mirroring
/// [`crate::abi::DEFAULT_BUFFER_CAP`].
const OUT_CAP: usize = crate::abi::DEFAULT_BUFFER_CAP as usize;
/// An [`AiController`] backed by a signed native shared library.
///
/// The [`Library`] is held for the lifetime of the controller; dropping
/// the controller unloads the library. The scratch output buffer is
/// guarded by a [`Mutex`] because `decide_turn` writes into it and the
/// controller trait is `Send + Sync`.
pub struct NativeAiController {
// Library MUST outlive every function pointer below — declared
// first so it drops last (Rust drops fields in declaration order
// for `Drop` impls, but the implicit drop order is reverse, which
// is what we want here).
#[allow(dead_code)]
lib: Library,
decide: DecideFn,
ident: AiControllerIdent,
out_buf: Mutex<Vec<u8>>,
}
impl NativeAiController {
/// Registered id, used as the registry key.
#[must_use]
pub fn ident_id(&self) -> &str {
&self.ident.id
}
}
impl AiController for NativeAiController {
fn decide_turn(
&self,
state: &TacticalState,
slot: u8,
_weights: &ScoringWeights,
seed: u64,
) -> Vec<Action> {
let payload = match postcard::to_allocvec(state) {
Ok(p) => p,
Err(_) => return Vec::new(),
};
let mut framed: Vec<u8> = Vec::with_capacity(4 + payload.len());
framed.extend_from_slice(&STATE_VERSION_PREFIX.to_be_bytes());
framed.extend_from_slice(&payload);
let mut out = match self.out_buf.lock() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
// SAFETY: `decide` is the FFI function we resolved at load
// time. `framed` and `out` are valid host allocations for the
// duration of the call. The mod sees `&[u8]` / `&mut [u8]`
// semantics; we do not re-enter the controller from within
// this call.
let rc = unsafe {
(self.decide)(
framed.as_ptr(),
framed.len(),
slot,
seed,
out.as_mut_ptr(),
out.len(),
)
};
if rc <= 0 {
// Empty plan or negative DecideErrorCode discriminant.
let _code = abi::DecideErrorCode::from_raw(rc as i32);
return Vec::new();
}
let written = rc as usize;
if written > out.len() {
return Vec::new();
}
postcard::from_bytes::<Vec<Action>>(&out[..written]).unwrap_or_default()
}
fn ident(&self) -> AiControllerIdent {
self.ident.clone()
}
}
/// Read `path` from disk, verify the manifest's signature against the
/// bytes, then `dlopen` the library and bind the exported symbols.
///
/// The manifest MUST have `sandbox == "native"` and a non-empty
/// `signature` field — callers are expected to run
/// [`Manifest::validate`] first.
///
/// # Errors
/// * [`ModError::ManifestInvalid`] — manifest sandbox kind is not native.
/// * [`ModError::SignatureRequired`] — manifest signature field is empty.
/// * [`ModError::SignatureInvalid`] — signature did not verify against
/// [`crate::signing::ENGINE_PUBKEY`].
/// * [`ModError::NativeLoadFailed`] — `dlopen` or symbol resolution
/// failed.
/// * [`ModError::MissingNativeSymbol`] — one of `init`, `ident_str`,
/// or `decide_turn` is not exported by the binary.
/// * [`ModError::InitRejected`] — `init()` returned 0.
/// * [`ModError::InvalidIdent`] — `ident_str()` returned a string that
/// did not match the locked ident format.
pub fn load_native_mod(path: &Path, manifest: &Manifest) -> Result<NativeAiController, ModError> {
if manifest.sandbox != SandboxKindWire::Native {
return Err(ModError::ManifestInvalid(
"load_native_mod requires sandbox==\"native\"".into(),
));
}
let sig_b64 = manifest
.signature
.as_deref()
.ok_or(ModError::SignatureRequired)?;
let bytes = fs::read(path).map_err(|e| {
ModError::ManifestInvalid(format!("failed to read native binary {path:?}: {e}"))
})?;
verify_native_signature(&bytes, sig_b64)?;
// SAFETY: `Library::new` is unsafe because the loaded code runs
// any constructors / `.init_array` entries in this process with
// full host privileges. The signature check above is the gate
// that authorises that; do NOT remove it.
let lib = unsafe { Library::new(path) }.map_err(ModError::NativeLoadFailed)?;
// SAFETY: We bind the three exports the ABI requires. Each
// `Symbol<T>` borrow lifetime is tied to `lib`; we copy the raw
// function pointer out before `lib` moves into the returned
// struct, which is fine because the library outlives every
// pointer (declaration order in `NativeAiController`).
let (init_fn, decide_fn, ident_fn): (InitFn, DecideFn, IdentFn) = unsafe {
let init: Symbol<InitFn> = lib
.get(b"init\0")
.map_err(|_| ModError::MissingNativeSymbol("init".into()))?;
let decide: Symbol<DecideFn> = lib
.get(b"decide_turn\0")
.map_err(|_| ModError::MissingNativeSymbol("decide_turn".into()))?;
let ident: Symbol<IdentFn> = lib
.get(b"ident_str\0")
.map_err(|_| ModError::MissingNativeSymbol("ident_str".into()))?;
(*init, *decide, *ident)
};
// SAFETY: `init_fn` is a fresh FFI binding; calling it once at
// load time mirrors the WASM contract.
let init_rc = unsafe { init_fn() };
if init_rc == 0 {
return Err(ModError::InitRejected);
}
// SAFETY: `ident_fn` returns a `*const c_char` the mod guarantees
// is NUL-terminated, valid UTF-8, and lives for the library's
// lifetime. We copy the bytes immediately so subsequent unloads
// don't dangle.
let ident_str: String = unsafe {
let ptr = ident_fn();
if ptr.is_null() {
return Err(ModError::InvalidIdent("ident_str returned NULL".into()));
}
CStr::from_ptr(ptr)
.to_str()
.map_err(|_| ModError::InvalidIdent("ident_str non-utf8".into()))?
.to_owned()
};
let parsed = abi::parse_ident(&ident_str)?;
// Cross-check manifest claims against what the binary self-reports.
// Mismatch is treated as a manifest error so the loader surfaces a
// clear "wrong file paired with manifest" message.
if parsed.name != manifest.controller_id && parsed.name != manifest.id {
return Err(ModError::ManifestInvalid(format!(
"ident name {:?} does not match manifest id {:?} or controller_id {:?}",
parsed.name, manifest.id, manifest.controller_id
)));
}
if parsed.version != manifest.version {
return Err(ModError::ManifestInvalid(format!(
"ident version {:?} does not match manifest version {:?}",
parsed.version, manifest.version
)));
}
let ident = AiControllerIdent {
id: manifest.controller_id.clone(),
version: manifest.version.clone(),
sandbox: SandboxKind::Native,
};
Ok(NativeAiController {
lib,
decide: decide_fn,
ident,
out_buf: Mutex::new(vec![0u8; OUT_CAP]),
})
}

View file

@ -0,0 +1,141 @@
//! ed25519 signature verification for native AI-controller mods.
//!
//! Native mods (`.so` / `.dll` / `.dylib`) execute in-process with full
//! host privileges, so the host MUST refuse to `dlopen` an unsigned or
//! tampered binary. The signing protocol is documented in
//! `docs/modding/abi-decisions.md` §"Native sandbox":
//!
//! 1. The mod author SHA-256s the payload bytes.
//! 2. The author signs the 32-byte digest with the engine's release
//! ed25519 private key (a Magic Civilization Team key — never
//! shipped with the engine).
//! 3. The author base64-encodes the 64-byte signature and writes it
//! into `manifest.json#/signature`.
//! 4. At load time the host re-hashes the bytes on disk and verifies
//! against [`ENGINE_PUBKEY`].
//!
//! TRACKED: The public key embedded below is a build-time constant.
//! Release pipelines replace it via a `build.rs` step (Stage 6 follow-up,
//! `docs/modding/abi-decisions.md` §"Native sandbox" deferral note) so
//! debug, beta, and release branches can have independent signing keys.
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use ed25519_dalek::{Signature, Verifier, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH};
use sha2::{Digest, Sha256};
use crate::ModError;
/// Engine release ed25519 public key (32 bytes).
///
/// TRACKED: Currently a zero-byte constant — verification against it
/// will always fail because there is no known private key whose
/// corresponding public key is all zeros. This is intentional: no
/// untrusted native mod can pass verification until the release
/// pipeline (Stage 6) overrides this constant with a real
/// Magic Civilization Team key. See
/// `docs/modding/abi-decisions.md` §"Native sandbox" for the
/// distribution decision.
///
/// Tests use [`tests::test_keypair`] via [`verify_with_key`] so they
/// don't depend on this constant.
pub const ENGINE_PUBKEY: [u8; PUBLIC_KEY_LENGTH] = [0u8; PUBLIC_KEY_LENGTH];
/// Verify `sig_b64` against the SHA-256 digest of `binary_bytes`, using
/// the engine release [`ENGINE_PUBKEY`].
///
/// Production entry point — `native_loader::load_native_mod` calls this
/// before `dlopen`. The base64 must decode to exactly
/// [`SIGNATURE_LENGTH`] (64) bytes.
///
/// # Errors
/// * [`ModError::SignatureInvalid`] — public-key decode failure or the
/// signature did not verify.
/// * [`ModError::ManifestInvalid`] — base64 decode failed or the
/// signature was the wrong length.
pub fn verify_native_signature(binary_bytes: &[u8], sig_b64: &str) -> Result<(), ModError> {
let key = VerifyingKey::from_bytes(&ENGINE_PUBKEY).map_err(ModError::SignatureInvalid)?;
verify_with_key(&key, binary_bytes, sig_b64)
}
/// Verify `sig_b64` against `binary_bytes` using a caller-supplied
/// [`VerifyingKey`]. Exposed for unit tests so they don't have to round
/// through the release [`ENGINE_PUBKEY`].
///
/// # Errors
/// See [`verify_native_signature`].
pub fn verify_with_key(
key: &VerifyingKey,
binary_bytes: &[u8],
sig_b64: &str,
) -> Result<(), ModError> {
let sig_bytes = B64.decode(sig_b64.as_bytes()).map_err(|e| {
ModError::ManifestInvalid(format!("signature base64 decode failed: {e}"))
})?;
if sig_bytes.len() != SIGNATURE_LENGTH {
return Err(ModError::ManifestInvalid(format!(
"signature must be {SIGNATURE_LENGTH} bytes, got {}",
sig_bytes.len()
)));
}
let mut sig_arr = [0u8; SIGNATURE_LENGTH];
sig_arr.copy_from_slice(&sig_bytes);
let sig = Signature::from_bytes(&sig_arr);
let digest = Sha256::digest(binary_bytes);
key.verify(digest.as_slice(), &sig)
.map_err(ModError::SignatureInvalid)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH};
/// Deterministic test signing key constructed from a constant
/// 32-byte secret. Stable across runs.
pub(crate) fn test_keypair() -> SigningKey {
let secret: [u8; SECRET_KEY_LENGTH] = [
0x5A, 0x4D, 0x43, 0x41, 0x57, 0x49, 0x4C, 0x4F, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x17, 0x18,
];
SigningKey::from_bytes(&secret)
}
#[test]
fn signature_round_trips_against_test_key() {
let sk = test_keypair();
let pk = sk.verifying_key();
let payload = b"hello native mod";
let digest = Sha256::digest(payload);
let sig = sk.sign(digest.as_slice());
let sig_b64 = B64.encode(sig.to_bytes());
verify_with_key(&pk, payload, &sig_b64).expect("valid signature must verify");
}
#[test]
fn tampered_payload_fails_verification() {
let sk = test_keypair();
let pk = sk.verifying_key();
let payload = b"hello native mod";
let digest = Sha256::digest(payload);
let sig = sk.sign(digest.as_slice());
let sig_b64 = B64.encode(sig.to_bytes());
let tampered = b"hello NATIVE mod";
assert!(matches!(
verify_with_key(&pk, tampered, &sig_b64),
Err(ModError::SignatureInvalid(_))
));
}
#[test]
fn wrong_length_signature_rejected() {
let sk = test_keypair();
let pk = sk.verifying_key();
let short = B64.encode([0u8; 10]);
assert!(matches!(
verify_with_key(&pk, b"x", &short),
Err(ModError::ManifestInvalid(_))
));
}
}

View file

@ -5,7 +5,7 @@
//! All ABI choices come from `docs/modding/abi-decisions.md`. If you
//! find yourself needing to change one, update the memo first.
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use mc_ai::evaluator::ScoringWeights;
use mc_ai::tactical::{Action, TacticalState};
@ -15,7 +15,7 @@ use wasmtime::{Memory, TypedFunc, Val};
use crate::abi::{
self, DecideErrorCode, EPOCH_DEADLINE_TICKS, FUEL_BUDGET, MAX_BUFFER_CAP, STATE_VERSION_PREFIX,
};
use crate::{ModError, WasmHost, WasmModuleHandle};
use crate::{MemoryLimiter, ModError, WasmHost, WasmModuleHandle};
/// How the guest exposes its scratch buffers.
///
@ -31,10 +31,11 @@ enum BufferLayout {
in_cap: u32,
out_ptr: i32,
out_cap: u32,
// alloc/dealloc retained so we can resize on -2 retries (TODO).
#[allow(dead_code)]
/// `alloc(size) -> ptr` from the guest. Retained so the host can
/// resize the output buffer on a `-2 BufferTooSmall` retry.
alloc: TypedFunc<u32, i32>,
#[allow(dead_code)]
/// `dealloc(ptr, size)` from the guest. Called before the
/// resize alloc to release the old buffer.
dealloc: TypedFunc<(i32, u32), ()>,
},
/// Four static globals (`__input_ptr`, `__input_cap`, `__output_ptr`,
@ -85,7 +86,11 @@ pub struct WasmAiController {
host: Arc<WasmHost>,
handle: WasmModuleHandle,
memory: Memory,
buffers: BufferLayout,
/// Buffer layout is mutexed because an allocator-mode `-2` retry
/// frees the old output buffer and replaces it with a 2x one; the new
/// `out_ptr` / `out_cap` must persist across `decide_turn` calls so
/// the next call doesn't write to freed memory.
buffers: Mutex<BufferLayout>,
decide: DecideTurnFunc,
ident: AiControllerIdent,
}
@ -161,7 +166,7 @@ impl WasmAiController {
host,
handle,
memory,
buffers,
buffers: Mutex::new(buffers),
decide,
ident,
})
@ -176,7 +181,7 @@ impl WasmAiController {
fn read_i32_global(
handle: &WasmModuleHandle,
store: &mut wasmtime::Store<()>,
store: &mut wasmtime::Store<MemoryLimiter>,
name: &str,
) -> Result<i32, ModError> {
let g = handle
@ -193,7 +198,7 @@ fn read_i32_global(
fn read_optional_u32_global(
handle: &WasmModuleHandle,
store: &mut wasmtime::Store<()>,
store: &mut wasmtime::Store<MemoryLimiter>,
name: &str,
) -> Option<u32> {
let g = handle.instance.get_global(&mut *store, name)?;
@ -205,7 +210,7 @@ fn read_optional_u32_global(
fn detect_buffer_layout(
handle: &WasmModuleHandle,
store: &mut wasmtime::Store<()>,
store: &mut wasmtime::Store<MemoryLimiter>,
) -> Result<BufferLayout, ModError> {
// Allocator mode requires BOTH alloc + dealloc.
let alloc: Option<TypedFunc<u32, i32>> = handle
@ -280,68 +285,78 @@ impl AiController for WasmAiController {
}
};
let total_len = 4usize + payload.len();
let in_cap = self.buffers.in_cap() as usize;
if total_len > in_cap {
// Input doesn't fit; empty plan.
return Vec::new();
}
let mut buffers = match self.buffers.lock() {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let mut store = match self.handle.store.lock() {
Ok(s) => s,
Err(_) => return Vec::new(),
};
// Write 4-byte BE version prefix + postcard payload into input buffer.
let mem = self.memory.data_mut(&mut *store);
let in_off = self.buffers.in_ptr() as usize;
if in_off
.checked_add(total_len)
.map_or(true, |end| end > mem.len())
{
if total_len > buffers.in_cap() as usize {
return Vec::new();
}
mem[in_off..in_off + 4].copy_from_slice(&STATE_VERSION_PREFIX.to_be_bytes());
mem[in_off + 4..in_off + total_len].copy_from_slice(&payload);
// Deterministic per-call: reset fuel + epoch.
if store.set_fuel(FUEL_BUDGET).is_err() {
// First attempt — write input then call decide_turn.
if !write_input(&self.memory, &mut *store, &*buffers, &payload, total_len) {
return Vec::new();
}
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let rc = match self.decide.call(
let rc = match call_decide(
&self.decide,
&mut *store,
(
self.buffers.in_ptr(),
total_len as i32,
u32::from(slot),
seed,
self.buffers.out_ptr(),
self.buffers.out_cap() as i32,
),
&*buffers,
total_len as i32,
slot,
seed,
) {
Ok(rc) => rc,
Err(_trap) => {
// Trap (fuel/epoch/memory). Empty plan.
return Vec::new();
Some(rc) => rc,
None => return Vec::new(),
};
let rc = if rc == DecideErrorCode::BufferTooSmall as i32 {
// Allocator-mode retry: dealloc old out buffer, alloc 2x, rewrite
// state (guest may have clobbered the input region), and call
// again exactly once. Static mode cannot retry — globals are
// immutable — so it falls through to the negative-return path.
match retry_buffer_too_small(&mut *buffers, &mut *store) {
Some(()) => {
if !write_input(&self.memory, &mut *store, &*buffers, &payload, total_len) {
return Vec::new();
}
match call_decide(
&self.decide,
&mut *store,
&*buffers,
total_len as i32,
slot,
seed,
) {
Some(rc2) => rc2,
None => return Vec::new(),
}
}
None => rc,
}
} else {
rc
};
if rc == 0 {
return Vec::new();
}
if rc < 0 {
// Map for completeness even though we currently treat all
// negatives the same. Retry-on-BufferTooSmall in allocator
// mode is a future TODO; static mode cannot retry.
// Map for completeness even though we currently treat remaining
// negatives the same (including a second `-2` after retry).
let _code = DecideErrorCode::from_raw(rc);
return Vec::new();
}
let written = rc as u32;
if written > self.buffers.out_cap() {
if written > buffers.out_cap() {
return Vec::new();
}
let out_off = self.buffers.out_ptr() as usize;
let out_off = buffers.out_ptr() as usize;
let mem = self.memory.data(&*store);
let Some(end) = out_off.checked_add(written as usize) else {
return Vec::new();
@ -359,3 +374,100 @@ impl AiController for WasmAiController {
self.ident.clone()
}
}
/// Copy the 4-byte BE [`STATE_VERSION_PREFIX`] and the postcard payload
/// into the guest's input buffer. Returns `false` on bounds failure.
fn write_input(
memory: &Memory,
store: &mut wasmtime::Store<MemoryLimiter>,
buffers: &BufferLayout,
payload: &[u8],
total_len: usize,
) -> bool {
let mem = memory.data_mut(&mut *store);
let in_off = buffers.in_ptr() as usize;
let Some(end) = in_off.checked_add(total_len) else {
return false;
};
if end > mem.len() {
return false;
}
mem[in_off..in_off + 4].copy_from_slice(&STATE_VERSION_PREFIX.to_be_bytes());
mem[in_off + 4..in_off + total_len].copy_from_slice(payload);
true
}
/// Reset per-call fuel + epoch and invoke `decide_turn`. Returns the
/// guest's `i32` return code or `None` on a trap / set_fuel failure.
fn call_decide(
decide: &DecideTurnFunc,
store: &mut wasmtime::Store<MemoryLimiter>,
buffers: &BufferLayout,
in_len: i32,
slot: u8,
seed: u64,
) -> Option<i32> {
if store.set_fuel(FUEL_BUDGET).is_err() {
return None;
}
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
decide
.call(
&mut *store,
(
buffers.in_ptr(),
in_len,
u32::from(slot),
seed,
buffers.out_ptr(),
buffers.out_cap() as i32,
),
)
.ok()
}
/// Allocator-mode `-2 BufferTooSmall` recovery: free the current output
/// buffer, allocate a 2x replacement (clamped to [`MAX_BUFFER_CAP`]), and
/// patch `buffers` in place so the new pointer persists for subsequent
/// calls. Returns `None` if `buffers` is static-mode, if the new capacity
/// would equal the old (already at the ceiling), or if the guest's
/// `alloc` traps / returns 0.
fn retry_buffer_too_small(
buffers: &mut BufferLayout,
store: &mut wasmtime::Store<MemoryLimiter>,
) -> Option<()> {
let BufferLayout::Allocator {
out_ptr,
out_cap,
alloc,
dealloc,
..
} = buffers
else {
// Static mode cannot grow buffers; abi-decisions §"Memory
// allocation" calls this case out explicitly.
return None;
};
let new_cap = (*out_cap).saturating_mul(2).min(MAX_BUFFER_CAP);
if new_cap <= *out_cap {
// Already at the ceiling; no productive retry available.
return None;
}
// Pre-arm fuel/epoch for dealloc + alloc.
store.set_fuel(FUEL_BUDGET).ok()?;
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
dealloc.call(&mut *store, (*out_ptr, *out_cap)).ok()?;
store.set_fuel(FUEL_BUDGET).ok()?;
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let new_ptr = alloc.call(&mut *store, new_cap).ok()?;
if new_ptr == 0 {
return None;
}
*out_ptr = new_ptr;
*out_cap = new_cap;
Some(())
}

View file

@ -0,0 +1,76 @@
;; Stage 5c fixture: exercise the allocator-mode `-2 BufferTooSmall`
;; retry path.
;;
;; Layout:
;; * Allocator mode is selected because `alloc` + `dealloc` are both
;; exported. The host calls `alloc(in_cap)` then `alloc(out_cap)` at
;; load time; both succeed via a trivial bump allocator that hands
;; out chunks starting at $bump_base (0x2000).
;; * A mutable global $count tracks call number. The first
;; `decide_turn` returns -2 (BufferTooSmall). The host responds by
;; dealloc-ing the old output buffer and alloc-ing a 2x replacement,
;; then re-calling `decide_turn`. On call #2 the guest writes the
;; single 0x00 byte that postcard uses for `Vec::<T>::new()` and
;; returns 1.
;;
;; `dealloc` is a no-op: bump allocators do not free, and the retry path
;; only requires the call to not trap. The new alloc bumps past the
;; freed region, which is fine — addresses don't collide because the
;; subsequent alloc bumps further forward.
(module
(memory (export "memory") 1)
;; Bump pointer starts well clear of the ident string + ABI globals.
(global $bump (mut i32) (i32.const 0x2000))
;; Number of `decide_turn` calls observed so far.
(global $count (mut i32) (i32.const 0))
(global (export "__ident_ptr") i32 (i32.const 0x100))
(global (export "__ident_len") i32 (i32.const 12))
;; "echo2x 0.1.0" — exactly 12 bytes, no trailing NUL.
(data (i32.const 0x100) "echo2x 0.1.0")
;; Trivial bump allocator: returns current $bump, advances it by size.
(func (export "alloc") (param $size i32) (result i32)
(local $p i32)
global.get $bump
local.set $p
global.get $bump
local.get $size
i32.add
global.set $bump
local.get $p)
;; No-op dealloc — bump allocators never reclaim. Mandatory export so
;; the host detects allocator mode (alloc + dealloc both present).
(func (export "dealloc") (param $ptr i32) (param $size i32))
(func (export "init") (result i32)
i32.const 1)
;; (state_ptr, state_len, slot, seed, out_ptr, out_cap) -> i32
;;
;; Call #1 → return -2 (BufferTooSmall). Host retries with 2x buffer.
;; Call #2 → write 0x00 (postcard empty Vec) at out_ptr, return 1.
(func (export "decide_turn")
(param $state_ptr i32) (param $state_len i32)
(param $slot i32) (param $seed i64)
(param $out_ptr i32) (param $out_cap i32)
(result i32)
global.get $count
i32.const 1
i32.add
global.set $count
global.get $count
i32.const 1
i32.eq
if (result i32)
i32.const -2
else
local.get $out_ptr
i32.const 0
i32.store8
i32.const 1
end))

View file

@ -0,0 +1,43 @@
;; Stage 5c fixture: verify that `wasmtime::ResourceLimiter` enforces the
;; 16 MiB linear-memory cap.
;;
;; `decide_turn` attempts `memory.grow` by 300 pages (= 19.7 MiB) on top
;; of the initial 1 page. Total would be 301 pages > 256 pages (16 MiB),
;; so the limiter rejects the request and `memory.grow` returns -1.
;;
;; Whether the guest then traps or returns a negative code, the host
;; controller MUST surface an empty action chain (no panic, no leak).
;; This fixture chooses to trap via `unreachable` on grow failure, which
;; exercises the trap-handling path; the host turns that into Vec::new().
(module
(memory (export "memory") 1)
(global (export "__input_ptr") i32 (i32.const 0x1000))
(global (export "__input_cap") i32 (i32.const 0x4000))
(global (export "__output_ptr") i32 (i32.const 0x8000))
(global (export "__output_cap") i32 (i32.const 0x4000))
(global (export "__ident_ptr") i32 (i32.const 0x100))
(global (export "__ident_len") i32 (i32.const 13))
;; "growoom 0.1.0" — exactly 13 bytes, no trailing NUL.
(data (i32.const 0x100) "growoom 0.1.0")
(func (export "init") (result i32)
i32.const 1)
;; (state_ptr, state_len, slot, seed, out_ptr, out_cap) -> i32
;;
;; Request 300 extra pages (well past the 256-page / 16 MiB cap). The
;; limiter returns Ok(false), so `memory.grow` yields -1. We then
;; explicitly trap; the host treats the trap as a fault and returns
;; the empty plan.
(func (export "decide_turn")
(param i32 i32 i32 i64 i32 i32) (result i32)
i32.const 300
memory.grow
i32.const -1
i32.eq
if
unreachable
end
i32.const 0))

View file

@ -0,0 +1,94 @@
//! Integration tests for the native AI-controller loader (Stage 5c).
//!
//! End-to-end `dlopen` against a real signed `.so` is deferred to Stage
//! 6 (requires the release public-key distribution decision). These
//! tests cover the layers we own today: manifest parsing,
//! sandbox-kind enforcement, signature presence enforcement, and the
//! signing primitive itself with a known ed25519 keypair.
use mc_mod_host::{Manifest, ModError, SandboxKindWire};
const WASM_OK: &str = r#"{
"id": "noop",
"name": "Noop",
"author": "tests",
"version": "0.1.0",
"kind": "ai_controller",
"controller_id": "mod:noop",
"sandbox": "wasm"
}"#;
const NATIVE_NO_SIG: &str = r#"{
"id": "rusher",
"name": "Rusher",
"author": "tests",
"version": "1.0.0",
"kind": "ai_controller",
"controller_id": "mod:rusher",
"sandbox": "native"
}"#;
const NATIVE_WITH_SIG: &str = r#"{
"id": "rusher",
"name": "Rusher",
"author": "tests",
"version": "1.0.0",
"kind": "ai_controller",
"controller_id": "mod:rusher",
"sandbox": "native",
"signature": "AAAA",
"entrypoint": "controller.so"
}"#;
#[test]
fn manifest_parses_wasm_minimal() {
let m = Manifest::parse(WASM_OK.as_bytes()).expect("parse wasm manifest");
m.validate().expect("validate wasm manifest");
assert_eq!(m.sandbox, SandboxKindWire::Wasm);
assert_eq!(m.controller_id, "mod:noop");
assert!(m.signature.is_none());
}
#[test]
fn manifest_rejects_native_without_signature() {
let m = Manifest::parse(NATIVE_NO_SIG.as_bytes()).expect("parse");
let err = m.validate().expect_err("must reject");
assert!(
matches!(err, ModError::SignatureRequired),
"expected SignatureRequired, got {err:?}"
);
}
#[test]
fn manifest_accepts_native_with_signature() {
let m = Manifest::parse(NATIVE_WITH_SIG.as_bytes()).expect("parse");
m.validate().expect("validate native manifest");
assert_eq!(m.sandbox, SandboxKindWire::Native);
assert_eq!(m.signature.as_deref(), Some("AAAA"));
assert_eq!(m.entrypoint.as_deref(), Some("controller.so"));
}
#[test]
fn signature_verification_test_vector() {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH};
use sha2::{Digest, Sha256};
// Deterministic keypair so the test vector is stable across runs.
let secret: [u8; SECRET_KEY_LENGTH] = [7u8; SECRET_KEY_LENGTH];
let sk = SigningKey::from_bytes(&secret);
let pk = sk.verifying_key();
let payload = b"binary-bytes-go-here";
let digest = Sha256::digest(payload);
let sig = sk.sign(digest.as_slice());
let sig_b64 = B64.encode(sig.to_bytes());
// Verify via the test-only entry point that takes a caller-supplied key.
mc_mod_host::signing::verify_with_key(&pk, payload, &sig_b64)
.expect("valid signature must verify against its own public key");
// Tamper detection: changing payload invalidates the signature.
mc_mod_host::signing::verify_with_key(&pk, b"binary-bytes-go-here!", &sig_b64)
.expect_err("tampered payload must not verify");
}

View file

@ -0,0 +1,70 @@
//! Stage 5c integration tests covering the two Stage 5b carry-overs:
//!
//! 1. `MemoryLimiter` actually caps guest linear-memory growth at 16 MiB
//! via `wasmtime::ResourceLimiter`.
//! 2. Allocator-mode `decide_turn` retries exactly once on `-2
//! BufferTooSmall`, freeing the old output buffer and re-allocating a
//! 2x replacement before re-calling the guest.
//!
//! Both fixtures live under `tests/fixtures/` and are hand-written WAT.
use std::sync::Arc;
use mc_ai::evaluator::ScoringWeights;
use mc_ai::tactical::{TacticalMap, TacticalState};
use mc_mod_host::{WasmAiController, WasmHost};
use mc_player_api::controllers::AiController;
const GROW_OOM_WAT: &[u8] = include_bytes!("fixtures/grow_oom.wat");
const ECHO_2X_WAT: &[u8] = include_bytes!("fixtures/echo_2x.wat");
fn empty_state() -> TacticalState {
TacticalState {
current_player: 0,
turn: 0,
map: TacticalMap {
width: 0,
height: 0,
tiles: Vec::new(),
},
players: Vec::new(),
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
}
}
#[test]
fn memory_limiter_blocks_grow_past_16_mib() {
let host = Arc::new(WasmHost::new().expect("engine builds"));
let handle = host.load_module(GROW_OOM_WAT).expect("grow_oom.wat loads");
let controller = WasmAiController::new(host, handle).expect("controller initialises");
let state = empty_state();
let weights = ScoringWeights::default();
// Guest requests +300 pages on top of the initial 1; limiter rejects,
// memory.grow returns -1, guest traps. Host MUST surface empty plan.
let actions = controller.decide_turn(&state, 0, &weights, 0);
assert!(
actions.is_empty(),
"memory.grow past 16 MiB cap must yield empty actions, got {actions:?}"
);
}
#[test]
fn allocator_mode_retries_once_on_buffer_too_small() {
let host = Arc::new(WasmHost::new().expect("engine builds"));
let handle = host.load_module(ECHO_2X_WAT).expect("echo_2x.wat loads");
let controller = WasmAiController::new(host, handle).expect("controller initialises");
let state = empty_state();
let weights = ScoringWeights::default();
// Call #1 returns -2; host retries → call #2 writes one zero byte
// (postcard's `Vec::<Action>::new()`) and returns 1. Decoded result:
// an empty action chain.
let actions = controller.decide_turn(&state, 0, &weights, 0);
assert!(
actions.is_empty(),
"expected empty postcard Vec after retry, got {actions:?}"
);
}