#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Instant, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use wasmtime::{
Config, Engine, Linker, Module, OptLevel, Store, StoreLimits, StoreLimitsBuilder, Trap,
};
use wasmtime_wasi::I32Exit;
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
use mimobox_core::{Sandbox, SandboxConfig, SandboxError, SandboxResult};
struct StoreData {
wasi: WasiP1Ctx,
limits: StoreLimits,
}
fn wasm_logging_enabled() -> bool {
std::env::var_os("MIMOBOX_WASM_QUIET").is_none()
}
macro_rules! log_info {
($($arg:tt)*) => {
if wasm_logging_enabled() {
eprintln!("[mimobox:wasm:info] {}", format!($($arg)*))
}
};
}
macro_rules! log_warn {
($($arg:tt)*) => {
if wasm_logging_enabled() {
eprintln!("[mimobox:wasm:warn] {}", format!($($arg)*))
}
};
}
const FUEL_PER_SECOND: u64 = 15_000_000;
const DEFAULT_FUEL_LIMIT: u64 = 10_000_000;
const OUTPUT_MAX_CAPACITY: usize = 1024 * 1024;
const MAX_OUTPUT_SIZE: usize = 4 * 1024 * 1024;
const MAX_WASM_FILE_SIZE: u64 = 100 * 1024 * 1024;
const DEFAULT_MEMORY_LIMIT_MB: u64 = 64;
const EPOCH_TICK_INTERVAL_MS: u64 = 10;
fn fuel_from_timeout(timeout_secs: Option<u64>) -> u64 {
match timeout_secs {
Some(secs) => secs.saturating_mul(FUEL_PER_SECOND),
None => DEFAULT_FUEL_LIMIT,
}
}
fn truncate_output(data: Vec<u8>, label: &str) -> Vec<u8> {
if data.len() > MAX_OUTPUT_SIZE {
log_warn!(
"{} output exceeded limit ({} > {} bytes), truncated",
label,
data.len(),
MAX_OUTPUT_SIZE
);
data[..MAX_OUTPUT_SIZE].to_vec()
} else {
data
}
}
pub struct WasmSandbox {
engine: Arc<Engine>,
config: SandboxConfig,
cache_dir: PathBuf,
epoch_running: Arc<AtomicBool>,
epoch_thread: Option<std::thread::JoinHandle<()>>,
}
fn content_hash(data: &[u8]) -> String {
let hash = Sha256::digest(data);
format!("{:x}", hash)
}
fn file_fingerprint(path: &Path) -> Option<(u64, u64)> {
let meta = std::fs::metadata(path).ok()?;
let size = meta.len();
let mtime = meta
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_nanos() as u64;
Some((size, mtime))
}
fn compile_module_from_bytes(
engine: &Engine,
wasm_path: &Path,
bytes: &[u8],
) -> Result<Module, SandboxError> {
Module::from_binary(engine, bytes).map_err(|e| {
SandboxError::ExecutionFailed(format!(
"Failed to load Wasm module ({:?}): {}",
wasm_path, e
))
})
}
fn get_cached_module(
engine: &Engine,
wasm_path: &Path,
cache_dir: &Path,
) -> Result<Module, SandboxError> {
let _ = std::fs::create_dir_all(cache_dir);
let fingerprint = match file_fingerprint(wasm_path) {
Some(fp) => fp,
None => {
let file_data = std::fs::read(wasm_path).map_err(|e| {
SandboxError::ExecutionFailed(format!("Failed to read Wasm file: {}", e))
})?;
return compile_module_from_bytes(engine, wasm_path, &file_data);
}
};
let map_file = cache_dir.join(format!("{}_{}.map", fingerprint.0, fingerprint.1));
if let Ok(hash) = std::fs::read_to_string(&map_file) {
let cache_path = cache_dir.join(format!("{}.cwasm", hash.trim()));
match std::fs::read(&cache_path) {
Ok(cached) => {
match unsafe { Module::deserialize(engine, &cached) } {
Ok(module) => {
log_info!("Loaded module from cache: {:?}", wasm_path);
return Ok(module);
}
Err(e) => {
log_warn!("Cache deserialization failed, recompiling: {}", e);
let _ = std::fs::remove_file(&cache_path);
let _ = std::fs::remove_file(&map_file);
}
}
}
Err(_) => {
let _ = std::fs::remove_file(&map_file);
}
}
}
let file_data = std::fs::read(wasm_path)
.map_err(|e| SandboxError::ExecutionFailed(format!("Failed to read Wasm file: {}", e)))?;
let hash = content_hash(&file_data);
let cache_path = cache_dir.join(format!("{}.cwasm", hash));
if let Ok(cached) = std::fs::read(&cache_path) {
match unsafe { Module::deserialize(engine, &cached) } {
Ok(module) => {
let _ = std::fs::write(&map_file, &hash);
log_info!("Loaded module from cache (content match): {:?}", wasm_path);
return Ok(module);
}
Err(e) => {
log_warn!("Cache deserialization failed, recompiling: {}", e);
let _ = std::fs::remove_file(&cache_path);
}
}
}
let module = compile_module_from_bytes(engine, wasm_path, &file_data)?;
if let Ok(serialized) = module.serialize() {
let tmp_path = cache_path.with_extension("cwasm.tmp");
if std::fs::write(&tmp_path, &serialized).is_ok() {
if let Err(e) = std::fs::rename(&tmp_path, &cache_path) {
log_warn!("Failed to rename cache file: {}", e);
let _ = std::fs::remove_file(&tmp_path);
}
}
let _ = std::fs::write(&map_file, &hash);
}
log_info!("Compiled and cached module: {:?}", wasm_path);
Ok(module)
}
fn create_engine_config() -> Config {
let mut config = Config::new();
config.cranelift_opt_level(OptLevel::Speed);
config.consume_fuel(true);
config.epoch_interruption(true);
config.max_wasm_stack(512 * 1024); config.parallel_compilation(true);
config
}
fn build_wasi_ctx(
config: &SandboxConfig,
args: &[String],
stdout_pipe: MemoryOutputPipe,
stderr_pipe: MemoryOutputPipe,
) -> WasiP1Ctx {
let mut builder = WasiCtxBuilder::new();
for arg in args {
builder.arg(arg);
}
builder.env("HOME", "/tmp");
builder.env("PATH", "/usr/bin:/bin");
builder.env("TERM", "dumb");
builder.env("SANDBOX", "wasm");
builder.stdout(Box::new(stdout_pipe));
builder.stderr(Box::new(stderr_pipe));
for path in &config.fs_readonly {
if let Some(path_str) = path.to_str() {
if path.exists() {
if let Err(e) =
builder.preopened_dir(path, path_str, DirPerms::READ, FilePerms::READ)
{
log_warn!("Failed to preopen read-only dir {:?}: {}", path, e);
}
} else {
log_warn!("Read-only path does not exist: {:?}", path);
}
}
}
for path in &config.fs_readwrite {
if let Some(path_str) = path.to_str() {
if path.exists() {
if let Err(e) =
builder.preopened_dir(path, path_str, DirPerms::all(), FilePerms::all())
{
log_warn!("Failed to preopen read-write dir {:?}: {}", path, e);
}
} else {
log_warn!("Read-write path does not exist: {:?}", path);
}
}
}
if config.deny_network {
log_info!("WASI network denied by SandboxConfig; no sockets are preopened");
} else {
log_warn!(
"SandboxConfig allows network, but WASI backend cannot enable network access; keeping network denied"
);
}
builder.build_p1()
}
impl Sandbox for WasmSandbox {
fn new(config: SandboxConfig) -> Result<Self, SandboxError> {
let engine_config = create_engine_config();
let engine = Arc::new(Engine::new(&engine_config).map_err(|e| {
SandboxError::ExecutionFailed(format!("Failed to create Wasmtime Engine: {}", e))
})?);
let epoch_running = Arc::new(AtomicBool::new(true));
let epoch_thread_engine = engine.clone();
let epoch_thread_running = epoch_running.clone();
let epoch_thread = std::thread::Builder::new()
.name("mimobox-wasm-epoch-ticker".to_string())
.spawn(move || {
let tick_interval = std::time::Duration::from_millis(EPOCH_TICK_INTERVAL_MS);
while epoch_thread_running.load(Ordering::Relaxed) {
std::thread::sleep(tick_interval);
epoch_thread_engine.increment_epoch();
}
})
.map_err(|e| {
SandboxError::ExecutionFailed(format!("Failed to start Wasm epoch ticker: {}", e))
})?;
let uid = unsafe { libc::geteuid() };
let cache_dir = std::env::temp_dir().join(format!("mimobox-cache-{}", uid));
log_info!(
"Created Wasm sandbox backend, memory_limit={:?}MB, timeout={:?}s, cache_dir={:?}",
config.memory_limit_mb,
config.timeout_secs,
cache_dir,
);
Ok(Self {
engine,
config,
cache_dir,
epoch_running,
epoch_thread: Some(epoch_thread),
})
}
fn execute(&mut self, cmd: &[String]) -> Result<SandboxResult, SandboxError> {
let start = Instant::now();
if cmd.is_empty() {
return Err(SandboxError::ExecutionFailed("Command is empty".into()));
}
let wasm_path = Path::new(&cmd[0]);
if !wasm_path.exists() {
return Err(SandboxError::ExecutionFailed(format!(
"Wasm file does not exist: {:?}",
wasm_path
)));
}
if let Ok(meta) = std::fs::metadata(wasm_path)
&& meta.len() > MAX_WASM_FILE_SIZE
{
return Err(SandboxError::ExecutionFailed(format!(
"Wasm file too large: {} bytes (limit {} bytes)",
meta.len(),
MAX_WASM_FILE_SIZE
)));
}
let module = get_cached_module(&self.engine, wasm_path, &self.cache_dir)?;
let stdout_pipe = MemoryOutputPipe::new(OUTPUT_MAX_CAPACITY);
let stdout_reader = stdout_pipe.clone(); let stderr_pipe = MemoryOutputPipe::new(OUTPUT_MAX_CAPACITY);
let stderr_reader = stderr_pipe.clone();
let wasi_ctx = build_wasi_ctx(&self.config, cmd, stdout_pipe, stderr_pipe);
let mut linker: Linker<StoreData> = Linker::new(&self.engine);
p1::add_to_linker_sync(&mut linker, |data| &mut data.wasi).map_err(|e| {
SandboxError::ExecutionFailed(format!("Failed to register WASI Preview 1: {}", e))
})?;
let memory_limit_bytes: usize = self
.config
.memory_limit_mb
.map(|mb| mb * 1024 * 1024)
.unwrap_or(DEFAULT_MEMORY_LIMIT_MB * 1024 * 1024)
.try_into()
.unwrap_or(usize::MAX);
let limits = StoreLimitsBuilder::new()
.memory_size(memory_limit_bytes)
.memories(1) .tables(4) .instances(1) .trap_on_grow_failure(true) .build();
let store_data = StoreData {
wasi: wasi_ctx,
limits,
};
let mut store = Store::new(&self.engine, store_data);
store.limiter(|data| &mut data.limits);
let fuel_limit = fuel_from_timeout(self.config.timeout_secs);
store
.set_fuel(fuel_limit)
.map_err(|e| SandboxError::ExecutionFailed(format!("Failed to set fuel: {}", e)))?;
store.epoch_deadline_trap();
let epoch_deadline_ticks = self
.config
.timeout_secs
.map(|s| s.saturating_mul(100)) .unwrap_or(3000); store.set_epoch_deadline(epoch_deadline_ticks);
let instance = linker.instantiate(&mut store, &module).map_err(|e| {
SandboxError::ExecutionFailed(format!("Failed to instantiate Wasm module: {}", e))
})?;
let exit_code = match instance.get_typed_func::<(), ()>(&mut store, "_start") {
Ok(start_func) => {
match start_func.call(&mut store, ()) {
Ok(()) => Some(0),
Err(e) => {
if let Some(exit) = find_exit_code(&e) {
Some(exit)
} else if is_fuel_exhausted(&store) || is_epoch_interrupt(&e) {
log_warn!(
"Execution timed out (fuel exhausted or epoch deadline exceeded)"
);
let elapsed = start.elapsed();
let stdout =
truncate_output(stdout_reader.contents().to_vec(), "stdout");
let stderr =
truncate_output(stderr_reader.contents().to_vec(), "stderr");
return Ok(SandboxResult {
stdout,
stderr,
exit_code: None,
elapsed,
timed_out: true,
});
} else if is_memory_trap(&e) {
log_info!("Wasm memory limit exceeded, mapping to exit code 1");
Some(1)
} else {
log_warn!("Wasm execution error: {}", e);
None
}
}
}
}
Err(_) => {
match instance.get_typed_func::<(), i32>(&mut store, "main") {
Ok(main_func) => match main_func.call(&mut store, ()) {
Ok(code) => Some(code),
Err(e) => {
if let Some(exit) = find_exit_code(&e) {
Some(exit)
} else if is_memory_trap(&e) {
log_info!("Wasm memory limit exceeded, mapping to exit code 1");
Some(1)
} else {
log_warn!("main function execution failed: {}", e);
None
}
}
},
Err(_) => {
return Err(SandboxError::ExecutionFailed(
"Wasm module has no _start or main export function".into(),
));
}
}
}
};
let elapsed = start.elapsed();
let stdout = truncate_output(stdout_reader.contents().to_vec(), "stdout");
let stderr = truncate_output(stderr_reader.contents().to_vec(), "stderr");
log_info!(
"Wasm execution completed, exit_code={:?}, elapsed={:.2}ms",
exit_code,
elapsed.as_secs_f64() * 1000.0
);
Ok(SandboxResult {
stdout,
stderr,
exit_code,
elapsed,
timed_out: false,
})
}
fn destroy(self) -> Result<(), SandboxError> {
log_info!("Destroying Wasm sandbox backend");
Ok(())
}
}
impl Drop for WasmSandbox {
fn drop(&mut self) {
self.epoch_running.store(false, Ordering::Relaxed);
if let Some(epoch_thread) = self.epoch_thread.take()
&& let Err(e) = epoch_thread.join()
{
log_warn!("Wasm epoch ticker thread join failed: {:?}", e);
}
}
}
fn is_fuel_exhausted(store: &Store<StoreData>) -> bool {
store.get_fuel().is_ok_and(|f| f == 0)
}
fn is_epoch_interrupt(error: &wasmtime::Error) -> bool {
if let Some(trap) = error.downcast_ref::<Trap>() {
matches!(trap, Trap::Interrupt)
} else {
false
}
}
fn is_memory_trap(error: &wasmtime::Error) -> bool {
if let Some(trap) = error.downcast_ref::<Trap>()
&& matches!(trap, Trap::MemoryOutOfBounds)
{
return true;
}
let message = format!("{error:#}").to_ascii_lowercase();
message.contains("memory")
&& (message.contains("out of bounds")
|| message.contains("grow")
|| message.contains("growth"))
}
fn find_exit_code(error: &wasmtime::Error) -> Option<i32> {
if let Some(exit) = error.downcast_ref::<I32Exit>() {
return Some(exit.0);
}
let root = error.root_cause();
if let Some(exit) = root.downcast_ref::<I32Exit>() {
return Some(exit.0);
}
None
}
pub fn run_wasm_benchmark(
wasm_path: &str,
iterations: usize,
) -> Result<(), Box<dyn std::error::Error>> {
println!("=== mimobox Wasm Sandbox Benchmark ===\n");
let mut config = SandboxConfig::default();
config.deny_network = true;
config.memory_limit_mb = Some(64);
config.timeout_secs = Some(30);
config.fs_readonly = vec![];
config.fs_readwrite = vec![];
config.seccomp_profile = mimobox_core::SeccompProfile::Essential;
config.allow_fork = false;
config.allowed_http_domains = vec![];
if !Path::new(wasm_path).exists() {
return Err(format!("Wasm file does not exist: {}", wasm_path).into());
}
let cmd = vec![wasm_path.to_string()];
println!("Testing Engine creation overhead...");
let engine_start = Instant::now();
let mut sb = WasmSandbox::new(config.clone())?;
let engine_elapsed = engine_start.elapsed();
println!(
" Engine creation: {:.2}ms",
engine_elapsed.as_secs_f64() * 1000.0
);
println!("\nTesting first module compilation...");
let compile_start = Instant::now();
let result = sb.execute(&cmd)?;
let compile_elapsed = compile_start.elapsed();
println!(
" First execution (with compilation): {:.2}ms, exit_code={:?}",
compile_elapsed.as_secs_f64() * 1000.0,
result.exit_code
);
println!(
"\nCold start test ({} iterations, each with new + execute)...",
iterations
);
let mut cold_times = Vec::with_capacity(iterations);
for i in 0..iterations {
let start = Instant::now();
let mut sb = WasmSandbox::new(config.clone())?;
let result = sb.execute(&cmd)?;
let elapsed = start.elapsed();
cold_times.push(elapsed.as_micros() as f64 / 1000.0);
if result.exit_code != Some(0) {
eprintln!("Iteration {} failed: exit code {:?}", i, result.exit_code);
}
}
println!(
"\nHot path test ({} iterations, reusing Engine)...",
iterations
);
let mut hot_times = Vec::with_capacity(iterations);
for _ in 0..iterations {
let start = Instant::now();
let result = sb.execute(&cmd)?;
let elapsed = start.elapsed();
hot_times.push(elapsed.as_micros() as f64 / 1000.0);
if result.exit_code != Some(0) {
eprintln!("Hot path execution failed: {:?}", result.exit_code);
}
}
cold_times.sort_by(f64::total_cmp);
hot_times.sort_by(f64::total_cmp);
fn print_stats(label: &str, times: &[f64]) {
let n = times.len();
if n == 0 {
println!("{} no data", label);
return;
}
let p50 = times[n / 2];
let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
let p99_idx = ((n as f64 * 0.99) as usize).min(n - 1);
let avg: f64 = times.iter().sum::<f64>() / n as f64;
println!("\n{} latency:", label);
println!(" Min: {:.2}ms", times[0]);
println!(" P50: {:.2}ms", p50);
println!(" P95: {:.2}ms", times[p95_idx]);
println!(" P99: {:.2}ms", times[p99_idx]);
println!(" Avg: {:.2}ms", avg);
println!(" Max: {:.2}ms", times[n - 1]);
}
print_stats("Cold start ", &cold_times);
print_stats("Hot path ", &hot_times);
let cold_p50 = cold_times[cold_times.len() / 2];
let hot_p50 = hot_times[hot_times.len() / 2];
println!("\nTarget check:");
println!(
" Cold start P50: {:.2}ms {}",
cold_p50,
if cold_p50 < 5.0 { "[PASS]" } else { "[FAIL]" }
);
println!(
" Hot path P50: {:.2}ms {}",
hot_p50,
if hot_p50 < 1.0 { "[PASS]" } else { "[FAIL]" }
);
println!("\n=== Test completed ===");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mimobox_core::Sandbox;
use wasmtime::{Instance, Store};
fn test_config() -> SandboxConfig {
let mut config = SandboxConfig::default();
config.timeout_secs = Some(10);
config.memory_limit_mb = Some(64);
config.fs_readonly = vec![];
config.fs_readwrite = vec![];
config.deny_network = true;
config.seccomp_profile = mimobox_core::SeccompProfile::Essential;
config.allow_fork = false;
config.allowed_http_domains = vec![];
config
}
#[test]
fn test_wasm_sandbox_create() {
let sb = WasmSandbox::new(test_config());
assert!(sb.is_ok(), "Failed to create Wasm sandbox: {:?}", sb.err());
}
#[test]
fn test_wasm_sandbox_empty_command() {
let mut sb = WasmSandbox::new(test_config()).expect("Failed to create");
let result = sb.execute(&[]);
assert!(result.is_err(), "Empty command should return error");
}
#[test]
fn test_wasm_sandbox_nonexistent_file() {
let mut sb = WasmSandbox::new(test_config()).expect("Failed to create");
let result = sb.execute(&["/nonexistent/file.wasm".to_string()]);
assert!(result.is_err(), "Nonexistent file should return error");
}
#[test]
fn test_wasm_sandbox_destroy() {
let sb = WasmSandbox::new(test_config()).expect("Failed to create");
let result = sb.destroy();
assert!(
result.is_ok(),
"Failed to destroy sandbox: {:?}",
result.err()
);
}
#[test]
fn test_compile_module_from_bytes_is_not_affected_by_path_swap() {
let engine = Engine::default();
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let wasm_path = temp_dir.path().join("swap.wasm");
let module_a = wat::parse_str(
r#"
(module
(func (export "main") (result i32)
i32.const 1))
"#,
)
.expect("Failed to compile module A WAT");
let module_b = wat::parse_str(
r#"
(module
(func (export "main") (result i32)
i32.const 2))
"#,
)
.expect("Failed to compile module B WAT");
std::fs::write(&wasm_path, &module_a).expect("Failed to write initial module");
let module = compile_module_from_bytes(&engine, &wasm_path, &module_a)
.expect("Should compile from read bytes");
std::fs::write(&wasm_path, &module_b).expect("Failed to overwrite module");
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])
.expect("Failed to instantiate module from read bytes");
let main = instance
.get_typed_func::<(), i32>(&mut store, "main")
.expect("Failed to get main export");
assert_eq!(main.call(&mut store, ()).expect("Failed to call main"), 1);
}
}