use std::path::PathBuf;
#[cfg(feature = "exochain")]
use std::sync::Arc;
#[cfg(feature = "wasm-sandbox")]
use std::time::Duration;
use sha2::{Digest, Sha256};
use super::types::*;
pub struct WasmToolRunner {
config: WasmSandboxConfig,
#[cfg(feature = "wasm-sandbox")]
engine: wasmtime::Engine,
#[cfg(feature = "exochain")]
chain_manager: Option<Arc<crate::chain::ChainManager>>,
#[cfg(feature = "exochain")]
governance_engine: Option<Arc<crate::governance::GovernanceEngine>>,
}
impl WasmToolRunner {
pub fn new(config: WasmSandboxConfig) -> Self {
#[cfg(feature = "wasm-sandbox")]
{
let mut wt_config = wasmtime::Config::new();
wt_config.consume_fuel(true);
wt_config.async_support(true);
let engine = wasmtime::Engine::new(&wt_config)
.expect("failed to create wasmtime engine");
Self {
config,
engine,
#[cfg(feature = "exochain")]
chain_manager: None,
#[cfg(feature = "exochain")]
governance_engine: None,
}
}
#[cfg(not(feature = "wasm-sandbox"))]
{
Self {
config,
#[cfg(feature = "exochain")]
chain_manager: None,
#[cfg(feature = "exochain")]
governance_engine: None,
}
}
}
#[cfg(feature = "exochain")]
pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
self.chain_manager = Some(cm);
}
#[cfg(feature = "exochain")]
pub fn set_governance_engine(&mut self, ge: Arc<crate::governance::GovernanceEngine>) {
self.governance_engine = Some(ge);
}
pub fn config(&self) -> &WasmSandboxConfig {
&self.config
}
pub fn validate_wasm(&self, wasm_bytes: &[u8]) -> Result<WasmValidation, WasmError> {
if wasm_bytes.len() > self.config.max_module_size_bytes {
return Err(WasmError::ModuleTooLarge {
size: wasm_bytes.len(),
limit: self.config.max_module_size_bytes,
});
}
if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\0asm" {
return Err(WasmError::InvalidModule(
"missing WASM magic bytes (\\0asm)".into(),
));
}
let mut warnings = Vec::new();
let version =
u32::from_le_bytes([wasm_bytes[4], wasm_bytes[5], wasm_bytes[6], wasm_bytes[7]]);
if version != 1 {
warnings.push(format!("unexpected WASM version: {version} (expected 1)"));
}
#[cfg(not(feature = "wasm-sandbox"))]
{
Ok(WasmValidation {
valid: true,
exports: Vec::new(),
imports: Vec::new(),
estimated_memory: 0,
warnings,
})
}
#[cfg(feature = "wasm-sandbox")]
{
if let Err(e) = wasmtime::Module::validate(&self.engine, wasm_bytes) {
return Err(WasmError::InvalidModule(e.to_string()));
}
match wasmtime::Module::new(&self.engine, wasm_bytes) {
Ok(module) => {
let exports: Vec<String> = module
.exports()
.map(|e| e.name().to_string())
.collect();
let imports: Vec<String> = module
.imports()
.map(|i| format!("{}::{}", i.module(), i.name()))
.collect();
Ok(WasmValidation {
valid: true,
exports,
imports,
estimated_memory: 0,
warnings,
})
}
Err(e) => Err(WasmError::CompilationFailed(e.to_string())),
}
}
}
pub fn load_tool(&self, name: &str, wasm_bytes: &[u8]) -> Result<WasmTool, WasmError> {
let validation = self.validate_wasm(wasm_bytes)?;
if !validation.valid {
return Err(WasmError::InvalidModule(validation.warnings.join("; ")));
}
let module_hash = compute_module_hash(wasm_bytes);
#[cfg(not(feature = "wasm-sandbox"))]
{
let _ = name;
let _ = module_hash;
Err(WasmError::RuntimeUnavailable)
}
#[cfg(feature = "wasm-sandbox")]
{
Ok(WasmTool {
name: name.to_owned(),
module_size: wasm_bytes.len(),
module_hash,
schema: None,
exports: validation.exports,
})
}
}
pub fn execute(
&self,
_tool: &WasmTool,
_input: serde_json::Value,
) -> Result<WasmToolResult, WasmError> {
#[cfg(feature = "exochain")]
if let Some(ref ge) = self.governance_engine {
let request = crate::governance::GovernanceRequest::new("wasm", "wasm.execute")
.with_effect(crate::governance::EffectVector {
risk: 0.4,
security: 0.5,
..Default::default()
});
let result = ge.evaluate(&request);
if matches!(
result.decision,
crate::governance::GovernanceDecision::Deny(_)
) {
return Err(WasmError::GovernanceDenied(result.decision.to_string()));
}
}
#[cfg(feature = "exochain")]
if let Some(ref cm) = self.chain_manager {
cm.append(
"wasm",
crate::chain::EVENT_KIND_WASM_EXECUTE,
Some(serde_json::json!({
"tool": &_tool.name,
"module_size": _tool.module_size,
})),
);
}
#[cfg(not(feature = "wasm-sandbox"))]
{
Err(WasmError::RuntimeUnavailable)
}
#[cfg(feature = "wasm-sandbox")]
{
Err(WasmError::RuntimeUnavailable)
}
}
#[cfg(feature = "wasm-sandbox")]
pub fn execute_sync(
&self,
name: &str,
wasm_bytes: &[u8],
_input: serde_json::Value,
) -> Result<WasmToolResult, WasmError> {
let started = std::time::Instant::now();
let mut sync_config = wasmtime::Config::new();
sync_config.consume_fuel(true);
let sync_engine = wasmtime::Engine::new(&sync_config)
.map_err(|e| WasmError::CompilationFailed(format!("sync engine: {e}")))?;
let module = wasmtime::Module::new(&sync_engine, wasm_bytes)
.map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
let limiter = MemoryLimiter {
max_bytes: self.config.max_memory_bytes,
};
let mut store = wasmtime::Store::new(&sync_engine, limiter);
store
.set_fuel(self.config.max_fuel)
.map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);
let instance = wasmtime::Instance::new(&mut store, &module, &[])
.map_err(|e| classify_trap_with_limiter(e, &self.config, &store))?;
let entry = instance
.get_func(&mut store, "_start")
.or_else(|| instance.get_func(&mut store, "run"))
.ok_or_else(|| {
WasmError::WasmTrap(format!("{name}: no _start or run export"))
})?;
match entry.call(&mut store, &[], &mut []) {
Ok(_) => {
let fuel_remaining = store.get_fuel().unwrap_or(0);
let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
Ok(WasmToolResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
fuel_consumed,
memory_peak: 0,
execution_time: started.elapsed(),
})
}
Err(e) => Err(classify_trap_with_limiter(e, &self.config, &store)),
}
}
#[cfg(feature = "wasm-sandbox")]
pub async fn execute_bytes(
&self,
name: &str,
wasm_bytes: &[u8],
input: serde_json::Value,
) -> Result<WasmToolResult, WasmError> {
use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
let started = std::time::Instant::now();
let input_bytes = serde_json::to_vec(&input)
.map_err(|e| WasmError::WasmTrap(format!("input serialization: {e}")))?;
let stdout_pipe = MemoryOutputPipe::new(65_536);
let stderr_pipe = MemoryOutputPipe::new(65_536);
let stdin_pipe = MemoryInputPipe::new(input_bytes);
let wasi_ctx = wasmtime_wasi::p2::WasiCtxBuilder::new()
.stdin(stdin_pipe)
.stdout(stdout_pipe.clone())
.stderr(stderr_pipe.clone())
.build_p1();
let mut store = wasmtime::Store::new(&self.engine, wasi_ctx);
store
.set_fuel(self.config.max_fuel)
.map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
let mut linker = wasmtime::Linker::<wasmtime_wasi::preview1::WasiP1Ctx>::new(&self.engine);
wasmtime_wasi::preview1::add_to_linker_async(&mut linker, |ctx| ctx)
.map_err(|e| WasmError::CompilationFailed(format!("WASI linker: {e}")))?;
let module = wasmtime::Module::new(&self.engine, wasm_bytes)
.map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
let instance = linker
.instantiate_async(&mut store, &module)
.await
.map_err(|e| {
let is_fuel = e
.downcast_ref::<wasmtime::Trap>()
.is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
|| e.to_string().contains("fuel");
if is_fuel {
WasmError::FuelExhausted {
consumed: self.config.max_fuel,
limit: self.config.max_fuel,
}
} else {
WasmError::CompilationFailed(format!("instantiate {name}: {e}"))
}
})?;
let timeout = Duration::from_secs(self.config.max_execution_time_secs);
let exec_result = tokio::time::timeout(timeout, async {
if let Some(start_fn) = instance.get_func(&mut store, "_start") {
start_fn.call_async(&mut store, &[], &mut []).await
} else if let Some(exec_fn) = instance.get_func(&mut store, "execute") {
exec_fn.call_async(&mut store, &[], &mut []).await
} else {
Err(wasmtime::Error::msg("no _start or execute export"))
}
})
.await;
let stdout = String::from_utf8_lossy(&stdout_pipe.contents()).to_string();
let stderr = String::from_utf8_lossy(&stderr_pipe.contents()).to_string();
let fuel_remaining = store.get_fuel().unwrap_or(0);
let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
match exec_result {
Ok(Ok(_)) => Ok(WasmToolResult {
stdout,
stderr,
exit_code: 0,
fuel_consumed,
memory_peak: 0,
execution_time: started.elapsed(),
}),
Ok(Err(trap)) => {
let msg = trap.to_string();
let is_fuel = trap
.downcast_ref::<wasmtime::Trap>()
.is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
|| msg.contains("fuel");
if is_fuel {
Err(WasmError::FuelExhausted {
consumed: fuel_consumed,
limit: self.config.max_fuel,
})
} else if msg.contains("memory") {
Err(WasmError::MemoryLimitExceeded {
allocated: self.config.max_memory_bytes,
limit: self.config.max_memory_bytes,
})
} else {
Ok(WasmToolResult {
stdout,
stderr: if stderr.is_empty() {
format!("trap: {msg}")
} else {
format!("{stderr}\ntrap: {msg}")
},
exit_code: 1,
fuel_consumed,
memory_peak: 0,
execution_time: started.elapsed(),
})
}
}
Err(_timeout) => Err(WasmError::ExecutionTimeout(timeout)),
}
}
#[cfg(feature = "wasm-sandbox")]
pub fn engine(&self) -> &wasmtime::Engine {
&self.engine
}
}
#[cfg(feature = "wasm-sandbox")]
pub(crate) struct MemoryLimiter {
pub(crate) max_bytes: usize,
}
#[cfg(feature = "wasm-sandbox")]
impl wasmtime::ResourceLimiter for MemoryLimiter {
fn memory_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> Result<bool, wasmtime::Error> {
if desired > self.max_bytes {
Ok(false)
} else {
Ok(true)
}
}
fn table_growing(
&mut self,
_current: usize,
_desired: usize,
_maximum: Option<usize>,
) -> Result<bool, wasmtime::Error> {
Ok(true)
}
}
#[cfg(feature = "wasm-sandbox")]
fn classify_trap_impl(
err: wasmtime::Error,
config: &WasmSandboxConfig,
fuel_remaining: u64,
) -> WasmError {
let msg = err.to_string();
let is_fuel = err
.downcast_ref::<wasmtime::Trap>()
.is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
|| msg.contains("fuel");
if is_fuel {
return WasmError::FuelExhausted {
consumed: config.max_fuel.saturating_sub(fuel_remaining),
limit: config.max_fuel,
};
}
if msg.contains("memory") {
return WasmError::MemoryLimitExceeded {
allocated: config.max_memory_bytes,
limit: config.max_memory_bytes,
};
}
WasmError::WasmTrap(msg)
}
#[cfg(feature = "wasm-sandbox")]
fn classify_trap_with_limiter(
err: wasmtime::Error,
config: &WasmSandboxConfig,
store: &wasmtime::Store<MemoryLimiter>,
) -> WasmError {
classify_trap_impl(err, config, store.get_fuel().unwrap_or(0))
}
pub fn compute_module_hash(bytes: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(bytes);
let result = hasher.finalize();
let mut hash = [0u8; 32];
hash.copy_from_slice(&result);
hash
}
pub struct CompiledModuleCache {
cache_dir: PathBuf,
max_size: u64,
}
impl CompiledModuleCache {
pub fn new(cache_dir: PathBuf, max_size: u64) -> Self {
let _ = std::fs::create_dir_all(&cache_dir);
Self { cache_dir, max_size }
}
pub fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
let path = self.cache_path(hash);
std::fs::read(&path).ok()
}
pub fn put(&self, hash: &[u8; 32], bytes: &[u8]) {
let path = self.cache_path(hash);
let _ = std::fs::write(&path, bytes);
self.evict_lru();
}
fn evict_lru(&self) {
let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
if let Ok(dir) = std::fs::read_dir(&self.cache_dir) {
for entry in dir.flatten() {
if let Ok(meta) = entry.metadata() {
let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
entries.push((entry.path(), meta.len(), modified));
}
}
}
let total: u64 = entries.iter().map(|(_, s, _)| s).sum();
if total <= self.max_size {
return;
}
entries.sort_by_key(|(_, _, t)| *t);
let mut remaining = total;
for (path, size, _) in &entries {
if remaining <= self.max_size {
break;
}
let _ = std::fs::remove_file(path);
remaining -= size;
}
}
fn cache_path(&self, hash: &[u8; 32]) -> PathBuf {
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
self.cache_dir.join(format!("{hex}.wasm"))
}
}