use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use crate::core::error::AgnosaiError;
use tracing::{debug, info, warn};
use wasmtime::*;
use wasmtime_wasi::WasiCtxBuilder;
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
const DEFAULT_MAX_MEMORY_BYTES: usize = 64 * 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_FUEL: u64 = 1_000_000_000;
const STDOUT_CAPACITY: usize = 1024 * 1024;
pub struct WasmSandbox {
engine: Engine,
max_memory_bytes: usize,
timeout: Duration,
fuel: u64,
}
#[derive(Debug)]
pub struct WasmModule {
module: Module,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WasmResult {
pub stdout: String,
pub exit_code: i32,
}
fn sandbox_err(msg: impl Into<String>) -> AgnosaiError {
AgnosaiError::Sandbox(msg.into())
}
fn map_wasm_err(err: impl std::fmt::Display) -> AgnosaiError {
sandbox_err(err.to_string())
}
impl WasmSandbox {
pub fn new() -> crate::core::Result<Self> {
Self::with_limits(DEFAULT_MAX_MEMORY_BYTES, DEFAULT_TIMEOUT)
}
pub fn with_limits(max_memory_bytes: usize, timeout: Duration) -> crate::core::Result<Self> {
let mut config = Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
let engine = Engine::new(&config).map_err(map_wasm_err)?;
info!(
max_memory_mb = max_memory_bytes / (1024 * 1024),
timeout_secs = timeout.as_secs(),
"WASM sandbox created"
);
Ok(Self {
engine,
max_memory_bytes,
timeout,
fuel: DEFAULT_FUEL,
})
}
pub fn load_module(&self, wasm_bytes: &[u8]) -> crate::core::Result<WasmModule> {
debug!(bytes = wasm_bytes.len(), "loading WASM module from bytes");
let module = Module::new(&self.engine, wasm_bytes).map_err(map_wasm_err)?;
Ok(WasmModule { module })
}
pub fn load_module_from_file(&self, path: &Path) -> crate::core::Result<WasmModule> {
debug!(path = %path.display(), "loading WASM module from file");
let module = Module::from_file(&self.engine, path).map_err(map_wasm_err)?;
Ok(WasmModule { module })
}
pub fn execute(&self, module: &WasmModule, input: &str) -> crate::core::Result<WasmResult> {
debug!(input_len = input.len(), "executing WASM module");
let stdin = MemoryInputPipe::new(input.to_owned());
let stdout = MemoryOutputPipe::new(STDOUT_CAPACITY);
let stdout_clone = stdout.clone();
let wasi_ctx = WasiCtxBuilder::new().stdin(stdin).stdout(stdout).build_p1();
let limits = StoreLimitsBuilder::new()
.memory_size(self.max_memory_bytes)
.instances(10)
.tables(10)
.memories(10)
.trap_on_grow_failure(true)
.build();
let mut store = Store::new(&self.engine, SandboxState { wasi_ctx, limits });
store.limiter(|state| &mut state.limits);
store.set_fuel(self.fuel).map_err(map_wasm_err)?;
store.epoch_deadline_trap();
store.set_epoch_deadline(1);
let engine = self.engine.clone();
let timeout = self.timeout;
let ticker = start_epoch_ticker(engine, timeout);
let mut linker: Linker<SandboxState> = Linker::new(&self.engine);
p1::add_to_linker_sync(&mut linker, |state: &mut SandboxState| &mut state.wasi_ctx)
.map_err(map_wasm_err)?;
let instance = linker
.instantiate(&mut store, &module.module)
.map_err(map_wasm_err)?;
let start = instance
.get_typed_func::<(), ()>(&mut store, "_start")
.map_err(|_| sandbox_err("module does not export a WASI _start function"))?;
let exit_code = match start.call(&mut store, ()) {
Ok(()) => 0,
Err(err) => extract_exit_code(&err),
};
drop(ticker);
let raw_stdout = stdout_clone.contents();
let stdout_str = String::from_utf8(raw_stdout.to_vec()).unwrap_or_else(|e| {
warn!("WASM stdout contained invalid UTF-8, lossy conversion applied");
String::from_utf8_lossy(&e.into_bytes()).into_owned()
});
debug!(
exit_code,
stdout_len = stdout_str.len(),
"WASM execution complete"
);
Ok(WasmResult {
stdout: stdout_str,
exit_code,
})
}
}
struct SandboxState {
wasi_ctx: WasiP1Ctx,
limits: StoreLimits,
}
fn extract_exit_code(err: &Error) -> i32 {
if let Some(exit) = err.downcast_ref::<wasmtime_wasi::I32Exit>() {
return exit.0;
}
let full = format!("{err:?}");
if full.contains("epoch") || full.contains("interrupt") {
warn!("WASM execution interrupted by epoch deadline (timeout)");
return -1;
}
if full.contains("fuel") {
warn!("WASM execution ran out of fuel (CPU limit)");
return -2;
}
warn!(error = %err, "WASM execution failed");
-3
}
struct EpochTicker {
handle: Option<std::thread::JoinHandle<()>>,
cancel: Arc<std::sync::atomic::AtomicBool>,
}
impl Drop for EpochTicker {
fn drop(&mut self) {
self.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
fn start_epoch_ticker(engine: Engine, timeout: Duration) -> EpochTicker {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancel_clone = cancel.clone();
let handle = std::thread::spawn(move || {
let step = Duration::from_millis(50);
let mut elapsed = Duration::ZERO;
while elapsed < timeout {
std::thread::sleep(step);
if cancel_clone.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
elapsed += step;
}
engine.increment_epoch();
});
EpochTicker {
handle: Some(handle),
cancel,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_sandbox_with_defaults() {
let sandbox = WasmSandbox::new().expect("should create sandbox");
assert_eq!(sandbox.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
assert_eq!(sandbox.timeout, DEFAULT_TIMEOUT);
}
#[test]
fn create_sandbox_with_custom_limits() {
let max_mem = 32 * 1024 * 1024;
let timeout = Duration::from_secs(10);
let sandbox = WasmSandbox::with_limits(max_mem, timeout).expect("should create sandbox");
assert_eq!(sandbox.max_memory_bytes, max_mem);
assert_eq!(sandbox.timeout, timeout);
}
#[test]
fn load_invalid_bytes_returns_error() {
let sandbox = WasmSandbox::new().expect("should create sandbox");
let result = sandbox.load_module(b"not valid wasm");
assert!(result.is_err());
let err = result.unwrap_err();
match &err {
AgnosaiError::Sandbox(msg) => {
assert!(
msg.contains("expected"),
"error should mention expected magic: {msg}"
);
}
other => panic!("expected Sandbox error, got: {other:?}"),
}
}
#[test]
fn load_empty_bytes_returns_error() {
let sandbox = WasmSandbox::new().expect("should create sandbox");
let result = sandbox.load_module(b"");
assert!(result.is_err());
}
#[test]
fn load_module_from_nonexistent_file_returns_error() {
let sandbox = WasmSandbox::new().expect("should create sandbox");
let result = sandbox.load_module_from_file(Path::new("/nonexistent/module.wasm"));
assert!(result.is_err());
}
#[test]
fn execute_wasi_module_captures_stdout() {
let wat = r#"(module
(import "wasi_snapshot_preview1" "fd_write"
(func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "hello")
(data (i32.const 8) "\00\00\00\00")
(data (i32.const 12) "\05\00\00\00")
(func (export "_start")
(drop (call $fd_write
(i32.const 1)
(i32.const 8)
(i32.const 1)
(i32.const 20)
))
)
)"#;
let wasm_bytes = wat::parse_str(wat).expect("WAT should parse");
let sandbox = WasmSandbox::new().expect("should create sandbox");
let module = sandbox
.load_module(&wasm_bytes)
.expect("should load module");
let result = sandbox.execute(&module, "").expect("should execute module");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello");
}
#[test]
fn execute_wasi_module_with_stdin_input() {
let wat = r#"(module
(import "wasi_snapshot_preview1" "fd_read"
(func $fd_read (param i32 i32 i32 i32) (result i32)))
(import "wasi_snapshot_preview1" "fd_write"
(func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "_start")
;; Set up iov for reading: buffer at offset 100, length 5
(i32.store (i32.const 0) (i32.const 100)) ;; iov_base
(i32.store (i32.const 4) (i32.const 5)) ;; iov_len
;; fd_read(stdin=0, iovs=0, iovs_count=1, nread_ptr=200)
(drop (call $fd_read
(i32.const 0)
(i32.const 0)
(i32.const 1)
(i32.const 200)
))
;; Now write what we read: set up write iov at offset 0
;; pointing to the buffer at 100, with length from nread at 200
(i32.store (i32.const 0) (i32.const 100)) ;; iov_base
(i32.store (i32.const 4) (i32.load (i32.const 200))) ;; iov_len = nread
;; fd_write(stdout=1, iovs=0, iovs_count=1, nwritten_ptr=204)
(drop (call $fd_write
(i32.const 1)
(i32.const 0)
(i32.const 1)
(i32.const 204)
))
)
)"#;
let wasm_bytes = wat::parse_str(wat).expect("WAT should parse");
let sandbox = WasmSandbox::new().expect("should create sandbox");
let module = sandbox
.load_module(&wasm_bytes)
.expect("should load module");
let result = sandbox
.execute(&module, "world")
.expect("should execute module");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "world");
}
#[test]
fn fuel_exhaustion_returns_error() {
let wat = r#"(module
(func (export "_start")
(loop br 0)
)
(memory (export "memory") 1)
)"#;
let wasm_bytes = wat::parse_str(wat).expect("WAT should parse");
let mut sandbox =
WasmSandbox::with_limits(DEFAULT_MAX_MEMORY_BYTES, Duration::from_secs(30))
.expect("should create sandbox");
sandbox.fuel = 1_000;
let module = sandbox
.load_module(&wasm_bytes)
.expect("should load module");
let result = sandbox
.execute(&module, "")
.expect("should return result, not panic");
assert_eq!(
result.exit_code, -2,
"fuel exhaustion should produce exit_code -2, got {}",
result.exit_code
);
}
#[test]
fn epoch_timeout_returns_error() {
let wat = r#"(module
(func (export "_start")
(loop br 0)
)
(memory (export "memory") 1)
)"#;
let wasm_bytes = wat::parse_str(wat).expect("WAT should parse");
let mut sandbox =
WasmSandbox::with_limits(DEFAULT_MAX_MEMORY_BYTES, Duration::from_millis(100))
.expect("should create sandbox");
sandbox.fuel = u64::MAX / 2;
let module = sandbox
.load_module(&wasm_bytes)
.expect("should load module");
let result = sandbox
.execute(&module, "")
.expect("should return result, not panic");
assert_eq!(
result.exit_code, -1,
"epoch timeout should produce exit_code -1, got {}",
result.exit_code
);
}
#[test]
fn execute_valid_wasi_module_exits_cleanly() {
let wat = r#"(module
(memory (export "memory") 1)
(func (export "_start"))
)"#;
let wasm_bytes = wat::parse_str(wat).expect("WAT should parse");
let sandbox = WasmSandbox::new().expect("should create sandbox");
let module = sandbox
.load_module(&wasm_bytes)
.expect("should load module");
let result = sandbox.execute(&module, "").expect("should execute module");
assert_eq!(result.exit_code, 0, "clean exit should produce exit_code 0");
assert!(
result.stdout.is_empty(),
"no-op module should produce empty stdout"
);
}
#[test]
fn zero_length_input_succeeds() {
let wat = r#"(module
(import "wasi_snapshot_preview1" "fd_read"
(func $fd_read (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "_start")
;; Set up iov: buffer at 100, length 16
(i32.store (i32.const 0) (i32.const 100))
(i32.store (i32.const 4) (i32.const 16))
;; fd_read(stdin=0, iovs=0, iovs_count=1, nread_ptr=200)
(drop (call $fd_read
(i32.const 0)
(i32.const 0)
(i32.const 1)
(i32.const 200)
))
)
)"#;
let wasm_bytes = wat::parse_str(wat).expect("WAT should parse");
let sandbox = WasmSandbox::new().expect("should create sandbox");
let module = sandbox
.load_module(&wasm_bytes)
.expect("should load module");
let result = sandbox
.execute(&module, "")
.expect("should execute with empty input");
assert_eq!(result.exit_code, 0, "empty input should not cause an error");
}
}