use roboticus_core::{Result, RoboticusError, input_capability_scan};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmPluginConfig {
pub name: String,
pub wasm_path: PathBuf,
#[serde(default = "default_memory_limit")]
pub memory_limit_bytes: u64,
#[serde(default = "default_execution_timeout_ms")]
pub execution_timeout_ms: u64,
#[serde(default)]
pub capabilities: Vec<WasmCapability>,
}
fn default_memory_limit() -> u64 {
64 * 1024 * 1024
}
fn default_execution_timeout_ms() -> u64 {
30_000
}
const MAX_CONCURRENT_WASM_EXECUTIONS: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WasmCapability {
ReadFilesystem,
WriteFilesystem,
Network,
Environment,
}
pub struct WasmPlugin {
pub config: WasmPluginConfig,
pub loaded: bool,
pub invocation_count: u64,
pub last_error: Option<String>,
engine: Option<wasmer::Engine>,
module: Option<wasmer::Module>,
active_executions: Arc<AtomicUsize>,
}
impl std::fmt::Debug for WasmPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmPlugin")
.field("config", &self.config)
.field("loaded", &self.loaded)
.field("invocation_count", &self.invocation_count)
.field("last_error", &self.last_error)
.field("has_engine", &self.engine.is_some())
.field("has_module", &self.module.is_some())
.field(
"active_executions",
&self.active_executions.load(Ordering::Relaxed),
)
.finish()
}
}
impl WasmPlugin {
pub fn new(config: WasmPluginConfig) -> Self {
Self {
config,
loaded: false,
invocation_count: 0,
last_error: None,
engine: None,
module: None,
active_executions: Arc::new(AtomicUsize::new(0)),
}
}
pub fn load(&mut self) -> Result<()> {
if !self.config.wasm_path.exists() {
return Err(RoboticusError::Config(format!(
"WASM file not found: {}",
self.config.wasm_path.display()
)));
}
let wasm_bytes = std::fs::read(&self.config.wasm_path)
.map_err(|e| RoboticusError::Config(format!("cannot read WASM file: {e}")))?;
if wasm_bytes.is_empty() {
return Err(RoboticusError::Config("WASM file is empty".into()));
}
let engine = wasmer::Engine::default();
let module = wasmer::Module::new(&engine, &wasm_bytes)
.map_err(|e| RoboticusError::Config(format!("WASM compilation failed: {e}")))?;
for export in module.exports() {
if let wasmer::ExternType::Memory(mem_type) = export.ty() {
let min_bytes = mem_type.minimum.0 as u64 * 65_536;
if min_bytes > self.config.memory_limit_bytes {
return Err(RoboticusError::Config(format!(
"WASM module minimum memory ({min_bytes} bytes) exceeds limit ({} bytes)",
self.config.memory_limit_bytes
)));
}
}
}
let size = wasm_bytes.len();
self.engine = Some(engine);
self.module = Some(module);
self.loaded = true;
info!(
name = %self.config.name,
size,
"loaded WASM plugin"
);
Ok(())
}
fn acquire_execution_slot(&self) -> Result<()> {
loop {
let current = self.active_executions.load(Ordering::Relaxed);
if current >= MAX_CONCURRENT_WASM_EXECUTIONS {
return Err(RoboticusError::Config(format!(
"WASM plugin '{}' refused execution: concurrent execution limit ({MAX_CONCURRENT_WASM_EXECUTIONS}) reached",
self.config.name
)));
}
if self
.active_executions
.compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
return Ok(());
}
}
}
pub fn execute(&mut self, input: &serde_json::Value) -> Result<serde_json::Value> {
if !self.loaded {
return Err(RoboticusError::Config("WASM plugin not loaded".into()));
}
self.enforce_capabilities(input)?;
let engine = self
.engine
.as_ref()
.ok_or_else(|| RoboticusError::Config("WASM engine not initialized".into()))?;
let module = self
.module
.as_ref()
.ok_or_else(|| RoboticusError::Config("WASM module not compiled".into()))?;
self.invocation_count += 1;
debug!(
name = %self.config.name,
invocations = self.invocation_count,
"executing WASM plugin"
);
let mut store = wasmer::Store::new(engine.clone());
let imports = wasmer::Imports::new();
let instance = wasmer::Instance::new(&mut store, module, &imports)
.map_err(|e| RoboticusError::Config(format!("WASM instantiation failed: {e}")))?;
if let Ok(memory) = instance.exports.get_memory("memory")
&& let Ok(input_bytes) = serde_json::to_vec(input)
{
let view = memory.view(&store);
let mem_size = view.data_size() as usize;
if input_bytes.len() <= mem_size {
if let Err(e) = view.write(0, &input_bytes) {
warn!(
plugin = %self.config.name,
error = %e,
"failed to write input to WASM memory"
);
}
} else {
warn!(
plugin = %self.config.name,
input_len = input_bytes.len(),
mem_size,
"input exceeds WASM memory size, skipping write"
);
}
}
let deadline = std::time::Duration::from_millis(self.config.execution_timeout_ms);
if let Ok(func) = instance.exports.get_function("process") {
self.acquire_execution_slot()?;
let func = func.clone();
let memory = instance.exports.get_memory("memory").ok().cloned();
let active = Arc::clone(&self.active_executions);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
struct ActiveGuard(Arc<AtomicUsize>);
impl Drop for ActiveGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
let _guard = ActiveGuard(active);
let result = func.call(&mut store, &[]);
let _ = tx.send((result, store));
});
let (results, store) = match rx.recv_timeout(deadline) {
Ok((Ok(results), store)) => (results, store),
Ok((Err(e), _)) => {
return Err(RoboticusError::Config(format!(
"WASM execution failed: {e}"
)));
}
Err(_) => {
warn!(
plugin = %self.config.name,
deadline_ms = self.config.execution_timeout_ms,
"WASM execution timed out — orphan thread may still be running"
);
return Err(RoboticusError::Config(format!(
"WASM plugin '{}' timed out after {}ms",
self.config.name, self.config.execution_timeout_ms,
)));
}
};
let result_values: Vec<serde_json::Value> =
results.iter().map(wasmer_value_to_json).collect();
if let Some(ref memory) = memory
&& result_values.len() == 2
&& let Some(ptr) = result_values[0].as_i64().filter(|&v| v >= 0)
&& let Some(len) = result_values[1]
.as_i64()
.filter(|&v| v > 0 && v <= 10_000_000)
{
let view = memory.view(&store);
let mem_size = view.data_size();
let end = (ptr as u64).saturating_add(len as u64);
if end > mem_size {
return Err(RoboticusError::Config(format!(
"WASM memory read out of bounds: ptr={ptr}, len={len}, memory_size={mem_size}"
)));
}
let mut buf = vec![0u8; len as usize];
if view.read(ptr as u64, &mut buf).is_ok()
&& let Ok(text) = String::from_utf8(buf)
{
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&text) {
return Ok(serde_json::json!({
"status": "executed",
"plugin": self.config.name,
"output": json_val,
}));
}
return Ok(serde_json::json!({
"status": "executed",
"plugin": self.config.name,
"output": text,
}));
}
}
let result_json = match result_values.len() {
0 => serde_json::Value::Null,
1 => result_values.into_iter().next().unwrap(),
_ => serde_json::json!(result_values),
};
return Ok(serde_json::json!({
"status": "executed",
"plugin": self.config.name,
"result": result_json,
}));
}
if let Ok(func) = instance.exports.get_function("_start") {
self.acquire_execution_slot()?;
let func = func.clone();
let active = Arc::clone(&self.active_executions);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
struct ActiveGuard(Arc<AtomicUsize>);
impl Drop for ActiveGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
let _guard = ActiveGuard(active);
let result = func.call(&mut store, &[]);
let _ = tx.send(result);
});
match rx.recv_timeout(deadline) {
Ok(Ok(_)) => {
return Ok(serde_json::json!({
"status": "executed",
"plugin": self.config.name,
}));
}
Ok(Err(e)) => {
return Err(RoboticusError::Config(format!(
"WASM execution failed: {e}"
)));
}
Err(_) => {
warn!(
plugin = %self.config.name,
deadline_ms = self.config.execution_timeout_ms,
"WASM execution timed out — orphan thread may still be running"
);
return Err(RoboticusError::Config(format!(
"WASM plugin '{}' timed out after {}ms",
self.config.name, self.config.execution_timeout_ms,
)));
}
}
}
let export_names: Vec<String> = instance
.exports
.iter()
.map(|(name, _)| name.to_string())
.collect();
Ok(serde_json::json!({
"status": "no_entry_point",
"plugin": self.config.name,
"available_exports": export_names,
}))
}
fn enforce_capabilities(&self, input: &serde_json::Value) -> Result<()> {
let mut required: Vec<WasmCapability> = vec![];
if let Some(explicit) = input
.get("required_capabilities")
.and_then(|v| v.as_array())
{
for cap in explicit.iter().filter_map(|v| v.as_str()) {
match cap.to_ascii_lowercase().as_str() {
"readfilesystem" | "read_filesystem" | "filesystem_read" => {
if !required.contains(&WasmCapability::ReadFilesystem) {
required.push(WasmCapability::ReadFilesystem);
}
}
"writefilesystem" | "write_filesystem" | "filesystem_write" => {
if !required.contains(&WasmCapability::WriteFilesystem) {
required.push(WasmCapability::WriteFilesystem);
}
}
"network" => {
if !required.contains(&WasmCapability::Network) {
required.push(WasmCapability::Network);
}
}
"environment" | "env" => {
if !required.contains(&WasmCapability::Environment) {
required.push(WasmCapability::Environment);
}
}
_ => {}
}
}
}
let scan = input_capability_scan::scan_input_capabilities(input);
if scan.requires_filesystem && !required.contains(&WasmCapability::ReadFilesystem) {
required.push(WasmCapability::ReadFilesystem);
}
if scan.requires_network && !required.contains(&WasmCapability::Network) {
required.push(WasmCapability::Network);
}
if scan.requires_environment && !required.contains(&WasmCapability::Environment) {
required.push(WasmCapability::Environment);
}
for cap in required {
if !self.has_capability(&cap) {
return Err(RoboticusError::Tool {
tool: self.config.name.clone(),
message: format!("missing required WASM capability: {:?}", cap),
});
}
}
Ok(())
}
pub fn is_loaded(&self) -> bool {
self.loaded
}
pub fn has_capability(&self, cap: &WasmCapability) -> bool {
self.config.capabilities.contains(cap)
}
pub fn unload(&mut self) {
self.loaded = false;
self.engine = None;
self.module = None;
debug!(name = %self.config.name, "unloaded WASM plugin");
}
}
fn wasmer_value_to_json(val: &wasmer::Value) -> serde_json::Value {
match val {
wasmer::Value::I32(v) => serde_json::json!(v),
wasmer::Value::I64(v) => serde_json::json!(v),
wasmer::Value::F32(v) => serde_json::json!(v),
wasmer::Value::F64(v) => serde_json::json!(v),
other => serde_json::json!(format!("{:?}", other)),
}
}
#[derive(Debug, Default)]
pub struct WasmPluginRegistry {
plugins: HashMap<String, WasmPlugin>,
}
impl WasmPluginRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, config: WasmPluginConfig) -> Result<()> {
let name = config.name.clone();
let plugin = WasmPlugin::new(config);
self.plugins.insert(name, plugin);
Ok(())
}
pub fn load_plugin(&mut self, name: &str) -> Result<()> {
let plugin = self
.plugins
.get_mut(name)
.ok_or_else(|| RoboticusError::Config(format!("plugin '{}' not registered", name)))?;
plugin.load()
}
pub fn execute(&mut self, name: &str, input: &serde_json::Value) -> Result<serde_json::Value> {
let plugin = self
.plugins
.get_mut(name)
.ok_or_else(|| RoboticusError::Config(format!("plugin '{}' not found", name)))?;
plugin.execute(input)
}
pub fn get(&self, name: &str) -> Option<&WasmPlugin> {
self.plugins.get(name)
}
pub fn list(&self) -> Vec<&str> {
self.plugins.keys().map(|s| s.as_str()).collect()
}
pub fn loaded_count(&self) -> usize {
self.plugins.values().filter(|p| p.loaded).count()
}
pub fn total_count(&self) -> usize {
self.plugins.len()
}
pub fn unload_all(&mut self) {
for plugin in self.plugins.values_mut() {
plugin.unload();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn test_wasm_bytes() -> Vec<u8> {
wat::parse_str(r#"(module (func (export "process") (result i32) i32.const 42))"#).unwrap()
}
fn test_config(dir: &Path, name: &str) -> WasmPluginConfig {
let wasm_path = dir.join(format!("{name}.wasm"));
fs::write(&wasm_path, test_wasm_bytes()).unwrap();
WasmPluginConfig {
name: name.to_string(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
}
}
fn plugin_with_capabilities(capabilities: Vec<WasmCapability>) -> WasmPlugin {
WasmPlugin::new(WasmPluginConfig {
name: "scan-matrix".to_string(),
wasm_path: PathBuf::from("/tmp/scan-matrix.wasm"),
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities,
})
}
#[test]
fn plugin_load_and_execute() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "test-plugin");
let mut plugin = WasmPlugin::new(config);
assert!(!plugin.is_loaded());
plugin.load().unwrap();
assert!(plugin.is_loaded());
let result = plugin
.execute(&serde_json::json!({"key": "value"}))
.unwrap();
assert_eq!(result["status"], "executed");
assert_eq!(result["result"], 42);
assert_eq!(plugin.invocation_count, 1);
}
#[test]
fn plugin_load_missing_file() {
let config = WasmPluginConfig {
name: "missing".to_string(),
wasm_path: PathBuf::from("/nonexistent/plugin.wasm"),
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
assert!(plugin.load().is_err());
}
#[test]
fn plugin_load_empty_file() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("empty.wasm");
fs::write(&wasm_path, b"").unwrap();
let config = WasmPluginConfig {
name: "empty".to_string(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
assert!(plugin.load().is_err());
}
#[test]
fn plugin_load_invalid_wasm() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("invalid.wasm");
fs::write(&wasm_path, b"not valid wasm bytes").unwrap();
let config = WasmPluginConfig {
name: "invalid".to_string(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
let err = plugin.load().unwrap_err();
assert!(err.to_string().contains("WASM compilation failed"));
}
#[test]
fn plugin_execute_without_load() {
let config = WasmPluginConfig {
name: "not-loaded".to_string(),
wasm_path: PathBuf::from("/fake.wasm"),
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
assert!(plugin.execute(&serde_json::json!({})).is_err());
}
#[test]
fn plugin_capabilities() {
let config = WasmPluginConfig {
name: "caps".to_string(),
wasm_path: PathBuf::from("/fake.wasm"),
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![WasmCapability::ReadFilesystem, WasmCapability::Network],
};
let plugin = WasmPlugin::new(config);
assert!(plugin.has_capability(&WasmCapability::ReadFilesystem));
assert!(plugin.has_capability(&WasmCapability::Network));
assert!(!plugin.has_capability(&WasmCapability::WriteFilesystem));
}
#[test]
fn capability_enforcement_blocks_network_access() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "caps-enforced");
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let err = plugin
.execute(&serde_json::json!({"url": "https://example.com"}))
.unwrap_err();
assert!(err.to_string().contains("missing required WASM capability"));
}
#[test]
fn capability_enforcement_allows_declared_network_access() {
let dir = TempDir::new().unwrap();
let mut config = test_config(dir.path(), "caps-network");
config.capabilities = vec![WasmCapability::Network];
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin
.execute(&serde_json::json!({"url": "https://example.com"}))
.unwrap();
assert_eq!(result["status"], "executed");
}
#[test]
fn capability_enforcement_blocks_filesystem_access_for_path_keys() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "caps-fs");
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let err = plugin
.execute(&serde_json::json!({"path": "src/main.rs"}))
.unwrap_err();
assert!(err.to_string().contains("missing required WASM capability"));
}
#[test]
fn capability_enforcement_ignores_regex_backslashes_without_path_context() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "caps-regex");
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin
.execute(&serde_json::json!({"pattern": "\\d+\\w+\\s*"}))
.unwrap();
assert_eq!(result["status"], "executed");
}
#[test]
fn capability_enforcement_matches_shared_scan_for_input_matrix() {
let cases = vec![
serde_json::json!({}),
serde_json::json!({"endpoint": "https://example.com/v1"}),
serde_json::json!({"socket": "wss://example.com/stream"}),
serde_json::json!({"model": "openai/gpt-4o"}),
serde_json::json!({"model": "/etc/passwd"}),
serde_json::json!({"path": "src/main.rs"}),
serde_json::json!({"input": "secrets/config.yaml"}),
serde_json::json!({"pattern": "\\d+\\w+\\s*"}),
serde_json::json!({"env_var": "SECRET_TOKEN"}),
];
for input in cases {
let scan = input_capability_scan::scan_input_capabilities(&input);
let mut required_caps = Vec::new();
if scan.requires_filesystem {
required_caps.push(WasmCapability::ReadFilesystem);
}
if scan.requires_network {
required_caps.push(WasmCapability::Network);
}
if scan.requires_environment {
required_caps.push(WasmCapability::Environment);
}
let no_caps = plugin_with_capabilities(vec![]);
let no_caps_ok = no_caps.enforce_capabilities(&input).is_ok();
assert_eq!(
no_caps_ok,
required_caps.is_empty(),
"no-capability behavior mismatch for input: {input}"
);
let with_required = plugin_with_capabilities(required_caps);
assert!(
with_required.enforce_capabilities(&input).is_ok(),
"required-capability behavior mismatch for input: {input}"
);
}
}
#[test]
fn plugin_unload() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "unload-test");
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
assert!(plugin.is_loaded());
plugin.unload();
assert!(!plugin.is_loaded());
assert!(plugin.engine.is_none());
assert!(plugin.module.is_none());
}
#[test]
fn plugin_no_entry_point() {
let dir = TempDir::new().unwrap();
let wasm_bytes =
wat::parse_str(r#"(module (func (export "other_fn") (result i32) i32.const 1))"#)
.unwrap();
let wasm_path = dir.path().join("no-entry.wasm");
fs::write(&wasm_path, wasm_bytes).unwrap();
let config = WasmPluginConfig {
name: "no-entry".to_string(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "no_entry_point");
let exports = result["available_exports"].as_array().unwrap();
assert!(exports.iter().any(|e| e == "other_fn"));
}
#[test]
fn plugin_start_entry_point() {
let dir = TempDir::new().unwrap();
let wasm_bytes = wat::parse_str(r#"(module (func (export "_start") nop))"#).unwrap();
let wasm_path = dir.path().join("start.wasm");
fs::write(&wasm_path, wasm_bytes).unwrap();
let config = WasmPluginConfig {
name: "start".to_string(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "executed");
assert_eq!(result["plugin"], "start");
}
#[test]
fn registry_register_and_list() {
let dir = TempDir::new().unwrap();
let mut reg = WasmPluginRegistry::new();
reg.register(test_config(dir.path(), "a")).unwrap();
reg.register(test_config(dir.path(), "b")).unwrap();
assert_eq!(reg.total_count(), 2);
assert_eq!(reg.loaded_count(), 0);
}
#[test]
fn registry_load_and_execute() {
let dir = TempDir::new().unwrap();
let mut reg = WasmPluginRegistry::new();
reg.register(test_config(dir.path(), "plugin")).unwrap();
reg.load_plugin("plugin").unwrap();
assert_eq!(reg.loaded_count(), 1);
let result = reg
.execute("plugin", &serde_json::json!({"q": "test"}))
.unwrap();
assert_eq!(result["status"], "executed");
assert_eq!(result["result"], 42);
}
#[test]
fn registry_execute_unknown() {
let mut reg = WasmPluginRegistry::new();
assert!(reg.execute("nope", &serde_json::json!({})).is_err());
}
#[test]
fn registry_unload_all() {
let dir = TempDir::new().unwrap();
let mut reg = WasmPluginRegistry::new();
reg.register(test_config(dir.path(), "a")).unwrap();
reg.register(test_config(dir.path(), "b")).unwrap();
reg.load_plugin("a").unwrap();
reg.load_plugin("b").unwrap();
assert_eq!(reg.loaded_count(), 2);
reg.unload_all();
assert_eq!(reg.loaded_count(), 0);
}
#[test]
fn config_serde() {
let config = WasmPluginConfig {
name: "test".to_string(),
wasm_path: PathBuf::from("/tmp/test.wasm"),
memory_limit_bytes: 1024,
execution_timeout_ms: 5000,
capabilities: vec![WasmCapability::Network],
};
let json = serde_json::to_string(&config).unwrap();
let back: WasmPluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "test");
assert_eq!(back.capabilities, vec![WasmCapability::Network]);
}
fn wasm_bytes_memory_json() -> Vec<u8> {
wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data (i32.const 4096) "{\"ok\":true}")
(func (export "process") (result i32 i32)
i32.const 4096 ;; ptr (beyond input write zone)
i32.const 11 ;; len of {"ok":true}
)
)"#,
)
.unwrap()
}
fn wasm_bytes_memory_text() -> Vec<u8> {
wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data (i32.const 4096) "hello")
(func (export "process") (result i32 i32)
i32.const 4096 ;; ptr (beyond input write zone)
i32.const 5 ;; len
)
)"#,
)
.unwrap()
}
fn wasm_bytes_memory_oob() -> Vec<u8> {
wat::parse_str(
r#"(module
(memory (export "memory") 1)
(func (export "process") (result i32 i32)
i32.const 0
i32.const 99999 ;; len far exceeds 1 page (65536 bytes)
)
)"#,
)
.unwrap()
}
fn wasm_bytes_memory_single_return() -> Vec<u8> {
wat::parse_str(
r#"(module
(memory (export "memory") 1)
(func (export "process") (result i32)
i32.const 99
)
)"#,
)
.unwrap()
}
fn wasm_bytes_multi_return() -> Vec<u8> {
wat::parse_str(
r#"(module
(func (export "process") (result i32 i32 i32)
i32.const 1
i32.const 2
i32.const 3
)
)"#,
)
.unwrap()
}
fn wasm_bytes_void_return() -> Vec<u8> {
wat::parse_str(
r#"(module
(func (export "process") nop)
)"#,
)
.unwrap()
}
#[test]
fn execute_memory_json_output() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("mem-json.wasm");
fs::write(&wasm_path, wasm_bytes_memory_json()).unwrap();
let config = WasmPluginConfig {
name: "mem-json".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "executed");
assert_eq!(result["plugin"], "mem-json");
assert_eq!(result["output"]["ok"], true);
}
#[test]
fn execute_memory_text_output() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("mem-text.wasm");
fs::write(&wasm_path, wasm_bytes_memory_text()).unwrap();
let config = WasmPluginConfig {
name: "mem-text".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "executed");
assert_eq!(result["output"], "hello");
}
#[test]
fn execute_memory_out_of_bounds() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("mem-oob.wasm");
fs::write(&wasm_path, wasm_bytes_memory_oob()).unwrap();
let config = WasmPluginConfig {
name: "mem-oob".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let err = plugin.execute(&serde_json::json!({})).unwrap_err();
assert!(
err.to_string().contains("out of bounds"),
"expected out-of-bounds error, got: {err}"
);
}
#[test]
fn execute_memory_single_return_with_exported_memory() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("mem-single.wasm");
fs::write(&wasm_path, wasm_bytes_memory_single_return()).unwrap();
let config = WasmPluginConfig {
name: "mem-single".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "executed");
assert_eq!(result["result"], 99);
}
#[test]
fn execute_multi_return_values() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("multi.wasm");
fs::write(&wasm_path, wasm_bytes_multi_return()).unwrap();
let config = WasmPluginConfig {
name: "multi".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "executed");
let arr = result["result"].as_array().unwrap();
assert_eq!(arr.len(), 3);
assert_eq!(arr[0], 1);
assert_eq!(arr[1], 2);
assert_eq!(arr[2], 3);
}
#[test]
fn execute_void_return() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("void.wasm");
fs::write(&wasm_path, wasm_bytes_void_return()).unwrap();
let config = WasmPluginConfig {
name: "void".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let result = plugin.execute(&serde_json::json!({})).unwrap();
assert_eq!(result["status"], "executed");
assert!(result["result"].is_null());
}
#[test]
fn execute_writes_input_to_memory() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("mem-write.wasm");
fs::write(&wasm_path, wasm_bytes_memory_text()).unwrap();
let config = WasmPluginConfig {
name: "mem-write".into(),
wasm_path,
memory_limit_bytes: default_memory_limit(),
execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let big_input = serde_json::json!({"data": "x".repeat(100)});
let result = plugin.execute(&big_input).unwrap();
assert_eq!(result["status"], "executed");
}
#[test]
fn wasmer_value_to_json_i32() {
let v = wasmer::Value::I32(42);
assert_eq!(wasmer_value_to_json(&v), serde_json::json!(42));
}
#[test]
fn wasmer_value_to_json_i64() {
let v = wasmer::Value::I64(9_999_999_999);
assert_eq!(
wasmer_value_to_json(&v),
serde_json::json!(9_999_999_999i64)
);
}
#[test]
fn wasmer_value_to_json_f32() {
let v = wasmer::Value::F32(1.5);
let json = wasmer_value_to_json(&v);
assert!(json.is_number());
let n = json.as_f64().unwrap();
assert!((n - 1.5).abs() < 0.01);
}
#[test]
fn wasmer_value_to_json_f64() {
let v = wasmer::Value::F64(1.23456);
let json = wasmer_value_to_json(&v);
let n = json.as_f64().unwrap();
assert!((n - 1.23456).abs() < 0.001);
}
#[test]
fn enforce_capabilities_explicit_read_filesystem_aliases() {
let dir = TempDir::new().unwrap();
let mut config = test_config(dir.path(), "cap-explicit");
config.capabilities = vec![WasmCapability::ReadFilesystem];
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
for alias in ["readfilesystem", "read_filesystem", "filesystem_read"] {
let input = serde_json::json!({"required_capabilities": [alias]});
assert!(
plugin.execute(&input).is_ok(),
"ReadFilesystem alias '{alias}' should be granted"
);
}
}
#[test]
fn enforce_capabilities_explicit_write_filesystem_aliases() {
let dir = TempDir::new().unwrap();
let mut config = test_config(dir.path(), "cap-write");
config.capabilities = vec![WasmCapability::WriteFilesystem];
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
for alias in ["writefilesystem", "write_filesystem", "filesystem_write"] {
let input = serde_json::json!({"required_capabilities": [alias]});
assert!(
plugin.execute(&input).is_ok(),
"WriteFilesystem alias '{alias}' should be granted"
);
}
}
#[test]
fn enforce_capabilities_explicit_network() {
let dir = TempDir::new().unwrap();
let mut config = test_config(dir.path(), "cap-net");
config.capabilities = vec![WasmCapability::Network];
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let input = serde_json::json!({"required_capabilities": ["network"]});
assert!(plugin.execute(&input).is_ok());
}
#[test]
fn enforce_capabilities_explicit_environment_aliases() {
let dir = TempDir::new().unwrap();
let mut config = test_config(dir.path(), "cap-env");
config.capabilities = vec![WasmCapability::Environment];
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
for alias in ["environment", "env"] {
let input = serde_json::json!({"required_capabilities": [alias]});
assert!(
plugin.execute(&input).is_ok(),
"Environment alias '{alias}' should be granted"
);
}
}
#[test]
fn enforce_capabilities_explicit_unknown_ignored() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "cap-unknown");
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let input = serde_json::json!({"required_capabilities": ["nonexistent_capability"]});
assert!(plugin.execute(&input).is_ok());
}
#[test]
fn enforce_capabilities_explicit_denied_without_grant() {
let dir = TempDir::new().unwrap();
let config = test_config(dir.path(), "cap-deny");
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let input = serde_json::json!({"required_capabilities": ["network"]});
let err = plugin.execute(&input).unwrap_err();
assert!(err.to_string().contains("missing required WASM capability"));
}
#[test]
fn enforce_capabilities_deduplicates() {
let dir = TempDir::new().unwrap();
let mut config = test_config(dir.path(), "cap-dedup");
config.capabilities = vec![WasmCapability::Network];
let mut plugin = WasmPlugin::new(config);
plugin.load().unwrap();
let input = serde_json::json!({
"required_capabilities": ["network", "network"],
"url": "https://example.com"
});
assert!(plugin.execute(&input).is_ok());
}
#[test]
fn load_rejects_oversized_memory() {
let dir = TempDir::new().unwrap();
let wasm = wat::parse_str(
r#"(module (memory (export "memory") 256) (func (export "process") nop))"#,
)
.unwrap();
let wasm_path = dir.path().join("big-mem.wasm");
fs::write(&wasm_path, wasm).unwrap();
let config = WasmPluginConfig {
name: "big-mem".into(),
wasm_path,
memory_limit_bytes: 1024 * 1024, execution_timeout_ms: default_execution_timeout_ms(),
capabilities: vec![],
};
let mut plugin = WasmPlugin::new(config);
let err = plugin.load().unwrap_err();
assert!(
err.to_string().contains("exceeds limit"),
"expected memory limit error, got: {err}"
);
}
#[test]
fn debug_impl_for_plugin() {
let config = WasmPluginConfig {
name: "debug-test".into(),
wasm_path: PathBuf::from("/tmp/debug.wasm"),
memory_limit_bytes: 1024,
execution_timeout_ms: 5000,
capabilities: vec![],
};
let plugin = WasmPlugin::new(config);
let dbg = format!("{:?}", plugin);
assert!(dbg.contains("debug-test"));
assert!(dbg.contains("has_engine"));
}
}