clawft_kernel/wasm_runner/
runner.rs1use std::path::PathBuf;
4use std::time::Duration;
5
6use sha2::{Digest, Sha256};
7
8use super::types::*;
9
10pub struct WasmToolRunner {
20 config: WasmSandboxConfig,
21 #[cfg(feature = "wasm-sandbox")]
22 engine: wasmtime::Engine,
23}
24
25impl WasmToolRunner {
26 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 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 pub fn config(&self) -> &WasmSandboxConfig {
46 &self.config
47 }
48
49 pub fn validate_wasm(&self, wasm_bytes: &[u8]) -> Result<WasmValidation, WasmError> {
54 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 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 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 if let Err(e) = wasmtime::Module::validate(&self.engine, wasm_bytes) {
93 return Err(WasmError::InvalidModule(e.to_string()));
94 }
95
96 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 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 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 #[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 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 let module = wasmtime::Module::new(&sync_engine, wasm_bytes)
203 .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
204
205 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 let instance = wasmtime::Instance::new(&mut store, &module, &[])
217 .map_err(|e| classify_trap_with_limiter(e, &self.config, &store))?;
218
219 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 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 #[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 let input_bytes = serde_json::to_vec(&input)
267 .map_err(|e| WasmError::WasmTrap(format!("input serialization: {e}")))?;
268
269 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 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 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 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 let module = wasmtime::Module::new(&self.engine, wasm_bytes)
294 .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
295
296 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 let timeout = Duration::from_secs(self.config.max_execution_time_secs);
317 let exec_result = tokio::time::timeout(timeout, async {
318 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 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 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 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 #[cfg(feature = "wasm-sandbox")]
384 pub fn engine(&self) -> &wasmtime::Engine {
385 &self.engine
386 }
387}
388
389#[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 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#[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 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 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#[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
470pub 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
484pub struct CompiledModuleCache {
493 cache_dir: PathBuf,
494 max_size: u64,
495}
496
497impl CompiledModuleCache {
498 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 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 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 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 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}