Skip to main content

clawft_kernel/wasm_runner/
runner.rs

1//! WASM tool runner: compilation, execution, module cache, and hashing.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use sha2::{Digest, Sha256};
7
8use super::types::*;
9
10// ---------------------------------------------------------------------------
11// WASM Tool Runner
12// ---------------------------------------------------------------------------
13
14/// WASM tool runner.
15///
16/// When the `wasm-sandbox` feature is enabled, this uses Wasmtime
17/// for actual WASM execution. Without the feature, all tool loads
18/// are rejected with [`WasmError::RuntimeUnavailable`].
19pub struct WasmToolRunner {
20    config: WasmSandboxConfig,
21    #[cfg(feature = "wasm-sandbox")]
22    engine: wasmtime::Engine,
23}
24
25impl WasmToolRunner {
26    /// Create a new WASM tool runner with the given configuration.
27    pub fn new(config: WasmSandboxConfig) -> Self {
28        #[cfg(feature = "wasm-sandbox")]
29        {
30            let mut wt_config = wasmtime::Config::new();
31            wt_config.consume_fuel(true);
32            wt_config.async_support(true);
33            // Memory limit is enforced per-store, not per-engine
34            let engine = wasmtime::Engine::new(&wt_config)
35                .expect("failed to create wasmtime engine");
36            Self { config, engine }
37        }
38        #[cfg(not(feature = "wasm-sandbox"))]
39        {
40            Self { config }
41        }
42    }
43
44    /// Get the sandbox configuration.
45    pub fn config(&self) -> &WasmSandboxConfig {
46        &self.config
47    }
48
49    /// Validate a WASM module's bytes without loading it.
50    ///
51    /// Checks module size, magic bytes, and (when the runtime is
52    /// available) uses wasmtime::Module::validate() for full validation.
53    pub fn validate_wasm(&self, wasm_bytes: &[u8]) -> Result<WasmValidation, WasmError> {
54        // Check module size
55        if wasm_bytes.len() > self.config.max_module_size_bytes {
56            return Err(WasmError::ModuleTooLarge {
57                size: wasm_bytes.len(),
58                limit: self.config.max_module_size_bytes,
59            });
60        }
61
62        // Check WASM magic bytes (\0asm)
63        if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\0asm" {
64            return Err(WasmError::InvalidModule(
65                "missing WASM magic bytes (\\0asm)".into(),
66            ));
67        }
68
69        let mut warnings = Vec::new();
70
71        // Parse version (bytes 4-7 in little-endian)
72        let version =
73            u32::from_le_bytes([wasm_bytes[4], wasm_bytes[5], wasm_bytes[6], wasm_bytes[7]]);
74        if version != 1 {
75            warnings.push(format!("unexpected WASM version: {version} (expected 1)"));
76        }
77
78        #[cfg(not(feature = "wasm-sandbox"))]
79        {
80            Ok(WasmValidation {
81                valid: true,
82                exports: Vec::new(),
83                imports: Vec::new(),
84                estimated_memory: 0,
85                warnings,
86            })
87        }
88
89        #[cfg(feature = "wasm-sandbox")]
90        {
91            // Full validation via wasmtime
92            if let Err(e) = wasmtime::Module::validate(&self.engine, wasm_bytes) {
93                return Err(WasmError::InvalidModule(e.to_string()));
94            }
95
96            // Parse module to extract exports/imports
97            match wasmtime::Module::new(&self.engine, wasm_bytes) {
98                Ok(module) => {
99                    let exports: Vec<String> = module
100                        .exports()
101                        .map(|e| e.name().to_string())
102                        .collect();
103                    let imports: Vec<String> = module
104                        .imports()
105                        .map(|i| format!("{}::{}", i.module(), i.name()))
106                        .collect();
107                    Ok(WasmValidation {
108                        valid: true,
109                        exports,
110                        imports,
111                        estimated_memory: 0,
112                        warnings,
113                    })
114                }
115                Err(e) => Err(WasmError::CompilationFailed(e.to_string())),
116            }
117        }
118    }
119
120    /// Load a WASM tool from bytes.
121    ///
122    /// Validates the module, computes a SHA-256 hash, and (with `wasm-sandbox`)
123    /// compiles it with the Wasmtime engine.
124    pub fn load_tool(&self, name: &str, wasm_bytes: &[u8]) -> Result<WasmTool, WasmError> {
125        let validation = self.validate_wasm(wasm_bytes)?;
126
127        if !validation.valid {
128            return Err(WasmError::InvalidModule(validation.warnings.join("; ")));
129        }
130
131        let module_hash = compute_module_hash(wasm_bytes);
132
133        #[cfg(not(feature = "wasm-sandbox"))]
134        {
135            let _ = name;
136            let _ = module_hash;
137            Err(WasmError::RuntimeUnavailable)
138        }
139
140        #[cfg(feature = "wasm-sandbox")]
141        {
142            Ok(WasmTool {
143                name: name.to_owned(),
144                module_size: wasm_bytes.len(),
145                module_hash,
146                schema: None,
147                exports: validation.exports,
148            })
149        }
150    }
151
152    /// Execute a loaded WASM tool synchronously.
153    ///
154    /// Creates an isolated store with fuel metering and memory limits,
155    /// compiles the tool's module bytes, and calls `_start` or `execute`.
156    /// No host filesystem access is provided -- the instance receives
157    /// an empty set of imports.
158    ///
159    /// For WASI-aware execution with stdio pipes, use [`execute_bytes`].
160    pub fn execute(
161        &self,
162        _tool: &WasmTool,
163        _input: serde_json::Value,
164    ) -> Result<WasmToolResult, WasmError> {
165        #[cfg(not(feature = "wasm-sandbox"))]
166        {
167            Err(WasmError::RuntimeUnavailable)
168        }
169
170        #[cfg(feature = "wasm-sandbox")]
171        {
172            Err(WasmError::RuntimeUnavailable)
173        }
174    }
175
176    /// Execute raw WASM bytes synchronously without WASI.
177    ///
178    /// This is the sync K3 execution path. It creates a fresh Wasmtime
179    /// store with fuel metering and memory limits, instantiates the
180    /// module with **no imports** (no filesystem, no network), and
181    /// calls `_start` or `run`.
182    ///
183    /// Returns [`WasmToolResult`] on success or a typed [`WasmError`]
184    /// on fuel exhaustion, memory overflow, or compilation failure.
185    #[cfg(feature = "wasm-sandbox")]
186    pub fn execute_sync(
187        &self,
188        name: &str,
189        wasm_bytes: &[u8],
190        _input: serde_json::Value,
191    ) -> Result<WasmToolResult, WasmError> {
192        let started = std::time::Instant::now();
193
194        // Build a sync-only engine (the shared engine has async_support
195        // enabled, which forbids synchronous Instance::new).
196        let mut sync_config = wasmtime::Config::new();
197        sync_config.consume_fuel(true);
198        let sync_engine = wasmtime::Engine::new(&sync_config)
199            .map_err(|e| WasmError::CompilationFailed(format!("sync engine: {e}")))?;
200
201        // Compile module (accepts binary .wasm or text .wat)
202        let module = wasmtime::Module::new(&sync_engine, wasm_bytes)
203            .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
204
205        // Create per-call store with embedded memory limiter
206        let limiter = MemoryLimiter {
207            max_bytes: self.config.max_memory_bytes,
208        };
209        let mut store = wasmtime::Store::new(&sync_engine, limiter);
210        store
211            .set_fuel(self.config.max_fuel)
212            .map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
213        store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);
214
215        // Instantiate with NO imports -- fully sandboxed, no host access
216        let instance = wasmtime::Instance::new(&mut store, &module, &[])
217            .map_err(|e| classify_trap_with_limiter(e, &self.config, &store))?;
218
219        // Find entry point: _start (WASI convention) or run
220        let entry = instance
221            .get_func(&mut store, "_start")
222            .or_else(|| instance.get_func(&mut store, "run"))
223            .ok_or_else(|| {
224                WasmError::WasmTrap(format!("{name}: no _start or run export"))
225            })?;
226
227        // Call the entry function
228        match entry.call(&mut store, &[], &mut []) {
229            Ok(_) => {
230                let fuel_remaining = store.get_fuel().unwrap_or(0);
231                let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
232                Ok(WasmToolResult {
233                    stdout: String::new(),
234                    stderr: String::new(),
235                    exit_code: 0,
236                    fuel_consumed,
237                    memory_peak: 0,
238                    execution_time: started.elapsed(),
239                })
240            }
241            Err(e) => Err(classify_trap_with_limiter(e, &self.config, &store)),
242        }
243    }
244
245    /// Compile and execute WASM bytes in one shot.
246    ///
247    /// This is the primary execution path for K3. It accepts raw WASM
248    /// bytes (binary or WAT text), compiles them with the engine, creates
249    /// an isolated WASI store with fuel metering, serializes `input` as
250    /// JSON to the module's stdin, calls `_start` (WASI preview1) or
251    /// `execute`, and reads stdout/stderr.
252    ///
253    /// For cached execution with pre-compiled modules, see K4.
254    #[cfg(feature = "wasm-sandbox")]
255    pub async fn execute_bytes(
256        &self,
257        name: &str,
258        wasm_bytes: &[u8],
259        input: serde_json::Value,
260    ) -> Result<WasmToolResult, WasmError> {
261        use wasmtime_wasi::pipe::{MemoryInputPipe, MemoryOutputPipe};
262
263        let started = std::time::Instant::now();
264
265        // Serialize input to JSON bytes for stdin
266        let input_bytes = serde_json::to_vec(&input)
267            .map_err(|e| WasmError::WasmTrap(format!("input serialization: {e}")))?;
268
269        // Create pipes for stdio
270        let stdout_pipe = MemoryOutputPipe::new(65_536);
271        let stderr_pipe = MemoryOutputPipe::new(65_536);
272        let stdin_pipe = MemoryInputPipe::new(input_bytes);
273
274        // Build WASI preview1 context with stdio pipes
275        let wasi_ctx = wasmtime_wasi::WasiCtxBuilder::new()
276            .stdin(stdin_pipe)
277            .stdout(stdout_pipe.clone())
278            .stderr(stderr_pipe.clone())
279            .build_p1();
280
281        // Create per-call store with fuel budget
282        let mut store = wasmtime::Store::new(&self.engine, wasi_ctx);
283        store
284            .set_fuel(self.config.max_fuel)
285            .map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
286
287        // Link WASI preview1 functions (wasi_snapshot_preview1.*)
288        let mut linker = wasmtime::Linker::<wasmtime_wasi::preview1::WasiP1Ctx>::new(&self.engine);
289        wasmtime_wasi::preview1::add_to_linker_async(&mut linker, |ctx| ctx)
290            .map_err(|e| WasmError::CompilationFailed(format!("WASI linker: {e}")))?;
291
292        // Compile the module (accepts both binary .wasm and text .wat)
293        let module = wasmtime::Module::new(&self.engine, wasm_bytes)
294            .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
295
296        // Instantiate
297        let instance = linker
298            .instantiate_async(&mut store, &module)
299            .await
300            .map_err(|e| {
301                let is_fuel = e
302                    .downcast_ref::<wasmtime::Trap>()
303                    .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
304                    || e.to_string().contains("fuel");
305                if is_fuel {
306                    WasmError::FuelExhausted {
307                        consumed: self.config.max_fuel,
308                        limit: self.config.max_fuel,
309                    }
310                } else {
311                    WasmError::CompilationFailed(format!("instantiate {name}: {e}"))
312                }
313            })?;
314
315        // Execute with wall-clock timeout
316        let timeout = Duration::from_secs(self.config.max_execution_time_secs);
317        let exec_result = tokio::time::timeout(timeout, async {
318            // Try _start (WASI convention), then execute
319            if let Some(start_fn) = instance.get_func(&mut store, "_start") {
320                start_fn.call_async(&mut store, &[], &mut []).await
321            } else if let Some(exec_fn) = instance.get_func(&mut store, "execute") {
322                exec_fn.call_async(&mut store, &[], &mut []).await
323            } else {
324                Err(wasmtime::Error::msg("no _start or execute export"))
325            }
326        })
327        .await;
328
329        // Read captured output
330        let stdout = String::from_utf8_lossy(&stdout_pipe.contents()).to_string();
331        let stderr = String::from_utf8_lossy(&stderr_pipe.contents()).to_string();
332
333        let fuel_remaining = store.get_fuel().unwrap_or(0);
334        let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
335
336        match exec_result {
337            Ok(Ok(_)) => Ok(WasmToolResult {
338                stdout,
339                stderr,
340                exit_code: 0,
341                fuel_consumed,
342                memory_peak: 0,
343                execution_time: started.elapsed(),
344            }),
345            Ok(Err(trap)) => {
346                let msg = trap.to_string();
347                // Check for fuel exhaustion via downcast or message
348                let is_fuel = trap
349                    .downcast_ref::<wasmtime::Trap>()
350                    .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
351                    || msg.contains("fuel");
352                if is_fuel {
353                    Err(WasmError::FuelExhausted {
354                        consumed: fuel_consumed,
355                        limit: self.config.max_fuel,
356                    })
357                } else if msg.contains("memory") {
358                    Err(WasmError::MemoryLimitExceeded {
359                        allocated: self.config.max_memory_bytes,
360                        limit: self.config.max_memory_bytes,
361                    })
362                } else {
363                    // Non-zero exit or trap -- return result with stderr
364                    Ok(WasmToolResult {
365                        stdout,
366                        stderr: if stderr.is_empty() {
367                            format!("trap: {msg}")
368                        } else {
369                            format!("{stderr}\ntrap: {msg}")
370                        },
371                        exit_code: 1,
372                        fuel_consumed,
373                        memory_peak: 0,
374                        execution_time: started.elapsed(),
375                    })
376                }
377            }
378            Err(_timeout) => Err(WasmError::ExecutionTimeout(timeout)),
379        }
380    }
381
382    /// Get a reference to the Wasmtime engine.
383    #[cfg(feature = "wasm-sandbox")]
384    pub fn engine(&self) -> &wasmtime::Engine {
385        &self.engine
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Wasmtime helpers (behind feature gate)
391// ---------------------------------------------------------------------------
392
393/// Resource limiter that caps linear memory growth.
394#[cfg(feature = "wasm-sandbox")]
395pub(crate) struct MemoryLimiter {
396    pub(crate) max_bytes: usize,
397}
398
399#[cfg(feature = "wasm-sandbox")]
400impl wasmtime::ResourceLimiter for MemoryLimiter {
401    fn memory_growing(
402        &mut self,
403        _current: usize,
404        desired: usize,
405        _maximum: Option<usize>,
406    ) -> Result<bool, wasmtime::Error> {
407        if desired > self.max_bytes {
408            // Deny the growth -- Wasmtime will trap
409            Ok(false)
410        } else {
411            Ok(true)
412        }
413    }
414
415    fn table_growing(
416        &mut self,
417        _current: usize,
418        _desired: usize,
419        _maximum: Option<usize>,
420    ) -> Result<bool, wasmtime::Error> {
421        Ok(true)
422    }
423}
424
425/// Classify a Wasmtime error into a typed [`WasmError`].
426///
427/// Inspects the error for fuel exhaustion or memory-related traps
428/// and returns the corresponding `WasmError` variant.
429#[cfg(feature = "wasm-sandbox")]
430fn classify_trap_impl(
431    err: wasmtime::Error,
432    config: &WasmSandboxConfig,
433    fuel_remaining: u64,
434) -> WasmError {
435    let msg = err.to_string();
436
437    // Check for fuel exhaustion
438    let is_fuel = err
439        .downcast_ref::<wasmtime::Trap>()
440        .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
441        || msg.contains("fuel");
442    if is_fuel {
443        return WasmError::FuelExhausted {
444            consumed: config.max_fuel.saturating_sub(fuel_remaining),
445            limit: config.max_fuel,
446        };
447    }
448
449    // Check for memory limit
450    if msg.contains("memory") {
451        return WasmError::MemoryLimitExceeded {
452            allocated: config.max_memory_bytes,
453            limit: config.max_memory_bytes,
454        };
455    }
456
457    WasmError::WasmTrap(msg)
458}
459
460/// Classify trap from a `Store<MemoryLimiter>` (used by execute_sync).
461#[cfg(feature = "wasm-sandbox")]
462fn classify_trap_with_limiter(
463    err: wasmtime::Error,
464    config: &WasmSandboxConfig,
465    store: &wasmtime::Store<MemoryLimiter>,
466) -> WasmError {
467    classify_trap_impl(err, config, store.get_fuel().unwrap_or(0))
468}
469
470// ---------------------------------------------------------------------------
471// Module hashing
472// ---------------------------------------------------------------------------
473
474/// Compute SHA-256 hash of WASM module bytes.
475pub fn compute_module_hash(bytes: &[u8]) -> [u8; 32] {
476    let mut hasher = Sha256::new();
477    hasher.update(bytes);
478    let result = hasher.finalize();
479    let mut hash = [0u8; 32];
480    hash.copy_from_slice(&result);
481    hash
482}
483
484// ---------------------------------------------------------------------------
485// Disk-persisted module cache
486// ---------------------------------------------------------------------------
487
488/// Compiled module cache with LRU eviction.
489///
490/// Stores compiled WASM modules on disk keyed by SHA-256 hash.
491/// When the cache exceeds `max_size`, the oldest entries are evicted.
492pub struct CompiledModuleCache {
493    cache_dir: PathBuf,
494    max_size: u64,
495}
496
497impl CompiledModuleCache {
498    /// Create a new module cache at the given directory.
499    pub fn new(cache_dir: PathBuf, max_size: u64) -> Self {
500        let _ = std::fs::create_dir_all(&cache_dir);
501        Self { cache_dir, max_size }
502    }
503
504    /// Get a cached compiled module by its hash.
505    pub fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
506        let path = self.cache_path(hash);
507        std::fs::read(&path).ok()
508    }
509
510    /// Store a compiled module in the cache.
511    pub fn put(&self, hash: &[u8; 32], bytes: &[u8]) {
512        let path = self.cache_path(hash);
513        let _ = std::fs::write(&path, bytes);
514        self.evict_lru();
515    }
516
517    /// Evict oldest entries until cache is under `max_size`.
518    fn evict_lru(&self) {
519        let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
520        if let Ok(dir) = std::fs::read_dir(&self.cache_dir) {
521            for entry in dir.flatten() {
522                if let Ok(meta) = entry.metadata() {
523                    let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
524                    entries.push((entry.path(), meta.len(), modified));
525                }
526            }
527        }
528        let total: u64 = entries.iter().map(|(_, s, _)| s).sum();
529        if total <= self.max_size {
530            return;
531        }
532        // Sort by modification time (oldest first)
533        entries.sort_by_key(|(_, _, t)| *t);
534        let mut remaining = total;
535        for (path, size, _) in &entries {
536            if remaining <= self.max_size {
537                break;
538            }
539            let _ = std::fs::remove_file(path);
540            remaining -= size;
541        }
542    }
543
544    fn cache_path(&self, hash: &[u8; 32]) -> PathBuf {
545        let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
546        self.cache_dir.join(format!("{hex}.wasm"))
547    }
548}