From c52ec4c3f26fc3ca51e33ff885995a922df7890f Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 06:32:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(mod-host):=20=E2=9C=A8=20Implement=20nativ?= =?UTF-8?q?e=20module=20sandboxing=20with=20memory=20enforcement,=20OOM=20?= =?UTF-8?q?handling,=20and=20signing=20enforcement=20for=20Minecraft=20mod?= =?UTF-8?q?ding=20simulator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- docs/modding/abi-decisions.md | 10 - src/simulator/crates/mc-mod-host/src/abi.rs | 6 +- src/simulator/crates/mc-mod-host/src/lib.rs | 140 +++++++++- .../crates/mc-mod-host/src/manifest.rs | 221 +++++++++++++++ .../crates/mc-mod-host/src/native_loader.rs | 253 ++++++++++++++++++ .../crates/mc-mod-host/src/signing.rs | 141 ++++++++++ .../crates/mc-mod-host/src/wasm_controller.rs | 204 ++++++++++---- .../mc-mod-host/tests/fixtures/echo_2x.wat | 76 ++++++ .../mc-mod-host/tests/fixtures/grow_oom.wat | 43 +++ .../crates/mc-mod-host/tests/native_loader.rs | 94 +++++++ .../tests/wasm_controller_limits.rs | 70 +++++ 11 files changed, 1194 insertions(+), 64 deletions(-) create mode 100644 src/simulator/crates/mc-mod-host/src/manifest.rs create mode 100644 src/simulator/crates/mc-mod-host/src/native_loader.rs create mode 100644 src/simulator/crates/mc-mod-host/src/signing.rs create mode 100644 src/simulator/crates/mc-mod-host/tests/fixtures/echo_2x.wat create mode 100644 src/simulator/crates/mc-mod-host/tests/fixtures/grow_oom.wat create mode 100644 src/simulator/crates/mc-mod-host/tests/native_loader.rs create mode 100644 src/simulator/crates/mc-mod-host/tests/wasm_controller_limits.rs diff --git a/docs/modding/abi-decisions.md b/docs/modding/abi-decisions.md index ae992153..9ce22694 100644 --- a/docs/modding/abi-decisions.md +++ b/docs/modding/abi-decisions.md @@ -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). diff --git a/src/simulator/crates/mc-mod-host/src/abi.rs b/src/simulator/crates/mc-mod-host/src/abi.rs index 3de010af..76d1a410 100644 --- a/src/simulator/crates/mc-mod-host/src/abi.rs +++ b/src/simulator/crates/mc-mod-host/src/abi.rs @@ -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 `" "` identity string read from the guest's diff --git a/src/simulator/crates/mc-mod-host/src/lib.rs b/src/simulator/crates/mc-mod-host/src/lib.rs index e12a65d6..1dd8686b 100644 --- a/src/simulator/crates/mc-mod-host/src/lib.rs +++ b/src/simulator/crates/mc-mod-host/src/lib.rs @@ -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, + ) -> wasmtime::Result { + Ok(desired <= self.max_bytes) + } + + fn table_growing( + &mut self, + _current: usize, + _desired: usize, + _maximum: Option, + ) -> wasmtime::Result { + 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>, + pub(crate) store: Mutex>, pub(crate) instance: Instance, } @@ -146,7 +212,15 @@ impl WasmHost { /// [`ModError::Instantiate`] if instantiation fails. pub fn load_module(&self, wasm_bytes: &[u8]) -> Result { 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, wasm_bytes: &[u8]) -> Result Result { + 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::*; diff --git a/src/simulator/crates/mc-mod-host/src/manifest.rs b/src/simulator/crates/mc-mod-host/src/manifest.rs new file mode 100644 index 00000000..43b1a938 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/src/manifest.rs @@ -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, + /// 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, +} + +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 { + serde_json::from_slice::(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(_)))); + } +} diff --git a/src/simulator/crates/mc-mod-host/src/native_loader.rs b/src/simulator/crates/mc-mod-host/src/native_loader.rs new file mode 100644 index 00000000..997caa5b --- /dev/null +++ b/src/simulator/crates/mc-mod-host/src/native_loader.rs @@ -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 `" "`. 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>, +} + +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 { + let payload = match postcard::to_allocvec(state) { + Ok(p) => p, + Err(_) => return Vec::new(), + }; + let mut framed: Vec = 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::>(&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 { + 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` 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 = lib + .get(b"init\0") + .map_err(|_| ModError::MissingNativeSymbol("init".into()))?; + let decide: Symbol = lib + .get(b"decide_turn\0") + .map_err(|_| ModError::MissingNativeSymbol("decide_turn".into()))?; + let ident: Symbol = 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]), + }) +} diff --git a/src/simulator/crates/mc-mod-host/src/signing.rs b/src/simulator/crates/mc-mod-host/src/signing.rs new file mode 100644 index 00000000..afbe84e8 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/src/signing.rs @@ -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(_)) + )); + } +} diff --git a/src/simulator/crates/mc-mod-host/src/wasm_controller.rs b/src/simulator/crates/mc-mod-host/src/wasm_controller.rs index 3a6e64bf..78124ff4 100644 --- a/src/simulator/crates/mc-mod-host/src/wasm_controller.rs +++ b/src/simulator/crates/mc-mod-host/src/wasm_controller.rs @@ -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, - #[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, 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, 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, name: &str, ) -> Result { 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, name: &str, ) -> Option { 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, ) -> Result { // Allocator mode requires BOTH alloc + dealloc. let alloc: Option> = 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, + 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, + buffers: &BufferLayout, + in_len: i32, + slot: u8, + seed: u64, +) -> Option { + 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, +) -> 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(()) +} diff --git a/src/simulator/crates/mc-mod-host/tests/fixtures/echo_2x.wat b/src/simulator/crates/mc-mod-host/tests/fixtures/echo_2x.wat new file mode 100644 index 00000000..e7b87d7d --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/fixtures/echo_2x.wat @@ -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::::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)) diff --git a/src/simulator/crates/mc-mod-host/tests/fixtures/grow_oom.wat b/src/simulator/crates/mc-mod-host/tests/fixtures/grow_oom.wat new file mode 100644 index 00000000..a84ed1c3 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/fixtures/grow_oom.wat @@ -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)) diff --git a/src/simulator/crates/mc-mod-host/tests/native_loader.rs b/src/simulator/crates/mc-mod-host/tests/native_loader.rs new file mode 100644 index 00000000..066e1420 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/native_loader.rs @@ -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"); +} diff --git a/src/simulator/crates/mc-mod-host/tests/wasm_controller_limits.rs b/src/simulator/crates/mc-mod-host/tests/wasm_controller_limits.rs new file mode 100644 index 00000000..79c5f100 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/wasm_controller_limits.rs @@ -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::::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:?}" + ); +}