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:
parent
7af2f53148
commit
c52ec4c3f2
11 changed files with 1194 additions and 64 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
221
src/simulator/crates/mc-mod-host/src/manifest.rs
Normal file
221
src/simulator/crates/mc-mod-host/src/manifest.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
253
src/simulator/crates/mc-mod-host/src/native_loader.rs
Normal file
253
src/simulator/crates/mc-mod-host/src/native_loader.rs
Normal 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]),
|
||||
})
|
||||
}
|
||||
141
src/simulator/crates/mc-mod-host/src/signing.rs
Normal file
141
src/simulator/crates/mc-mod-host/src/signing.rs
Normal 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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
76
src/simulator/crates/mc-mod-host/tests/fixtures/echo_2x.wat
vendored
Normal file
76
src/simulator/crates/mc-mod-host/tests/fixtures/echo_2x.wat
vendored
Normal 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))
|
||||
43
src/simulator/crates/mc-mod-host/tests/fixtures/grow_oom.wat
vendored
Normal file
43
src/simulator/crates/mc-mod-host/tests/fixtures/grow_oom.wat
vendored
Normal 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))
|
||||
94
src/simulator/crates/mc-mod-host/tests/native_loader.rs
Normal file
94
src/simulator/crates/mc-mod-host/tests/native_loader.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue