Skip to main content

mimobox_wasm/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![warn(missing_docs)]
3//! mimobox-wasm: Wasm sandbox backend.
4//!
5//! Implements a Wasm sandbox on top of the Wasmtime runtime with WASI Preview 1 support.
6//! Core design:
7//! - Globally shared Engine, owned by `WasmSandbox` and reused across multiple `execute` calls.
8//! - Module compilation cache based on SHA256 hashes of file content to avoid repeated compilation.
9//! - Independent Store per `execute`, with a fresh WASI context and resource limits.
10//! - stdout/stderr captured into in-memory buffers through `MemoryOutputPipe` with a built-in capacity limit.
11//! - Dual execution-time limits with fuel and epoch interruption.
12
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::time::{Instant, UNIX_EPOCH};
17
18use sha2::{Digest, Sha256};
19use wasmtime::{
20    Config, Engine, Linker, Module, OptLevel, Store, StoreLimits, StoreLimitsBuilder, Trap,
21};
22use wasmtime_wasi::I32Exit;
23use wasmtime_wasi::p1::{self, WasiP1Ctx};
24use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
25use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
26
27use mimobox_core::{Sandbox, SandboxConfig, SandboxError, SandboxResult};
28
29/// Sandbox Store data combining the WASI context and resource limits.
30///
31/// [FATAL-01 fix] Embeds `StoreLimits` in the Store data type so that the `store.limiter()`
32/// callback correctly returns `&mut dyn ResourceLimiter`, allowing `memory_limit_mb` to be
33/// applied to the Wasm runtime.
34struct StoreData {
35    wasi: WasiP1Ctx,
36    limits: StoreLimits,
37}
38
39/// Logging macro.
40fn wasm_logging_enabled() -> bool {
41    std::env::var_os("MIMOBOX_WASM_QUIET").is_none()
42}
43
44macro_rules! log_info {
45    ($($arg:tt)*) => {
46        if wasm_logging_enabled() {
47            eprintln!("[mimobox:wasm:info] {}", format!($($arg)*))
48        }
49    };
50}
51
52macro_rules! log_warn {
53    ($($arg:tt)*) => {
54        if wasm_logging_enabled() {
55            eprintln!("[mimobox:wasm:warn] {}", format!($($arg)*))
56        }
57    };
58}
59
60/// Fuel estimation factor: about 15 million Wasm instructions (fuel) per second, including 50% headroom.
61const FUEL_PER_SECOND: u64 = 15_000_000;
62
63/// Default fuel limit when no timeout is configured, roughly equivalent to 10 million Wasm instructions.
64const DEFAULT_FUEL_LIMIT: u64 = 10_000_000;
65
66/// Maximum stdout/stderr buffer capacity: 1 MB.
67/// The `MemoryOutputPipe` capacity parameter also acts as the write limit;
68/// writes beyond this capacity return `StreamError::Closed`.
69const OUTPUT_MAX_CAPACITY: usize = 1024 * 1024;
70
71/// Maximum returned size for a single output stream: 4 MB; excess data is truncated and logged as a warning.
72const MAX_OUTPUT_SIZE: usize = 4 * 1024 * 1024;
73
74/// Maximum Wasm module file size: 100 MB.
75const MAX_WASM_FILE_SIZE: u64 = 100 * 1024 * 1024;
76
77/// Default memory limit: 64 MB, used when the config does not specify one.
78const DEFAULT_MEMORY_LIMIT_MB: u64 = 64;
79
80/// Epoch tick interval: 10 ms.
81const EPOCH_TICK_INTERVAL_MS: u64 = 10;
82
83/// Dynamically calculates the fuel limit from `timeout_secs`.
84///
85/// [IMPORTANT-01 fix] Roughly maps `timeout_secs` to a fuel quota.
86/// Fuel is consumed only while executing pure Wasm instructions; wait time during WASI I/O does
87/// not count. Therefore, fuel is an approximate timeout mechanism paired with epoch interruption
88/// to enforce wall-clock timeout.
89fn fuel_from_timeout(timeout_secs: Option<u64>) -> u64 {
90    match timeout_secs {
91        Some(secs) => secs.saturating_mul(FUEL_PER_SECOND),
92        None => DEFAULT_FUEL_LIMIT,
93    }
94}
95
96/// Reads output and truncates it to the maximum size.
97fn truncate_output(data: Vec<u8>, label: &str) -> Vec<u8> {
98    if data.len() > MAX_OUTPUT_SIZE {
99        log_warn!(
100            "{} output exceeded limit ({} > {} bytes), truncated",
101            label,
102            data.len(),
103            MAX_OUTPUT_SIZE
104        );
105        data[..MAX_OUTPUT_SIZE].to_vec()
106    } else {
107        data
108    }
109}
110
111/// Wasm sandbox backend.
112///
113/// Holds the globally shared Engine and module cache directory path, and creates an independent
114/// Store for each `execute` call.
115pub struct WasmSandbox {
116    engine: Arc<Engine>,
117    config: SandboxConfig,
118    cache_dir: PathBuf,
119    epoch_running: Arc<AtomicBool>,
120    epoch_thread: Option<std::thread::JoinHandle<()>>,
121}
122
123/// Calculates the SHA256 hash of file content.
124///
125/// [IMPORTANT-02 fix] Uses SHA256 instead of `DefaultHasher`, generating cache keys from file
126/// content rather than path plus modification time to eliminate TOCTOU race conditions.
127fn content_hash(data: &[u8]) -> String {
128    let hash = Sha256::digest(data);
129    format!("{:x}", hash)
130}
131
132/// Gets a lightweight metadata fingerprint for a file: size plus modification time.
133///
134/// Used to quickly determine whether a file may have changed, avoiding a full file read and
135/// SHA256 calculation on every `execute` call.
136fn file_fingerprint(path: &Path) -> Option<(u64, u64)> {
137    let meta = std::fs::metadata(path).ok()?;
138    let size = meta.len();
139    let mtime = meta
140        .modified()
141        .ok()?
142        .duration_since(UNIX_EPOCH)
143        .ok()?
144        .as_nanos() as u64;
145    Some((size, mtime))
146}
147
148fn compile_module_from_bytes(
149    engine: &Engine,
150    wasm_path: &Path,
151    bytes: &[u8],
152) -> Result<Module, SandboxError> {
153    // SECURITY: 调用方在读取字节后立刻使用同一份不可变切片编译,
154    // 避免“先读取算哈希、再按路径重新打开编译”的 TOCTOU 竞态。
155    Module::from_binary(engine, bytes).map_err(|e| {
156        SandboxError::ExecutionFailed(format!(
157            "Failed to load Wasm module ({:?}): {}",
158            wasm_path, e
159        ))
160    })
161}
162
163/// Gets or compiles a module with a disk cache.
164///
165/// Uses a hybrid cache strategy:
166/// 1. First looks up the cache mapping file using the lightweight metadata fingerprint (size plus modification time).
167/// 2. When the cache mapping hits, loads directly with the corresponding SHA256 cache key.
168/// 3. When the cache mapping misses, calculates the file content SHA256 and updates the cache.
169///
170/// This avoids reading the whole file to calculate a hash on the cache-hit hot path.
171fn get_cached_module(
172    engine: &Engine,
173    wasm_path: &Path,
174    cache_dir: &Path,
175) -> Result<Module, SandboxError> {
176    let _ = std::fs::create_dir_all(cache_dir);
177
178    let fingerprint = match file_fingerprint(wasm_path) {
179        Some(fp) => fp,
180        None => {
181            // 无法获取元数据时也只读取一次文件,避免在读取与编译之间被路径替换。
182            let file_data = std::fs::read(wasm_path).map_err(|e| {
183                SandboxError::ExecutionFailed(format!("Failed to read Wasm file: {}", e))
184            })?;
185            return compile_module_from_bytes(engine, wasm_path, &file_data);
186        }
187    };
188
189    // 缓存映射文件:记录 "fingerprint -> sha256_hash" 的映射
190    // 文件名格式: {size}_{mtime_nanos}.map
191    let map_file = cache_dir.join(format!("{}_{}.map", fingerprint.0, fingerprint.1));
192
193    // 尝试通过映射文件找到对应的缓存
194    if let Ok(hash) = std::fs::read_to_string(&map_file) {
195        let cache_path = cache_dir.join(format!("{}.cwasm", hash.trim()));
196        match std::fs::read(&cache_path) {
197            Ok(cached) => {
198                // SAFETY: 缓存文件由本系统生成,Engine 配置未变。
199                // Module::deserialize 要求输入数据来自相同 Engine 配置的 serialize() 输出。
200                // 我们在缓存写入时确保了这一点,因此反序列化是安全的。
201                match unsafe { Module::deserialize(engine, &cached) } {
202                    Ok(module) => {
203                        log_info!("Loaded module from cache: {:?}", wasm_path);
204                        return Ok(module);
205                    }
206                    Err(e) => {
207                        // 反序列化失败:缓存可能损坏或 Engine 配置变更,静默降级重新编译
208                        log_warn!("Cache deserialization failed, recompiling: {}", e);
209                        let _ = std::fs::remove_file(&cache_path);
210                        let _ = std::fs::remove_file(&map_file);
211                    }
212                }
213            }
214            Err(_) => {
215                // 缓存文件不存在,映射过期,清理并重新编译
216                let _ = std::fs::remove_file(&map_file);
217            }
218        }
219    }
220
221    // 缓存未命中:需要计算文件内容的 SHA256
222    let file_data = std::fs::read(wasm_path)
223        .map_err(|e| SandboxError::ExecutionFailed(format!("Failed to read Wasm file: {}", e)))?;
224    let hash = content_hash(&file_data);
225    let cache_path = cache_dir.join(format!("{}.cwasm", hash));
226
227    // 检查是否已有相同内容的缓存(文件内容相同但元数据不同)
228    if let Ok(cached) = std::fs::read(&cache_path) {
229        // SAFETY: 缓存文件由本系统生成,Engine 配置未变。
230        // Module::deserialize 要求输入数据来自相同 Engine 配置的 serialize() 输出。
231        // 我们在缓存写入时确保了这一点,因此反序列化是安全的。
232        match unsafe { Module::deserialize(engine, &cached) } {
233            Ok(module) => {
234                // 更新映射文件
235                let _ = std::fs::write(&map_file, &hash);
236                log_info!("Loaded module from cache (content match): {:?}", wasm_path);
237                return Ok(module);
238            }
239            Err(e) => {
240                log_warn!("Cache deserialization failed, recompiling: {}", e);
241                let _ = std::fs::remove_file(&cache_path);
242            }
243        }
244    }
245
246    // 编译模块
247    let module = compile_module_from_bytes(engine, wasm_path, &file_data)?;
248
249    // 序列化到缓存目录(原子写入:先写临时文件再 rename,避免并发读到不完整数据)
250    if let Ok(serialized) = module.serialize() {
251        let tmp_path = cache_path.with_extension("cwasm.tmp");
252        if std::fs::write(&tmp_path, &serialized).is_ok() {
253            // rename 在同一文件系统上是原子的
254            if let Err(e) = std::fs::rename(&tmp_path, &cache_path) {
255                log_warn!("Failed to rename cache file: {}", e);
256                let _ = std::fs::remove_file(&tmp_path);
257            }
258        }
259        // 更新映射文件
260        let _ = std::fs::write(&map_file, &hash);
261    }
262
263    log_info!("Compiled and cached module: {:?}", wasm_path);
264    Ok(module)
265}
266
267/// Creates the Wasmtime Engine configuration for sandbox execution.
268fn create_engine_config() -> Config {
269    let mut config = Config::new();
270    config.cranelift_opt_level(OptLevel::Speed);
271    config.consume_fuel(true);
272    config.epoch_interruption(true);
273    config.max_wasm_stack(512 * 1024); // 512KB Wasm 栈
274    config.parallel_compilation(true);
275    config
276}
277
278/// Builds a WASI Preview 1 context.
279///
280/// Configures filesystem access, environment variables, and related settings from `SandboxConfig`.
281/// stdout/stderr are captured into in-memory buffers through `MemoryOutputPipe`.
282fn build_wasi_ctx(
283    config: &SandboxConfig,
284    args: &[String],
285    stdout_pipe: MemoryOutputPipe,
286    stderr_pipe: MemoryOutputPipe,
287) -> WasiP1Ctx {
288    let mut builder = WasiCtxBuilder::new();
289
290    // 设置命令行参数
291    for arg in args {
292        builder.arg(arg);
293    }
294
295    // 设置最小必要环境变量
296    builder.env("HOME", "/tmp");
297    builder.env("PATH", "/usr/bin:/bin");
298    builder.env("TERM", "dumb");
299    builder.env("SANDBOX", "wasm");
300
301    // 配置 stdout/stderr 捕获
302    builder.stdout(Box::new(stdout_pipe));
303    builder.stderr(Box::new(stderr_pipe));
304
305    // 文件系统访问:仅允许 config 中配置的路径
306    for path in &config.fs_readonly {
307        if let Some(path_str) = path.to_str() {
308            if path.exists() {
309                // 只授予 READ 权限,WASI 的目录创建/删除等写操作会被拒绝。
310                if let Err(e) =
311                    builder.preopened_dir(path, path_str, DirPerms::READ, FilePerms::READ)
312                {
313                    log_warn!("Failed to preopen read-only dir {:?}: {}", path, e);
314                }
315            } else {
316                log_warn!("Read-only path does not exist: {:?}", path);
317            }
318        }
319    }
320    for path in &config.fs_readwrite {
321        if let Some(path_str) = path.to_str() {
322            if path.exists() {
323                if let Err(e) =
324                    builder.preopened_dir(path, path_str, DirPerms::all(), FilePerms::all())
325                {
326                    log_warn!("Failed to preopen read-write dir {:?}: {}", path, e);
327                }
328            } else {
329                log_warn!("Read-write path does not exist: {:?}", path);
330            }
331        }
332    }
333
334    if config.deny_network {
335        log_info!("WASI network denied by SandboxConfig; no sockets are preopened");
336    } else {
337        log_warn!(
338            "SandboxConfig allows network, but WASI backend cannot enable network access; keeping network denied"
339        );
340    }
341
342    // 时钟能力:WasiCtxBuilder 仅暴露 wall_clock/monotonic_clock 替换点,
343    // 没有 WASI Preview 1 clocks 的禁用/白名单 API;执行时限由 fuel、epoch 和 timeout 控制。
344
345    builder.build_p1()
346}
347
348impl Sandbox for WasmSandbox {
349    fn new(config: SandboxConfig) -> Result<Self, SandboxError> {
350        let engine_config = create_engine_config();
351        let engine = Arc::new(Engine::new(&engine_config).map_err(|e| {
352            SandboxError::ExecutionFailed(format!("Failed to create Wasmtime Engine: {}", e))
353        })?);
354
355        let epoch_running = Arc::new(AtomicBool::new(true));
356        let epoch_thread_engine = engine.clone();
357        let epoch_thread_running = epoch_running.clone();
358        let epoch_thread = std::thread::Builder::new()
359            .name("mimobox-wasm-epoch-ticker".to_string())
360            .spawn(move || {
361                let tick_interval = std::time::Duration::from_millis(EPOCH_TICK_INTERVAL_MS);
362                while epoch_thread_running.load(Ordering::Relaxed) {
363                    std::thread::sleep(tick_interval);
364                    epoch_thread_engine.increment_epoch();
365                }
366            })
367            .map_err(|e| {
368                SandboxError::ExecutionFailed(format!("Failed to start Wasm epoch ticker: {}", e))
369            })?;
370
371        // [IMPORTANT-02 修复] 使用用户专属缓存目录,避免不同用户之间的缓存污染
372        // SAFETY: geteuid() 是无副作用的系统调用,始终返回有效的 uid。
373        let uid = unsafe { libc::geteuid() };
374        let cache_dir = std::env::temp_dir().join(format!("mimobox-cache-{}", uid));
375
376        log_info!(
377            "Created Wasm sandbox backend, memory_limit={:?}MB, timeout={:?}s, cache_dir={:?}",
378            config.memory_limit_mb,
379            config.timeout_secs,
380            cache_dir,
381        );
382
383        Ok(Self {
384            engine,
385            config,
386            cache_dir,
387            epoch_running,
388            epoch_thread: Some(epoch_thread),
389        })
390    }
391
392    fn execute(&mut self, cmd: &[String]) -> Result<SandboxResult, SandboxError> {
393        let start = Instant::now();
394
395        if cmd.is_empty() {
396            return Err(SandboxError::ExecutionFailed("Command is empty".into()));
397        }
398
399        let wasm_path = Path::new(&cmd[0]);
400        if !wasm_path.exists() {
401            return Err(SandboxError::ExecutionFailed(format!(
402                "Wasm file does not exist: {:?}",
403                wasm_path
404            )));
405        }
406
407        // [MINOR-07] 预检查文件大小,防止超大文件导致编译时 OOM
408        if let Ok(meta) = std::fs::metadata(wasm_path)
409            && meta.len() > MAX_WASM_FILE_SIZE
410        {
411            return Err(SandboxError::ExecutionFailed(format!(
412                "Wasm file too large: {} bytes (limit {} bytes)",
413                meta.len(),
414                MAX_WASM_FILE_SIZE
415            )));
416        }
417
418        // 1. 获取或编译模块(带缓存)
419        let module = get_cached_module(&self.engine, wasm_path, &self.cache_dir)?;
420
421        // 2. [IMPORTANT-03 说明] stdout/stderr 缓冲区容量限制
422        // MemoryOutputPipe 的 capacity 参数是写入上限而非初始容量,
423        // 超过此容量后 OutputStream::write() 返回 StreamError::Trap,
424        // check_write() 返回 StreamError::Closed。
425        let stdout_pipe = MemoryOutputPipe::new(OUTPUT_MAX_CAPACITY);
426        let stdout_reader = stdout_pipe.clone(); // 保留读取端
427        let stderr_pipe = MemoryOutputPipe::new(OUTPUT_MAX_CAPACITY);
428        let stderr_reader = stderr_pipe.clone(); // 保留读取端
429
430        // 3. 构建 WASI 上下文
431        let wasi_ctx = build_wasi_ctx(&self.config, cmd, stdout_pipe, stderr_pipe);
432
433        // 4. 创建 Linker 并注册 WASI Preview 1
434        // 注意:Linker 的类型参数必须与 Store data type 一致
435        let mut linker: Linker<StoreData> = Linker::new(&self.engine);
436        p1::add_to_linker_sync(&mut linker, |data| &mut data.wasi).map_err(|e| {
437            SandboxError::ExecutionFailed(format!("Failed to register WASI Preview 1: {}", e))
438        })?;
439
440        // 5. [FATAL-01 修复] 创建带资源限制的 Store
441        // 将 memory_limit_mb 通过 StoreLimitsBuilder 实际应用到 Wasm 运行时
442        let memory_limit_bytes: usize = self
443            .config
444            .memory_limit_mb
445            .map(|mb| mb * 1024 * 1024)
446            .unwrap_or(DEFAULT_MEMORY_LIMIT_MB * 1024 * 1024)
447            .try_into()
448            .unwrap_or(usize::MAX);
449
450        let limits = StoreLimitsBuilder::new()
451            .memory_size(memory_limit_bytes)
452            .memories(1) // 单个线性内存
453            .tables(4) // 限制间接调用表数量
454            .instances(1) // 单实例
455            .trap_on_grow_failure(true) // 内存增长失败时 trap 而非返回 -1
456            .build();
457
458        let store_data = StoreData {
459            wasi: wasi_ctx,
460            limits,
461        };
462        let mut store = Store::new(&self.engine, store_data);
463        store.limiter(|data| &mut data.limits);
464
465        // 6. [IMPORTANT-01 修复] 根据 timeout_secs 动态设置 fuel 上限
466        let fuel_limit = fuel_from_timeout(self.config.timeout_secs);
467        store
468            .set_fuel(fuel_limit)
469            .map_err(|e| SandboxError::ExecutionFailed(format!("Failed to set fuel: {}", e)))?;
470
471        // 7. [IMPORTANT-01 补充] 设置 epoch deadline 实现墙钟超时
472        // epoch_interruption 可中断包括 WASI I/O 阻塞在内的执行,
473        // 弥补 fuel 仅在纯 Wasm 指令执行时消耗的不足。
474        store.epoch_deadline_trap();
475        let epoch_deadline_ticks = self
476            .config
477            .timeout_secs
478            .map(|s| s.saturating_mul(100)) // 每 10ms 一个 epoch tick
479            .unwrap_or(3000); // 默认 30s
480        store.set_epoch_deadline(epoch_deadline_ticks);
481
482        // 8. 实例化模块
483        let instance = linker.instantiate(&mut store, &module).map_err(|e| {
484            SandboxError::ExecutionFailed(format!("Failed to instantiate Wasm module: {}", e))
485        })?;
486
487        // 9. 调用 _start 函数(WASI Command 模式)
488        // WASI Command 通过 _start 进入,正常退出时调用 proc_exit(code),
489        // 这会触发 I32Exit 错误,其中包含退出码。
490        let exit_code = match instance.get_typed_func::<(), ()>(&mut store, "_start") {
491            Ok(start_func) => {
492                match start_func.call(&mut store, ()) {
493                    Ok(()) => Some(0),
494                    Err(e) => {
495                        // 检查是否是 WASI 正常退出(I32Exit)
496                        // I32Exit 可能被包装在 error chain 中,需要遍历查找
497                        if let Some(exit) = find_exit_code(&e) {
498                            Some(exit)
499                        } else if is_fuel_exhausted(&store) || is_epoch_interrupt(&e) {
500                            log_warn!(
501                                "Execution timed out (fuel exhausted or epoch deadline exceeded)"
502                            );
503                            let elapsed = start.elapsed();
504                            let stdout =
505                                truncate_output(stdout_reader.contents().to_vec(), "stdout");
506                            let stderr =
507                                truncate_output(stderr_reader.contents().to_vec(), "stderr");
508                            return Ok(SandboxResult {
509                                stdout,
510                                stderr,
511                                exit_code: None,
512                                elapsed,
513                                timed_out: true,
514                            });
515                        } else if is_memory_trap(&e) {
516                            log_info!("Wasm memory limit exceeded, mapping to exit code 1");
517                            Some(1)
518                        } else {
519                            log_warn!("Wasm execution error: {}", e);
520                            None
521                        }
522                    }
523                }
524            }
525            Err(_) => {
526                // 没有 _start 函数,尝试查找 main 函数
527                match instance.get_typed_func::<(), i32>(&mut store, "main") {
528                    Ok(main_func) => match main_func.call(&mut store, ()) {
529                        Ok(code) => Some(code),
530                        Err(e) => {
531                            if let Some(exit) = find_exit_code(&e) {
532                                Some(exit)
533                            } else if is_memory_trap(&e) {
534                                log_info!("Wasm memory limit exceeded, mapping to exit code 1");
535                                Some(1)
536                            } else {
537                                log_warn!("main function execution failed: {}", e);
538                                None
539                            }
540                        }
541                    },
542                    Err(_) => {
543                        return Err(SandboxError::ExecutionFailed(
544                            "Wasm module has no _start or main export function".into(),
545                        ));
546                    }
547                }
548            }
549        };
550
551        let elapsed = start.elapsed();
552
553        // 10. 从 clone 的 MemoryOutputPipe 中读取捕获的输出(带截断保护)
554        let stdout = truncate_output(stdout_reader.contents().to_vec(), "stdout");
555        let stderr = truncate_output(stderr_reader.contents().to_vec(), "stderr");
556
557        log_info!(
558            "Wasm execution completed, exit_code={:?}, elapsed={:.2}ms",
559            exit_code,
560            elapsed.as_secs_f64() * 1000.0
561        );
562
563        Ok(SandboxResult {
564            stdout,
565            stderr,
566            exit_code,
567            elapsed,
568            timed_out: false,
569        })
570    }
571
572    fn destroy(self) -> Result<(), SandboxError> {
573        log_info!("Destroying Wasm sandbox backend");
574        Ok(())
575    }
576}
577
578impl Drop for WasmSandbox {
579    fn drop(&mut self) {
580        self.epoch_running.store(false, Ordering::Relaxed);
581        if let Some(epoch_thread) = self.epoch_thread.take()
582            && let Err(e) = epoch_thread.join()
583        {
584            log_warn!("Wasm epoch ticker thread join failed: {:?}", e);
585        }
586    }
587}
588
589/// Checks whether the Store fuel is exhausted.
590fn is_fuel_exhausted(store: &Store<StoreData>) -> bool {
591    store.get_fuel().is_ok_and(|f| f == 0)
592}
593
594/// Checks whether an error is an epoch interruption, indicating wall-clock timeout.
595fn is_epoch_interrupt(error: &wasmtime::Error) -> bool {
596    if let Some(trap) = error.downcast_ref::<Trap>() {
597        matches!(trap, Trap::Interrupt)
598    } else {
599        false
600    }
601}
602
603/// Checks whether an error is caused by Wasm memory access or growth limits.
604fn is_memory_trap(error: &wasmtime::Error) -> bool {
605    if let Some(trap) = error.downcast_ref::<Trap>()
606        && matches!(trap, Trap::MemoryOutOfBounds)
607    {
608        return true;
609    }
610
611    let message = format!("{error:#}").to_ascii_lowercase();
612    message.contains("memory")
613        && (message.contains("out of bounds")
614            || message.contains("grow")
615            || message.contains("growth"))
616}
617
618/// Finds a WASI `I32Exit` exit code in the wasmtime error chain.
619///
620/// WASI `proc_exit` propagates the exit code through an `I32Exit` error, but it may be wrapped
621/// by intermediate layers such as `WasmBacktrace`. This function walks the entire error chain.
622fn find_exit_code(error: &wasmtime::Error) -> Option<i32> {
623    // 直接 downcast
624    if let Some(exit) = error.downcast_ref::<I32Exit>() {
625        return Some(exit.0);
626    }
627
628    // 检查 root cause
629    let root = error.root_cause();
630    if let Some(exit) = root.downcast_ref::<I32Exit>() {
631        return Some(exit.0);
632    }
633
634    None
635}
636
637/// Runs the Wasm sandbox cold-start benchmark.
638pub fn run_wasm_benchmark(
639    wasm_path: &str,
640    iterations: usize,
641) -> Result<(), Box<dyn std::error::Error>> {
642    println!("=== mimobox Wasm Sandbox Benchmark ===\n");
643
644    let mut config = SandboxConfig::default();
645    config.deny_network = true;
646    config.memory_limit_mb = Some(64);
647    config.timeout_secs = Some(30);
648    config.fs_readonly = vec![];
649    config.fs_readwrite = vec![];
650    config.seccomp_profile = mimobox_core::SeccompProfile::Essential;
651    config.allow_fork = false;
652    config.allowed_http_domains = vec![];
653
654    if !Path::new(wasm_path).exists() {
655        return Err(format!("Wasm file does not exist: {}", wasm_path).into());
656    }
657
658    let cmd = vec![wasm_path.to_string()];
659
660    // Phase 1: Engine 创建开销(一次性)
661    println!("Testing Engine creation overhead...");
662    let engine_start = Instant::now();
663    let mut sb = WasmSandbox::new(config.clone())?;
664    let engine_elapsed = engine_start.elapsed();
665    println!(
666        "  Engine creation: {:.2}ms",
667        engine_elapsed.as_secs_f64() * 1000.0
668    );
669
670    // Phase 2: 模块编译开销(首次)
671    println!("\nTesting first module compilation...");
672    let compile_start = Instant::now();
673    let result = sb.execute(&cmd)?;
674    let compile_elapsed = compile_start.elapsed();
675    println!(
676        "  First execution (with compilation): {:.2}ms, exit_code={:?}",
677        compile_elapsed.as_secs_f64() * 1000.0,
678        result.exit_code
679    );
680
681    // Phase 3: 冷启动测试(每次 new + execute)
682    println!(
683        "\nCold start test ({} iterations, each with new + execute)...",
684        iterations
685    );
686    let mut cold_times = Vec::with_capacity(iterations);
687    for i in 0..iterations {
688        let start = Instant::now();
689        let mut sb = WasmSandbox::new(config.clone())?;
690        let result = sb.execute(&cmd)?;
691        let elapsed = start.elapsed();
692        cold_times.push(elapsed.as_micros() as f64 / 1000.0);
693
694        if result.exit_code != Some(0) {
695            eprintln!("Iteration {} failed: exit code {:?}", i, result.exit_code);
696        }
697    }
698
699    // Phase 4: 热路径测试(复用 Engine,仅 execute)
700    println!(
701        "\nHot path test ({} iterations, reusing Engine)...",
702        iterations
703    );
704    let mut hot_times = Vec::with_capacity(iterations);
705    for _ in 0..iterations {
706        let start = Instant::now();
707        let result = sb.execute(&cmd)?;
708        let elapsed = start.elapsed();
709        hot_times.push(elapsed.as_micros() as f64 / 1000.0);
710
711        if result.exit_code != Some(0) {
712            eprintln!("Hot path execution failed: {:?}", result.exit_code);
713        }
714    }
715
716    // 统计输出
717    cold_times.sort_by(f64::total_cmp);
718    hot_times.sort_by(f64::total_cmp);
719
720    fn print_stats(label: &str, times: &[f64]) {
721        let n = times.len();
722        if n == 0 {
723            println!("{}  no data", label);
724            return;
725        }
726        let p50 = times[n / 2];
727        let p95_idx = ((n as f64 * 0.95) as usize).min(n - 1);
728        let p99_idx = ((n as f64 * 0.99) as usize).min(n - 1);
729        let avg: f64 = times.iter().sum::<f64>() / n as f64;
730
731        println!("\n{} latency:", label);
732        println!("  Min:  {:.2}ms", times[0]);
733        println!("  P50:  {:.2}ms", p50);
734        println!("  P95:  {:.2}ms", times[p95_idx]);
735        println!("  P99:  {:.2}ms", times[p99_idx]);
736        println!("  Avg:  {:.2}ms", avg);
737        println!("  Max:  {:.2}ms", times[n - 1]);
738    }
739
740    print_stats("Cold start ", &cold_times);
741    print_stats("Hot path ", &hot_times);
742
743    // 目标检查
744    let cold_p50 = cold_times[cold_times.len() / 2];
745    let hot_p50 = hot_times[hot_times.len() / 2];
746    println!("\nTarget check:");
747    println!(
748        "  Cold start P50: {:.2}ms {}",
749        cold_p50,
750        if cold_p50 < 5.0 { "[PASS]" } else { "[FAIL]" }
751    );
752    println!(
753        "  Hot path P50: {:.2}ms {}",
754        hot_p50,
755        if hot_p50 < 1.0 { "[PASS]" } else { "[FAIL]" }
756    );
757
758    println!("\n=== Test completed ===");
759    Ok(())
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use mimobox_core::Sandbox;
766    use wasmtime::{Instance, Store};
767
768    fn test_config() -> SandboxConfig {
769        let mut config = SandboxConfig::default();
770        config.timeout_secs = Some(10);
771        config.memory_limit_mb = Some(64);
772        config.fs_readonly = vec![];
773        config.fs_readwrite = vec![];
774        config.deny_network = true;
775        config.seccomp_profile = mimobox_core::SeccompProfile::Essential;
776        config.allow_fork = false;
777        config.allowed_http_domains = vec![];
778        config
779    }
780
781    #[test]
782    fn test_wasm_sandbox_create() {
783        let sb = WasmSandbox::new(test_config());
784        assert!(sb.is_ok(), "Failed to create Wasm sandbox: {:?}", sb.err());
785    }
786
787    #[test]
788    fn test_wasm_sandbox_empty_command() {
789        let mut sb = WasmSandbox::new(test_config()).expect("Failed to create");
790        let result = sb.execute(&[]);
791        assert!(result.is_err(), "Empty command should return error");
792    }
793
794    #[test]
795    fn test_wasm_sandbox_nonexistent_file() {
796        let mut sb = WasmSandbox::new(test_config()).expect("Failed to create");
797        let result = sb.execute(&["/nonexistent/file.wasm".to_string()]);
798        assert!(result.is_err(), "Nonexistent file should return error");
799    }
800
801    #[test]
802    fn test_wasm_sandbox_destroy() {
803        let sb = WasmSandbox::new(test_config()).expect("Failed to create");
804        let result = sb.destroy();
805        assert!(
806            result.is_ok(),
807            "Failed to destroy sandbox: {:?}",
808            result.err()
809        );
810    }
811
812    #[test]
813    fn test_compile_module_from_bytes_is_not_affected_by_path_swap() {
814        let engine = Engine::default();
815        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
816        let wasm_path = temp_dir.path().join("swap.wasm");
817        let module_a = wat::parse_str(
818            r#"
819                (module
820                  (func (export "main") (result i32)
821                    i32.const 1))
822            "#,
823        )
824        .expect("Failed to compile module A WAT");
825        let module_b = wat::parse_str(
826            r#"
827                (module
828                  (func (export "main") (result i32)
829                    i32.const 2))
830            "#,
831        )
832        .expect("Failed to compile module B WAT");
833
834        std::fs::write(&wasm_path, &module_a).expect("Failed to write initial module");
835        let module = compile_module_from_bytes(&engine, &wasm_path, &module_a)
836            .expect("Should compile from read bytes");
837        std::fs::write(&wasm_path, &module_b).expect("Failed to overwrite module");
838
839        let mut store = Store::new(&engine, ());
840        let instance = Instance::new(&mut store, &module, &[])
841            .expect("Failed to instantiate module from read bytes");
842        let main = instance
843            .get_typed_func::<(), i32>(&mut store, "main")
844            .expect("Failed to get main export");
845
846        assert_eq!(main.call(&mut store, ()).expect("Failed to call main"), 1);
847    }
848}