1use std::path::PathBuf;
4#[cfg(feature = "exochain")]
5use std::sync::Arc;
6#[cfg(feature = "wasm-sandbox")]
7use std::time::Duration;
8
9use sha2::{Digest, Sha256};
10
11use super::types::*;
12
13pub struct WasmToolRunner {
23 config: WasmSandboxConfig,
24 #[cfg(feature = "wasm-sandbox")]
25 engine: wasmtime::Engine,
26 #[cfg(feature = "exochain")]
27 chain_manager: Option<Arc<crate::chain::ChainManager>>,
28 #[cfg(feature = "exochain")]
29 governance_engine: Option<Arc<crate::governance::GovernanceEngine>>,
30}
31
32impl WasmToolRunner {
33 pub fn new(config: WasmSandboxConfig) -> Self {
35 #[cfg(feature = "wasm-sandbox")]
36 {
37 let mut wt_config = wasmtime::Config::new();
38 wt_config.consume_fuel(true);
39 let engine =
44 wasmtime::Engine::new(&wt_config).expect("failed to create wasmtime engine");
45 Self {
46 config,
47 engine,
48 #[cfg(feature = "exochain")]
49 chain_manager: None,
50 #[cfg(feature = "exochain")]
51 governance_engine: None,
52 }
53 }
54 #[cfg(not(feature = "wasm-sandbox"))]
55 {
56 Self {
57 config,
58 #[cfg(feature = "exochain")]
59 chain_manager: None,
60 #[cfg(feature = "exochain")]
61 governance_engine: None,
62 }
63 }
64 }
65
66 #[cfg(feature = "exochain")]
68 pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
69 self.chain_manager = Some(cm);
70 }
71
72 #[cfg(feature = "exochain")]
74 pub fn set_governance_engine(&mut self, ge: Arc<crate::governance::GovernanceEngine>) {
75 self.governance_engine = Some(ge);
76 }
77
78 pub fn config(&self) -> &WasmSandboxConfig {
80 &self.config
81 }
82
83 pub fn validate_wasm(&self, wasm_bytes: &[u8]) -> Result<WasmValidation, WasmError> {
88 if wasm_bytes.len() > self.config.max_module_size_bytes {
90 return Err(WasmError::ModuleTooLarge {
91 size: wasm_bytes.len(),
92 limit: self.config.max_module_size_bytes,
93 });
94 }
95
96 if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\0asm" {
98 return Err(WasmError::InvalidModule(
99 "missing WASM magic bytes (\\0asm)".into(),
100 ));
101 }
102
103 let mut warnings = Vec::new();
104
105 let version =
107 u32::from_le_bytes([wasm_bytes[4], wasm_bytes[5], wasm_bytes[6], wasm_bytes[7]]);
108 if version != 1 {
109 warnings.push(format!("unexpected WASM version: {version} (expected 1)"));
110 }
111
112 #[cfg(not(feature = "wasm-sandbox"))]
113 {
114 Ok(WasmValidation {
115 valid: true,
116 exports: Vec::new(),
117 imports: Vec::new(),
118 estimated_memory: 0,
119 warnings,
120 })
121 }
122
123 #[cfg(feature = "wasm-sandbox")]
124 {
125 if let Err(e) = wasmtime::Module::validate(&self.engine, wasm_bytes) {
127 return Err(WasmError::InvalidModule(e.to_string()));
128 }
129
130 match wasmtime::Module::new(&self.engine, wasm_bytes) {
132 Ok(module) => {
133 let exports: Vec<String> =
134 module.exports().map(|e| e.name().to_string()).collect();
135 let imports: Vec<String> = module
136 .imports()
137 .map(|i| format!("{}::{}", i.module(), i.name()))
138 .collect();
139 Ok(WasmValidation {
140 valid: true,
141 exports,
142 imports,
143 estimated_memory: 0,
144 warnings,
145 })
146 }
147 Err(e) => Err(WasmError::CompilationFailed(e.to_string())),
148 }
149 }
150 }
151
152 pub fn load_tool(&self, name: &str, wasm_bytes: &[u8]) -> Result<WasmTool, WasmError> {
157 let validation = self.validate_wasm(wasm_bytes)?;
158
159 if !validation.valid {
160 return Err(WasmError::InvalidModule(validation.warnings.join("; ")));
161 }
162
163 let module_hash = compute_module_hash(wasm_bytes);
164
165 #[cfg(not(feature = "wasm-sandbox"))]
166 {
167 let _ = name;
168 let _ = module_hash;
169 Err(WasmError::RuntimeUnavailable)
170 }
171
172 #[cfg(feature = "wasm-sandbox")]
173 {
174 Ok(WasmTool {
175 name: name.to_owned(),
176 module_size: wasm_bytes.len(),
177 module_hash,
178 schema: None,
179 exports: validation.exports,
180 })
181 }
182 }
183
184 pub fn execute(
193 &self,
194 _tool: &WasmTool,
195 _input: serde_json::Value,
196 ) -> Result<WasmToolResult, WasmError> {
197 #[cfg(feature = "exochain")]
199 if let Some(ref ge) = self.governance_engine {
200 let request = crate::governance::GovernanceRequest::new("wasm", "wasm.execute")
201 .with_effect(crate::governance::EffectVector {
202 risk: 0.4,
203 security: 0.5,
204 ..Default::default()
205 });
206 let result = ge.evaluate(&request);
207 if matches!(
208 result.decision,
209 crate::governance::GovernanceDecision::Deny(_)
210 ) {
211 return Err(WasmError::GovernanceDenied(result.decision.to_string()));
212 }
213 }
214
215 #[cfg(feature = "exochain")]
217 if let Some(ref cm) = self.chain_manager {
218 cm.append(
219 "wasm",
220 crate::chain::EVENT_KIND_WASM_EXECUTE,
221 Some(serde_json::json!({
222 "tool": &_tool.name,
223 "module_size": _tool.module_size,
224 })),
225 );
226 }
227
228 #[cfg(not(feature = "wasm-sandbox"))]
229 {
230 Err(WasmError::RuntimeUnavailable)
231 }
232
233 #[cfg(feature = "wasm-sandbox")]
234 {
235 Err(WasmError::RuntimeUnavailable)
236 }
237 }
238
239 #[cfg(feature = "wasm-sandbox")]
249 pub fn execute_sync(
250 &self,
251 name: &str,
252 wasm_bytes: &[u8],
253 _input: serde_json::Value,
254 ) -> Result<WasmToolResult, WasmError> {
255 let started = std::time::Instant::now();
256
257 let mut sync_config = wasmtime::Config::new();
260 sync_config.consume_fuel(true);
261 let sync_engine = wasmtime::Engine::new(&sync_config)
262 .map_err(|e| WasmError::CompilationFailed(format!("sync engine: {e}")))?;
263
264 let module = wasmtime::Module::new(&sync_engine, wasm_bytes)
266 .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
267
268 let limiter = MemoryLimiter {
270 max_bytes: self.config.max_memory_bytes,
271 };
272 let mut store = wasmtime::Store::new(&sync_engine, limiter);
273 store
274 .set_fuel(self.config.max_fuel)
275 .map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
276 store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);
277
278 let instance = wasmtime::Instance::new(&mut store, &module, &[])
280 .map_err(|e| classify_trap_with_limiter(e, &self.config, &store))?;
281
282 let entry = instance
284 .get_func(&mut store, "_start")
285 .or_else(|| instance.get_func(&mut store, "run"))
286 .ok_or_else(|| WasmError::WasmTrap(format!("{name}: no _start or run export")))?;
287
288 match entry.call(&mut store, &[], &mut []) {
290 Ok(_) => {
291 let fuel_remaining = store.get_fuel().unwrap_or(0);
292 let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
293 Ok(WasmToolResult {
294 stdout: String::new(),
295 stderr: String::new(),
296 exit_code: 0,
297 fuel_consumed,
298 memory_peak: 0,
299 execution_time: started.elapsed(),
300 })
301 }
302 Err(e) => Err(classify_trap_with_limiter(e, &self.config, &store)),
303 }
304 }
305
306 #[cfg(feature = "wasm-sandbox")]
316 pub async fn execute_bytes(
317 &self,
318 name: &str,
319 wasm_bytes: &[u8],
320 input: serde_json::Value,
321 ) -> Result<WasmToolResult, WasmError> {
322 use wasmtime_wasi::p1::{self, WasiP1Ctx};
326 use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
327 use wasmtime_wasi::WasiCtxBuilder;
328
329 let started = std::time::Instant::now();
330
331 let input_bytes = serde_json::to_vec(&input)
333 .map_err(|e| WasmError::WasmTrap(format!("input serialization: {e}")))?;
334
335 let stdout_pipe = MemoryOutputPipe::new(65_536);
337 let stderr_pipe = MemoryOutputPipe::new(65_536);
338 let stdin_pipe = MemoryInputPipe::new(input_bytes);
339
340 let wasi_ctx = WasiCtxBuilder::new()
342 .stdin(stdin_pipe)
343 .stdout(stdout_pipe.clone())
344 .stderr(stderr_pipe.clone())
345 .build_p1();
346
347 let mut store = wasmtime::Store::new(&self.engine, wasi_ctx);
349 store
350 .set_fuel(self.config.max_fuel)
351 .map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
352
353 let mut linker = wasmtime::Linker::<WasiP1Ctx>::new(&self.engine);
355 p1::add_to_linker_async(&mut linker, |ctx| ctx)
356 .map_err(|e| WasmError::CompilationFailed(format!("WASI linker: {e}")))?;
357
358 let module = wasmtime::Module::new(&self.engine, wasm_bytes)
360 .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
361
362 let instance = linker
364 .instantiate_async(&mut store, &module)
365 .await
366 .map_err(|e| {
367 let is_fuel = e
368 .downcast_ref::<wasmtime::Trap>()
369 .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
370 || e.to_string().contains("fuel");
371 if is_fuel {
372 WasmError::FuelExhausted {
373 consumed: self.config.max_fuel,
374 limit: self.config.max_fuel,
375 }
376 } else {
377 WasmError::CompilationFailed(format!("instantiate {name}: {e}"))
378 }
379 })?;
380
381 let timeout = Duration::from_secs(self.config.max_execution_time_secs);
383 let exec_result = tokio::time::timeout(timeout, async {
384 if let Some(start_fn) = instance.get_func(&mut store, "_start") {
386 start_fn.call_async(&mut store, &[], &mut []).await
387 } else if let Some(exec_fn) = instance.get_func(&mut store, "execute") {
388 exec_fn.call_async(&mut store, &[], &mut []).await
389 } else {
390 Err(wasmtime::Error::msg("no _start or execute export"))
391 }
392 })
393 .await;
394
395 let stdout = String::from_utf8_lossy(&stdout_pipe.contents()).to_string();
397 let stderr = String::from_utf8_lossy(&stderr_pipe.contents()).to_string();
398
399 let fuel_remaining = store.get_fuel().unwrap_or(0);
400 let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
401
402 match exec_result {
403 Ok(Ok(_)) => Ok(WasmToolResult {
404 stdout,
405 stderr,
406 exit_code: 0,
407 fuel_consumed,
408 memory_peak: 0,
409 execution_time: started.elapsed(),
410 }),
411 Ok(Err(trap)) => {
412 let msg = trap.to_string();
413 let is_fuel = trap
415 .downcast_ref::<wasmtime::Trap>()
416 .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
417 || msg.contains("fuel");
418 if is_fuel {
419 Err(WasmError::FuelExhausted {
420 consumed: fuel_consumed,
421 limit: self.config.max_fuel,
422 })
423 } else if msg.contains("memory") {
424 Err(WasmError::MemoryLimitExceeded {
425 allocated: self.config.max_memory_bytes,
426 limit: self.config.max_memory_bytes,
427 })
428 } else {
429 Ok(WasmToolResult {
431 stdout,
432 stderr: if stderr.is_empty() {
433 format!("trap: {msg}")
434 } else {
435 format!("{stderr}\ntrap: {msg}")
436 },
437 exit_code: 1,
438 fuel_consumed,
439 memory_peak: 0,
440 execution_time: started.elapsed(),
441 })
442 }
443 }
444 Err(_timeout) => Err(WasmError::ExecutionTimeout(timeout)),
445 }
446 }
447
448 #[cfg(feature = "wasm-sandbox")]
450 pub fn engine(&self) -> &wasmtime::Engine {
451 &self.engine
452 }
453}
454
455#[cfg(feature = "wasm-sandbox")]
461pub(crate) struct MemoryLimiter {
462 pub(crate) max_bytes: usize,
463}
464
465#[cfg(feature = "wasm-sandbox")]
466impl wasmtime::ResourceLimiter for MemoryLimiter {
467 fn memory_growing(
468 &mut self,
469 _current: usize,
470 desired: usize,
471 _maximum: Option<usize>,
472 ) -> Result<bool, wasmtime::Error> {
473 if desired > self.max_bytes {
474 Ok(false)
476 } else {
477 Ok(true)
478 }
479 }
480
481 fn table_growing(
482 &mut self,
483 _current: usize,
484 _desired: usize,
485 _maximum: Option<usize>,
486 ) -> Result<bool, wasmtime::Error> {
487 Ok(true)
488 }
489}
490
491#[cfg(feature = "wasm-sandbox")]
496fn classify_trap_impl(
497 err: wasmtime::Error,
498 config: &WasmSandboxConfig,
499 fuel_remaining: u64,
500) -> WasmError {
501 let msg = err.to_string();
502
503 let is_fuel = err
505 .downcast_ref::<wasmtime::Trap>()
506 .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
507 || msg.contains("fuel");
508 if is_fuel {
509 return WasmError::FuelExhausted {
510 consumed: config.max_fuel.saturating_sub(fuel_remaining),
511 limit: config.max_fuel,
512 };
513 }
514
515 if msg.contains("memory") {
517 return WasmError::MemoryLimitExceeded {
518 allocated: config.max_memory_bytes,
519 limit: config.max_memory_bytes,
520 };
521 }
522
523 WasmError::WasmTrap(msg)
524}
525
526#[cfg(feature = "wasm-sandbox")]
528fn classify_trap_with_limiter(
529 err: wasmtime::Error,
530 config: &WasmSandboxConfig,
531 store: &wasmtime::Store<MemoryLimiter>,
532) -> WasmError {
533 classify_trap_impl(err, config, store.get_fuel().unwrap_or(0))
534}
535
536pub fn compute_module_hash(bytes: &[u8]) -> [u8; 32] {
542 let mut hasher = Sha256::new();
543 hasher.update(bytes);
544 let result = hasher.finalize();
545 let mut hash = [0u8; 32];
546 hash.copy_from_slice(&result);
547 hash
548}
549
550pub struct CompiledModuleCache {
559 cache_dir: PathBuf,
560 max_size: u64,
561}
562
563impl CompiledModuleCache {
564 pub fn new(cache_dir: PathBuf, max_size: u64) -> Self {
566 let _ = std::fs::create_dir_all(&cache_dir);
567 Self {
568 cache_dir,
569 max_size,
570 }
571 }
572
573 pub fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
575 let path = self.cache_path(hash);
576 std::fs::read(&path).ok()
577 }
578
579 pub fn put(&self, hash: &[u8; 32], bytes: &[u8]) {
581 let path = self.cache_path(hash);
582 let _ = std::fs::write(&path, bytes);
583 self.evict_lru();
584 }
585
586 fn evict_lru(&self) {
588 let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
589 if let Ok(dir) = std::fs::read_dir(&self.cache_dir) {
590 for entry in dir.flatten() {
591 if let Ok(meta) = entry.metadata() {
592 let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
593 entries.push((entry.path(), meta.len(), modified));
594 }
595 }
596 }
597 let total: u64 = entries.iter().map(|(_, s, _)| s).sum();
598 if total <= self.max_size {
599 return;
600 }
601 entries.sort_by_key(|(_, _, t)| *t);
603 let mut remaining = total;
604 for (path, size, _) in &entries {
605 if remaining <= self.max_size {
606 break;
607 }
608 let _ = std::fs::remove_file(path);
609 remaining -= size;
610 }
611 }
612
613 fn cache_path(&self, hash: &[u8; 32]) -> PathBuf {
614 let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
615 self.cache_dir.join(format!("{hex}.wasm"))
616 }
617}