use crate::backend::{Backend, SandboxBackend};
use crate::lifecycle::{ExecResult, SandboxConfig};
use crate::policy::SandboxPolicy;
use wasmtime::{Config, Engine, Linker, Module, Store};
use wasmtime_wasi::WasiCtxBuilder;
use wasmtime_wasi::p1::{self, WasiP1Ctx};
pub struct WasmBackend {
config: SandboxConfig,
engine: Engine,
}
impl WasmBackend {
pub fn new(config: &SandboxConfig) -> crate::Result<Self> {
let mut wasm_config = Config::new();
wasm_config.consume_fuel(true);
if let Some(mb) = config.policy.memory_limit_mb {
let pages = mb * 1024 * 1024 / 65536; wasm_config.memory_guaranteed_dense_image_size(pages.min(65536));
}
let engine = Engine::new(&wasm_config)
.map_err(|e| crate::KavachError::CreationFailed(format!("wasmtime engine: {e}")))?;
Ok(Self {
config: config.clone(),
engine,
})
}
fn build_wasi_ctx(&self) -> crate::Result<WasiP1Ctx> {
let mut builder = WasiCtxBuilder::new();
builder.inherit_stdio();
for rule in &self.config.policy.landlock_rules {
let path = std::path::Path::new(&rule.path);
if path.exists() {
let _ = match rule.access.as_str() {
"rw" => builder.preopened_dir(
path,
&rule.path,
wasmtime_wasi::DirPerms::all(),
wasmtime_wasi::FilePerms::all(),
),
_ => builder.preopened_dir(
path,
&rule.path,
wasmtime_wasi::DirPerms::READ,
wasmtime_wasi::FilePerms::READ,
),
};
}
}
for (k, v) in &self.config.env {
builder.env(k, v);
}
Ok(builder.build_p1())
}
fn fuel_from_timeout(timeout_ms: u64) -> u64 {
timeout_ms.saturating_mul(1_000_000)
}
}
#[async_trait::async_trait]
impl SandboxBackend for WasmBackend {
fn backend_type(&self) -> Backend {
Backend::Wasm
}
async fn exec(&self, command: &str, policy: &SandboxPolicy) -> crate::Result<ExecResult> {
let start = std::time::Instant::now();
let _ = policy;
let wasm_path = std::path::Path::new(command.trim());
let module = if wasm_path.exists() {
Module::from_file(&self.engine, wasm_path)
.map_err(|e| crate::KavachError::ExecFailed(format!("WASM load: {e}")))?
} else {
return Err(crate::KavachError::ExecFailed(
"WASM backend requires a .wasm file path as the command".into(),
));
};
let wasi_ctx = self.build_wasi_ctx()?;
let mut store = Store::new(&self.engine, wasi_ctx);
let fuel = Self::fuel_from_timeout(self.config.timeout_ms);
store
.set_fuel(fuel)
.map_err(|e| crate::KavachError::ExecFailed(format!("set fuel: {e}")))?;
let mut linker: Linker<WasiP1Ctx> = Linker::new(&self.engine);
p1::add_to_linker_async(&mut linker, |ctx: &mut WasiP1Ctx| ctx)
.map_err(|e| crate::KavachError::ExecFailed(format!("WASI link: {e}")))?;
let instance = linker
.instantiate_async(&mut store, &module)
.await
.map_err(|e| crate::KavachError::ExecFailed(format!("WASM instantiate: {e}")))?;
let start_fn = instance
.get_typed_func::<(), ()>(&mut store, "_start")
.map_err(|_| {
crate::KavachError::ExecFailed("WASM module has no _start function".into())
})?;
let timeout_dur = std::time::Duration::from_millis(self.config.timeout_ms);
let call_result: Result<(), wasmtime::Error> =
match tokio::time::timeout(timeout_dur, start_fn.call_async(&mut store, ())).await {
Ok(r) => r,
Err(_) => {
return Ok(ExecResult {
exit_code: -1,
stdout: String::new(),
stderr: String::new(),
duration_ms: start.elapsed().as_millis() as u64,
timed_out: true,
});
}
};
let exit_code = match call_result {
Ok(()) => 0,
Err(e) => {
let msg = e.to_string();
if msg.contains("fuel") {
return Ok(ExecResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("WASM fuel exhausted: {msg}"),
duration_ms: start.elapsed().as_millis() as u64,
timed_out: true,
});
}
if let Some(exit) = e.downcast_ref::<wasmtime_wasi::I32Exit>() {
exit.0
} else {
return Err(crate::KavachError::ExecFailed(format!("WASM exec: {e}")));
}
}
};
let duration_ms = start.elapsed().as_millis() as u64;
Ok(ExecResult {
exit_code,
stdout: String::new(), stderr: String::new(),
duration_ms,
timed_out: false,
})
}
async fn health_check(&self) -> crate::Result<bool> {
let wat = r#"(module (func (export "_start")))"#;
Module::new(&self.engine, wat)
.map(|_| true)
.map_err(|e| crate::KavachError::ExecFailed(format!("WASM health: {e}")))
}
async fn destroy(&self) -> crate::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuel_calculation() {
assert_eq!(WasmBackend::fuel_from_timeout(1000), 1_000_000_000);
assert_eq!(WasmBackend::fuel_from_timeout(100), 100_000_000);
assert_eq!(WasmBackend::fuel_from_timeout(0), 0);
}
#[test]
fn create_backend() {
let config = SandboxConfig::builder().backend(Backend::Wasm).build();
let backend = WasmBackend::new(&config);
assert!(backend.is_ok());
}
#[tokio::test]
async fn health_check() {
let config = SandboxConfig::builder().backend(Backend::Wasm).build();
let backend = WasmBackend::new(&config).unwrap();
assert!(backend.health_check().await.unwrap());
}
#[tokio::test]
async fn exec_nonexistent_file() {
let config = SandboxConfig::builder().backend(Backend::Wasm).build();
let backend = WasmBackend::new(&config).unwrap();
let policy = SandboxPolicy::minimal();
let result = backend.exec("/nonexistent.wasm", &policy).await;
assert!(result.is_err());
}
#[tokio::test]
async fn exec_non_wasm_command() {
let config = SandboxConfig::builder().backend(Backend::Wasm).build();
let backend = WasmBackend::new(&config).unwrap();
let policy = SandboxPolicy::minimal();
let result = backend.exec("echo hello", &policy).await;
assert!(result.is_err()); }
}