Skip to main content

rustant_tools/sandbox/
runtime.rs

1//! WASM runtime engine using wasmi for sandboxed execution.
2//!
3//! Manages the `wasmi` engine lifecycle, compiles WASM modules, and executes
4//! them with fuel metering and resource limits.  Host functions are registered
5//! in the `"env"` namespace so that guests can log messages, read input, and
6//! write output through a controlled interface.
7
8use super::config::{Capability, SandboxConfig};
9#[allow(unused_imports)]
10use wasmi::{Caller, Engine, Extern, Func, Instance, Linker, Memory, Module, Store};
11
12// ---------------------------------------------------------------------------
13// Error types
14// ---------------------------------------------------------------------------
15
16/// Error type for sandbox operations.
17#[derive(Debug, Clone, thiserror::Error)]
18pub enum SandboxError {
19    /// The WASM binary is invalid or could not be parsed.
20    #[error("invalid WASM module: {0}")]
21    ModuleInvalid(String),
22
23    /// The module could not be instantiated (e.g. missing imports).
24    #[error("module instantiation failed: {0}")]
25    InstantiationFailed(String),
26
27    /// Execution returned an error or trapped.
28    #[error("execution failed: {0}")]
29    ExecutionFailed(String),
30
31    /// Fuel / instruction budget exhausted.
32    #[error("fuel/instruction budget exhausted")]
33    OutOfFuel,
34
35    /// Memory limit exceeded.
36    #[error("memory limit exceeded")]
37    OutOfMemory,
38
39    /// Execution timed out.
40    #[error("execution timed out")]
41    Timeout,
42
43    /// Guest tried a forbidden operation.
44    #[error("capability denied: {0}")]
45    CapabilityDenied(String),
46
47    /// A host function returned an error.
48    #[error("host function error: {0}")]
49    HostError(String),
50}
51
52// ---------------------------------------------------------------------------
53// Execution result
54// ---------------------------------------------------------------------------
55
56/// The result of a successful WASM module execution.
57#[derive(Debug, Clone)]
58pub struct ExecutionResult {
59    /// Raw output bytes produced by the module via `host_write_output`.
60    pub output: Vec<u8>,
61    /// Number of fuel units consumed during execution.
62    pub fuel_consumed: u64,
63    /// Peak linear memory usage in bytes.
64    pub memory_peak_bytes: usize,
65}
66
67// ---------------------------------------------------------------------------
68// Host state
69// ---------------------------------------------------------------------------
70
71/// Per-execution state shared with host functions via the wasmi [`Store`].
72///
73/// Each sandbox invocation receives a fresh `HostState` that accumulates
74/// guest output and provides the input buffer for reading.
75pub struct HostState {
76    /// Captured stdout from the guest.
77    pub stdout: Vec<u8>,
78    /// Captured stderr from the guest.
79    pub stderr: Vec<u8>,
80    /// Module output buffer (populated by `host_write_output`).
81    pub output: Vec<u8>,
82    /// Module input buffer (read by `host_read_input`).
83    pub input: Vec<u8>,
84    /// Capabilities granted to this execution.
85    pub capabilities: Vec<Capability>,
86    /// Peak memory usage tracked across host calls.
87    pub memory_peak: usize,
88}
89
90// ---------------------------------------------------------------------------
91// WASM runtime
92// ---------------------------------------------------------------------------
93
94/// Manages the wasmi [`Engine`] and provides module validation and execution.
95///
96/// The engine is created once with fuel metering enabled.  Each call to
97/// [`execute`](Self::execute) compiles, instantiates, and runs a WASM module
98/// inside a fresh [`Store`] with its own fuel budget and host state.
99pub struct WasmRuntime {
100    engine: Engine,
101}
102
103impl WasmRuntime {
104    /// Create a new `WasmRuntime` with fuel metering enabled.
105    pub fn new() -> Self {
106        let mut config = wasmi::Config::default();
107        config.consume_fuel(true);
108        let engine = Engine::new(&config);
109        Self { engine }
110    }
111
112    /// Validate that `wasm_bytes` contains a well-formed WASM (or WAT) module.
113    ///
114    /// Accepts both `.wasm` binary and `.wat` text format (when the `wat`
115    /// crate feature is enabled, which it is by default in wasmi).
116    pub fn validate_module(&self, wasm_bytes: &[u8]) -> Result<(), SandboxError> {
117        Module::new(&self.engine, wasm_bytes)
118            .map(|_| ())
119            .map_err(|e| SandboxError::ModuleInvalid(e.to_string()))
120    }
121
122    /// Compile, instantiate, and execute a WASM module.
123    ///
124    /// # Execution flow
125    ///
126    /// 1. Compile the WASM (or WAT) bytes into a [`Module`].
127    /// 2. Create a [`Store`] seeded with fuel from `config`.
128    /// 3. Register host functions (`host_log`, `host_write_output`,
129    ///    `host_read_input`, `host_get_input_len`) under the `"env"` namespace.
130    /// 4. Instantiate the module (runs the WASM start section if present).
131    /// 5. Call the exported `execute(ptr, len) -> i32` function, or fall back
132    ///    to `_start()` if `execute` is not exported.
133    /// 6. Collect and return the [`ExecutionResult`].
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SandboxError::OutOfFuel`] when the fuel budget is exhausted,
138    /// [`SandboxError::ModuleInvalid`] for malformed WASM, and other variants
139    /// for instantiation or execution failures.
140    pub fn execute(
141        &self,
142        wasm_bytes: &[u8],
143        input: &[u8],
144        config: &SandboxConfig,
145    ) -> Result<ExecutionResult, SandboxError> {
146        // 1. Compile module
147        let module = Module::new(&self.engine, wasm_bytes)
148            .map_err(|e| SandboxError::ModuleInvalid(e.to_string()))?;
149
150        // 2. Create store with host state and fuel budget
151        let host_state = HostState {
152            stdout: Vec::new(),
153            stderr: Vec::new(),
154            output: Vec::new(),
155            input: input.to_vec(),
156            capabilities: config.capabilities.clone(),
157            memory_peak: 0,
158        };
159        let mut store = Store::new(&self.engine, host_state);
160        store
161            .set_fuel(config.resource_limits.max_fuel)
162            .map_err(|e| SandboxError::ExecutionFailed(e.to_string()))?;
163
164        // 3. Create linker and register host functions
165        let mut linker = <Linker<HostState>>::new(&self.engine);
166        Self::register_host_functions(&mut linker)?;
167
168        // 4. Instantiate the module (and run its WASM start section, if any)
169        let instance: Instance =
170            linker
171                .instantiate_and_start(&mut store, &module)
172                .map_err(|e: wasmi::Error| {
173                    let msg = e.to_string();
174                    if msg.contains("fuel") {
175                        SandboxError::OutOfFuel
176                    } else {
177                        SandboxError::InstantiationFailed(msg)
178                    }
179                })?;
180
181        // 5. Call the entry-point export.
182        //    Prefer `execute(ptr, len) -> i32`; fall back to `_start()`.
183        if let Ok(func) = instance.get_typed_func::<(i32, i32), i32>(&store, "execute") {
184            func.call(&mut store, (0, input.len() as i32))
185                .map_err(map_wasmi_error)?;
186        } else if let Ok(func) = instance.get_typed_func::<(), ()>(&store, "_start") {
187            func.call(&mut store, ()).map_err(map_wasmi_error)?;
188        } else {
189            return Err(SandboxError::ExecutionFailed(
190                "module exports neither '_start' nor 'execute' function".to_string(),
191            ));
192        }
193
194        // 6. Collect results
195        let fuel_remaining = store.get_fuel().unwrap_or(0);
196        let fuel_consumed = config
197            .resource_limits
198            .max_fuel
199            .saturating_sub(fuel_remaining);
200
201        let memory_peak_bytes: usize = instance
202            .get_export(&store, "memory")
203            .and_then(Extern::into_memory)
204            .map(|m: Memory| m.data(&store).len())
205            .unwrap_or(0);
206
207        let host_peak = store.data().memory_peak;
208        let output = std::mem::take(&mut store.data_mut().output);
209
210        Ok(ExecutionResult {
211            output,
212            fuel_consumed,
213            memory_peak_bytes: std::cmp::max(memory_peak_bytes, host_peak),
214        })
215    }
216
217    // -- Host function registration -----------------------------------------
218
219    /// Register the four standard host functions in the `"env"` namespace.
220    ///
221    /// | Function             | Signature                      | Purpose                        |
222    /// |----------------------|--------------------------------|--------------------------------|
223    /// | `host_log`           | `(ptr: i32, len: i32)`         | Log UTF-8 message from guest   |
224    /// | `host_write_output`  | `(ptr: i32, len: i32)`         | Append bytes to output buffer  |
225    /// | `host_read_input`    | `(ptr: i32, len: i32) -> i32`  | Copy input into guest memory   |
226    /// | `host_get_input_len` | `() -> i32`                    | Return input buffer length     |
227    fn register_host_functions(linker: &mut Linker<HostState>) -> Result<(), SandboxError> {
228        // -- env::host_log(ptr, len) ------------------------------------------
229        linker
230            .func_wrap(
231                "env",
232                "host_log",
233                |caller: Caller<'_, HostState>, ptr: i32, len: i32| {
234                    let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
235                        Some(m) => m,
236                        None => return,
237                    };
238                    let (ptr, len) = (ptr as usize, len as usize);
239                    let data = mem.data(&caller);
240                    if let Some(end) = ptr.checked_add(len)
241                        && end <= data.len()
242                        && let Ok(msg) = std::str::from_utf8(&data[ptr..end])
243                    {
244                        tracing::debug!(target: "wasm_guest", "{}", msg);
245                    }
246                },
247            )
248            .map_err(|e| SandboxError::InstantiationFailed(e.to_string()))?;
249
250        // -- env::host_write_output(ptr, len) ---------------------------------
251        linker
252            .func_wrap(
253                "env",
254                "host_write_output",
255                |mut caller: Caller<'_, HostState>, ptr: i32, len: i32| {
256                    let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
257                        Some(m) => m,
258                        None => return,
259                    };
260                    let (ptr, len) = (ptr as usize, len as usize);
261                    let data = mem.data(&caller);
262                    let bytes = match ptr.checked_add(len) {
263                        Some(end) if end <= data.len() => data[ptr..end].to_vec(),
264                        _ => return,
265                    };
266                    caller.data_mut().output.extend_from_slice(&bytes);
267                },
268            )
269            .map_err(|e| SandboxError::InstantiationFailed(e.to_string()))?;
270
271        // -- env::host_read_input(ptr, len) -> i32 ----------------------------
272        linker
273            .func_wrap(
274                "env",
275                "host_read_input",
276                |mut caller: Caller<'_, HostState>, ptr: i32, len: i32| -> i32 {
277                    let input_bytes = caller.data().input.clone();
278                    let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
279                        Some(m) => m,
280                        None => return 0,
281                    };
282                    let (ptr, len) = (ptr as usize, len as usize);
283                    let to_copy = std::cmp::min(len, input_bytes.len());
284                    let mem_data = mem.data_mut(&mut caller);
285                    match ptr.checked_add(to_copy) {
286                        Some(end) if end <= mem_data.len() => {
287                            mem_data[ptr..end].copy_from_slice(&input_bytes[..to_copy]);
288                            to_copy as i32
289                        }
290                        _ => 0,
291                    }
292                },
293            )
294            .map_err(|e| SandboxError::InstantiationFailed(e.to_string()))?;
295
296        // -- env::host_get_input_len() -> i32 ---------------------------------
297        linker
298            .func_wrap(
299                "env",
300                "host_get_input_len",
301                |caller: Caller<'_, HostState>| -> i32 { caller.data().input.len() as i32 },
302            )
303            .map_err(|e| SandboxError::InstantiationFailed(e.to_string()))?;
304
305        Ok(())
306    }
307}
308
309impl Default for WasmRuntime {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Error mapping
317// ---------------------------------------------------------------------------
318
319/// Map a [`wasmi::Error`] into a [`SandboxError`], detecting out-of-fuel traps.
320fn map_wasmi_error(err: wasmi::Error) -> SandboxError {
321    let msg = err.to_string();
322    if msg.contains("fuel") {
323        SandboxError::OutOfFuel
324    } else {
325        SandboxError::ExecutionFailed(msg)
326    }
327}
328
329// ---------------------------------------------------------------------------
330// Tests
331// ---------------------------------------------------------------------------
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    /// Helper: a default config with generous fuel and host calls enabled.
338    fn default_config() -> SandboxConfig {
339        SandboxConfig::new()
340            .with_fuel_limit(1_000_000)
341            .allow_host_calls()
342    }
343
344    // -- Runtime creation ----------------------------------------------------
345
346    #[test]
347    fn test_runtime_creation() {
348        let _runtime = WasmRuntime::new();
349    }
350
351    // -- Module validation ---------------------------------------------------
352
353    #[test]
354    fn test_validate_valid_module() {
355        let runtime = WasmRuntime::new();
356        let wat = b"(module (func (export \"_start\")))";
357        assert!(runtime.validate_module(wat).is_ok());
358    }
359
360    #[test]
361    fn test_validate_invalid_module() {
362        let runtime = WasmRuntime::new();
363        let invalid = b"this is definitely not valid wasm";
364        let result = runtime.validate_module(invalid);
365        assert!(result.is_err());
366        match result {
367            Err(SandboxError::ModuleInvalid(_)) => {}
368            other => panic!("expected ModuleInvalid, got {:?}", other),
369        }
370    }
371
372    // -- Execution -----------------------------------------------------------
373
374    #[test]
375    fn test_execute_simple_module() {
376        let runtime = WasmRuntime::new();
377        let wat = b"(module (func (export \"_start\")))";
378        let config = default_config();
379        let result = runtime.execute(wat, &[], &config).unwrap();
380        assert!(result.output.is_empty());
381    }
382
383    #[test]
384    fn test_execute_module_with_output() {
385        let runtime = WasmRuntime::new();
386        let wat = br#"
387            (module
388                (import "env" "host_write_output" (func $write (param i32 i32)))
389                (memory (export "memory") 1)
390                (data (i32.const 0) "hello")
391                (func (export "_start")
392                    i32.const 0
393                    i32.const 5
394                    call $write
395                )
396            )
397        "#;
398        let config = default_config();
399        let result = runtime.execute(wat, &[], &config).unwrap();
400        assert_eq!(result.output, b"hello");
401    }
402
403    #[test]
404    fn test_execute_fuel_limit() {
405        let runtime = WasmRuntime::new();
406        let wat = br#"
407            (module
408                (func (export "_start")
409                    (local $i i32)
410                    (loop $loop
411                        (local.set $i (i32.add (local.get $i) (i32.const 1)))
412                        (br_if $loop (i32.lt_u (local.get $i) (i32.const 999999999)))
413                    )
414                )
415            )
416        "#;
417        let config = SandboxConfig::new()
418            .with_fuel_limit(1_000)
419            .allow_host_calls();
420        let result = runtime.execute(wat, &[], &config);
421        assert!(
422            matches!(result, Err(SandboxError::OutOfFuel)),
423            "expected OutOfFuel, got {:?}",
424            result,
425        );
426    }
427
428    #[test]
429    fn test_execute_reads_input() {
430        let runtime = WasmRuntime::new();
431        let wat = br#"
432            (module
433                (import "env" "host_get_input_len" (func $get_len (result i32)))
434                (import "env" "host_read_input" (func $read (param i32 i32) (result i32)))
435                (import "env" "host_write_output" (func $write (param i32 i32)))
436                (memory (export "memory") 1)
437                (func (export "_start")
438                    (local $len i32)
439                    (local $read_len i32)
440                    (local.set $len (call $get_len))
441                    (local.set $read_len (call $read (i32.const 0) (local.get $len)))
442                    (call $write (i32.const 0) (local.get $read_len))
443                )
444            )
445        "#;
446        let config = default_config();
447        let input = b"world";
448        let result = runtime.execute(wat, input, &config).unwrap();
449        assert_eq!(result.output, b"world");
450    }
451
452    #[test]
453    fn test_fuel_consumption_tracked() {
454        let runtime = WasmRuntime::new();
455        let wat = b"(module (func (export \"_start\") nop))";
456        let config = default_config();
457        let result = runtime.execute(wat, &[], &config).unwrap();
458        assert!(
459            result.fuel_consumed > 0,
460            "fuel_consumed should be non-zero after execution",
461        );
462    }
463}