1use roboticus_core::{Result, RoboticusError, input_capability_scan};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use tracing::{debug, info, warn};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct WasmPluginConfig {
12 pub name: String,
13 pub wasm_path: PathBuf,
14 #[serde(default = "default_memory_limit")]
15 pub memory_limit_bytes: u64,
16 #[serde(default = "default_execution_timeout_ms")]
17 pub execution_timeout_ms: u64,
18 #[serde(default)]
19 pub capabilities: Vec<WasmCapability>,
20}
21
22fn default_memory_limit() -> u64 {
23 64 * 1024 * 1024
24}
25fn default_execution_timeout_ms() -> u64 {
26 30_000
27}
28const MAX_CONCURRENT_WASM_EXECUTIONS: usize = 32;
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub enum WasmCapability {
33 ReadFilesystem,
34 WriteFilesystem,
35 Network,
36 Environment,
37}
38
39pub struct WasmPlugin {
41 pub config: WasmPluginConfig,
42 pub loaded: bool,
43 pub invocation_count: u64,
44 pub last_error: Option<String>,
45 engine: Option<wasmer::Engine>,
46 module: Option<wasmer::Module>,
47 active_executions: Arc<AtomicUsize>,
48}
49
50impl std::fmt::Debug for WasmPlugin {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("WasmPlugin")
53 .field("config", &self.config)
54 .field("loaded", &self.loaded)
55 .field("invocation_count", &self.invocation_count)
56 .field("last_error", &self.last_error)
57 .field("has_engine", &self.engine.is_some())
58 .field("has_module", &self.module.is_some())
59 .field(
60 "active_executions",
61 &self.active_executions.load(Ordering::Relaxed),
62 )
63 .finish()
64 }
65}
66
67impl WasmPlugin {
68 pub fn new(config: WasmPluginConfig) -> Self {
69 Self {
70 config,
71 loaded: false,
72 invocation_count: 0,
73 last_error: None,
74 engine: None,
75 module: None,
76 active_executions: Arc::new(AtomicUsize::new(0)),
77 }
78 }
79
80 pub fn load(&mut self) -> Result<()> {
82 if !self.config.wasm_path.exists() {
83 return Err(RoboticusError::Config(format!(
84 "WASM file not found: {}",
85 self.config.wasm_path.display()
86 )));
87 }
88
89 let wasm_bytes = std::fs::read(&self.config.wasm_path)
90 .map_err(|e| RoboticusError::Config(format!("cannot read WASM file: {e}")))?;
91
92 if wasm_bytes.is_empty() {
93 return Err(RoboticusError::Config("WASM file is empty".into()));
94 }
95
96 let engine = wasmer::Engine::default();
97 let module = wasmer::Module::new(&engine, &wasm_bytes)
98 .map_err(|e| RoboticusError::Config(format!("WASM compilation failed: {e}")))?;
99
100 for export in module.exports() {
101 if let wasmer::ExternType::Memory(mem_type) = export.ty() {
102 let min_bytes = mem_type.minimum.0 as u64 * 65_536;
103 if min_bytes > self.config.memory_limit_bytes {
104 return Err(RoboticusError::Config(format!(
105 "WASM module minimum memory ({min_bytes} bytes) exceeds limit ({} bytes)",
106 self.config.memory_limit_bytes
107 )));
108 }
109 }
110 }
111
112 let size = wasm_bytes.len();
113 self.engine = Some(engine);
114 self.module = Some(module);
115 self.loaded = true;
116
117 info!(
118 name = %self.config.name,
119 size,
120 "loaded WASM plugin"
121 );
122 Ok(())
123 }
124
125 fn acquire_execution_slot(&self) -> Result<()> {
126 loop {
127 let current = self.active_executions.load(Ordering::Relaxed);
128 if current >= MAX_CONCURRENT_WASM_EXECUTIONS {
129 return Err(RoboticusError::Config(format!(
130 "WASM plugin '{}' refused execution: concurrent execution limit ({MAX_CONCURRENT_WASM_EXECUTIONS}) reached",
131 self.config.name
132 )));
133 }
134 if self
135 .active_executions
136 .compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
137 .is_ok()
138 {
139 return Ok(());
140 }
141 }
142 }
143
144 pub fn execute(&mut self, input: &serde_json::Value) -> Result<serde_json::Value> {
146 if !self.loaded {
147 return Err(RoboticusError::Config("WASM plugin not loaded".into()));
148 }
149 self.enforce_capabilities(input)?;
150
151 let engine = self
152 .engine
153 .as_ref()
154 .ok_or_else(|| RoboticusError::Config("WASM engine not initialized".into()))?;
155 let module = self
156 .module
157 .as_ref()
158 .ok_or_else(|| RoboticusError::Config("WASM module not compiled".into()))?;
159
160 self.invocation_count += 1;
161 debug!(
162 name = %self.config.name,
163 invocations = self.invocation_count,
164 "executing WASM plugin"
165 );
166
167 let mut store = wasmer::Store::new(engine.clone());
168 let imports = wasmer::Imports::new();
169 let instance = wasmer::Instance::new(&mut store, module, &imports)
170 .map_err(|e| RoboticusError::Config(format!("WASM instantiation failed: {e}")))?;
171
172 if let Ok(memory) = instance.exports.get_memory("memory")
173 && let Ok(input_bytes) = serde_json::to_vec(input)
174 {
175 let view = memory.view(&store);
176 let mem_size = view.data_size() as usize;
177 if input_bytes.len() <= mem_size {
178 if let Err(e) = view.write(0, &input_bytes) {
179 warn!(
180 plugin = %self.config.name,
181 error = %e,
182 "failed to write input to WASM memory"
183 );
184 }
185 } else {
186 warn!(
187 plugin = %self.config.name,
188 input_len = input_bytes.len(),
189 mem_size,
190 "input exceeds WASM memory size, skipping write"
191 );
192 }
193 }
194
195 let deadline = std::time::Duration::from_millis(self.config.execution_timeout_ms);
196
197 if let Ok(func) = instance.exports.get_function("process") {
204 self.acquire_execution_slot()?;
205 let func = func.clone();
206 let memory = instance.exports.get_memory("memory").ok().cloned();
207 let active = Arc::clone(&self.active_executions);
208
209 let (tx, rx) = std::sync::mpsc::sync_channel(1);
210 std::thread::spawn(move || {
211 struct ActiveGuard(Arc<AtomicUsize>);
212 impl Drop for ActiveGuard {
213 fn drop(&mut self) {
214 self.0.fetch_sub(1, Ordering::Relaxed);
215 }
216 }
217 let _guard = ActiveGuard(active);
218 let result = func.call(&mut store, &[]);
219 let _ = tx.send((result, store));
220 });
221
222 let (results, store) = match rx.recv_timeout(deadline) {
223 Ok((Ok(results), store)) => (results, store),
224 Ok((Err(e), _)) => {
225 return Err(RoboticusError::Config(format!(
226 "WASM execution failed: {e}"
227 )));
228 }
229 Err(_) => {
230 warn!(
231 plugin = %self.config.name,
232 deadline_ms = self.config.execution_timeout_ms,
233 "WASM execution timed out — orphan thread may still be running"
234 );
235 return Err(RoboticusError::Config(format!(
236 "WASM plugin '{}' timed out after {}ms",
237 self.config.name, self.config.execution_timeout_ms,
238 )));
239 }
240 };
241
242 let result_values: Vec<serde_json::Value> =
243 results.iter().map(wasmer_value_to_json).collect();
244
245 if let Some(ref memory) = memory
246 && result_values.len() == 2
247 && let Some(ptr) = result_values[0].as_i64().filter(|&v| v >= 0)
248 && let Some(len) = result_values[1]
249 .as_i64()
250 .filter(|&v| v > 0 && v <= 10_000_000)
251 {
252 let view = memory.view(&store);
253 let mem_size = view.data_size();
254 let end = (ptr as u64).saturating_add(len as u64);
255 if end > mem_size {
256 return Err(RoboticusError::Config(format!(
257 "WASM memory read out of bounds: ptr={ptr}, len={len}, memory_size={mem_size}"
258 )));
259 }
260
261 let mut buf = vec![0u8; len as usize];
262 if view.read(ptr as u64, &mut buf).is_ok()
263 && let Ok(text) = String::from_utf8(buf)
264 {
265 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&text) {
266 return Ok(serde_json::json!({
267 "status": "executed",
268 "plugin": self.config.name,
269 "output": json_val,
270 }));
271 }
272 return Ok(serde_json::json!({
273 "status": "executed",
274 "plugin": self.config.name,
275 "output": text,
276 }));
277 }
278 }
279
280 let result_json = match result_values.len() {
281 0 => serde_json::Value::Null,
282 1 => result_values.into_iter().next().unwrap(),
283 _ => serde_json::json!(result_values),
284 };
285
286 return Ok(serde_json::json!({
287 "status": "executed",
288 "plugin": self.config.name,
289 "result": result_json,
290 }));
291 }
292
293 if let Ok(func) = instance.exports.get_function("_start") {
294 self.acquire_execution_slot()?;
295 let func = func.clone();
296 let active = Arc::clone(&self.active_executions);
297 let (tx, rx) = std::sync::mpsc::sync_channel(1);
298 std::thread::spawn(move || {
299 struct ActiveGuard(Arc<AtomicUsize>);
300 impl Drop for ActiveGuard {
301 fn drop(&mut self) {
302 self.0.fetch_sub(1, Ordering::Relaxed);
303 }
304 }
305 let _guard = ActiveGuard(active);
306 let result = func.call(&mut store, &[]);
307 let _ = tx.send(result);
308 });
309
310 match rx.recv_timeout(deadline) {
311 Ok(Ok(_)) => {
312 return Ok(serde_json::json!({
313 "status": "executed",
314 "plugin": self.config.name,
315 }));
316 }
317 Ok(Err(e)) => {
318 return Err(RoboticusError::Config(format!(
319 "WASM execution failed: {e}"
320 )));
321 }
322 Err(_) => {
323 warn!(
324 plugin = %self.config.name,
325 deadline_ms = self.config.execution_timeout_ms,
326 "WASM execution timed out — orphan thread may still be running"
327 );
328 return Err(RoboticusError::Config(format!(
329 "WASM plugin '{}' timed out after {}ms",
330 self.config.name, self.config.execution_timeout_ms,
331 )));
332 }
333 }
334 }
335
336 let export_names: Vec<String> = instance
337 .exports
338 .iter()
339 .map(|(name, _)| name.to_string())
340 .collect();
341
342 Ok(serde_json::json!({
343 "status": "no_entry_point",
344 "plugin": self.config.name,
345 "available_exports": export_names,
346 }))
347 }
348
349 fn enforce_capabilities(&self, input: &serde_json::Value) -> Result<()> {
350 let mut required: Vec<WasmCapability> = vec![];
351 if let Some(explicit) = input
352 .get("required_capabilities")
353 .and_then(|v| v.as_array())
354 {
355 for cap in explicit.iter().filter_map(|v| v.as_str()) {
356 match cap.to_ascii_lowercase().as_str() {
357 "readfilesystem" | "read_filesystem" | "filesystem_read" => {
358 if !required.contains(&WasmCapability::ReadFilesystem) {
359 required.push(WasmCapability::ReadFilesystem);
360 }
361 }
362 "writefilesystem" | "write_filesystem" | "filesystem_write" => {
363 if !required.contains(&WasmCapability::WriteFilesystem) {
364 required.push(WasmCapability::WriteFilesystem);
365 }
366 }
367 "network" => {
368 if !required.contains(&WasmCapability::Network) {
369 required.push(WasmCapability::Network);
370 }
371 }
372 "environment" | "env" => {
373 if !required.contains(&WasmCapability::Environment) {
374 required.push(WasmCapability::Environment);
375 }
376 }
377 _ => {}
378 }
379 }
380 }
381
382 let scan = input_capability_scan::scan_input_capabilities(input);
383 if scan.requires_filesystem && !required.contains(&WasmCapability::ReadFilesystem) {
384 required.push(WasmCapability::ReadFilesystem);
385 }
386 if scan.requires_network && !required.contains(&WasmCapability::Network) {
387 required.push(WasmCapability::Network);
388 }
389 if scan.requires_environment && !required.contains(&WasmCapability::Environment) {
390 required.push(WasmCapability::Environment);
391 }
392
393 for cap in required {
394 if !self.has_capability(&cap) {
395 return Err(RoboticusError::Tool {
396 tool: self.config.name.clone(),
397 message: format!("missing required WASM capability: {:?}", cap),
398 });
399 }
400 }
401 Ok(())
402 }
403
404 pub fn is_loaded(&self) -> bool {
405 self.loaded
406 }
407
408 pub fn has_capability(&self, cap: &WasmCapability) -> bool {
409 self.config.capabilities.contains(cap)
410 }
411
412 pub fn unload(&mut self) {
413 self.loaded = false;
414 self.engine = None;
415 self.module = None;
416 debug!(name = %self.config.name, "unloaded WASM plugin");
417 }
418}
419
420fn wasmer_value_to_json(val: &wasmer::Value) -> serde_json::Value {
421 match val {
422 wasmer::Value::I32(v) => serde_json::json!(v),
423 wasmer::Value::I64(v) => serde_json::json!(v),
424 wasmer::Value::F32(v) => serde_json::json!(v),
425 wasmer::Value::F64(v) => serde_json::json!(v),
426 other => serde_json::json!(format!("{:?}", other)),
427 }
428}
429
430#[derive(Debug, Default)]
432pub struct WasmPluginRegistry {
433 plugins: HashMap<String, WasmPlugin>,
434}
435
436impl WasmPluginRegistry {
437 pub fn new() -> Self {
438 Self::default()
439 }
440
441 pub fn register(&mut self, config: WasmPluginConfig) -> Result<()> {
442 let name = config.name.clone();
443 let plugin = WasmPlugin::new(config);
444 self.plugins.insert(name, plugin);
445 Ok(())
446 }
447
448 pub fn load_plugin(&mut self, name: &str) -> Result<()> {
449 let plugin = self
450 .plugins
451 .get_mut(name)
452 .ok_or_else(|| RoboticusError::Config(format!("plugin '{}' not registered", name)))?;
453 plugin.load()
454 }
455
456 pub fn execute(&mut self, name: &str, input: &serde_json::Value) -> Result<serde_json::Value> {
457 let plugin = self
458 .plugins
459 .get_mut(name)
460 .ok_or_else(|| RoboticusError::Config(format!("plugin '{}' not found", name)))?;
461 plugin.execute(input)
462 }
463
464 pub fn get(&self, name: &str) -> Option<&WasmPlugin> {
465 self.plugins.get(name)
466 }
467
468 pub fn list(&self) -> Vec<&str> {
469 self.plugins.keys().map(|s| s.as_str()).collect()
470 }
471
472 pub fn loaded_count(&self) -> usize {
473 self.plugins.values().filter(|p| p.loaded).count()
474 }
475
476 pub fn total_count(&self) -> usize {
477 self.plugins.len()
478 }
479
480 pub fn unload_all(&mut self) {
481 for plugin in self.plugins.values_mut() {
482 plugin.unload();
483 }
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use std::fs;
491 use std::path::Path;
492 use tempfile::TempDir;
493
494 fn test_wasm_bytes() -> Vec<u8> {
495 wat::parse_str(r#"(module (func (export "process") (result i32) i32.const 42))"#).unwrap()
496 }
497
498 fn test_config(dir: &Path, name: &str) -> WasmPluginConfig {
499 let wasm_path = dir.join(format!("{name}.wasm"));
500 fs::write(&wasm_path, test_wasm_bytes()).unwrap();
501 WasmPluginConfig {
502 name: name.to_string(),
503 wasm_path,
504 memory_limit_bytes: default_memory_limit(),
505 execution_timeout_ms: default_execution_timeout_ms(),
506 capabilities: vec![],
507 }
508 }
509
510 fn plugin_with_capabilities(capabilities: Vec<WasmCapability>) -> WasmPlugin {
511 WasmPlugin::new(WasmPluginConfig {
512 name: "scan-matrix".to_string(),
513 wasm_path: PathBuf::from("/tmp/scan-matrix.wasm"),
514 memory_limit_bytes: default_memory_limit(),
515 execution_timeout_ms: default_execution_timeout_ms(),
516 capabilities,
517 })
518 }
519
520 #[test]
521 fn plugin_load_and_execute() {
522 let dir = TempDir::new().unwrap();
523 let config = test_config(dir.path(), "test-plugin");
524 let mut plugin = WasmPlugin::new(config);
525
526 assert!(!plugin.is_loaded());
527 plugin.load().unwrap();
528 assert!(plugin.is_loaded());
529
530 let result = plugin
531 .execute(&serde_json::json!({"key": "value"}))
532 .unwrap();
533 assert_eq!(result["status"], "executed");
534 assert_eq!(result["result"], 42);
535 assert_eq!(plugin.invocation_count, 1);
536 }
537
538 #[test]
539 fn plugin_load_missing_file() {
540 let config = WasmPluginConfig {
541 name: "missing".to_string(),
542 wasm_path: PathBuf::from("/nonexistent/plugin.wasm"),
543 memory_limit_bytes: default_memory_limit(),
544 execution_timeout_ms: default_execution_timeout_ms(),
545 capabilities: vec![],
546 };
547 let mut plugin = WasmPlugin::new(config);
548 assert!(plugin.load().is_err());
549 }
550
551 #[test]
552 fn plugin_load_empty_file() {
553 let dir = TempDir::new().unwrap();
554 let wasm_path = dir.path().join("empty.wasm");
555 fs::write(&wasm_path, b"").unwrap();
556
557 let config = WasmPluginConfig {
558 name: "empty".to_string(),
559 wasm_path,
560 memory_limit_bytes: default_memory_limit(),
561 execution_timeout_ms: default_execution_timeout_ms(),
562 capabilities: vec![],
563 };
564 let mut plugin = WasmPlugin::new(config);
565 assert!(plugin.load().is_err());
566 }
567
568 #[test]
569 fn plugin_load_invalid_wasm() {
570 let dir = TempDir::new().unwrap();
571 let wasm_path = dir.path().join("invalid.wasm");
572 fs::write(&wasm_path, b"not valid wasm bytes").unwrap();
573
574 let config = WasmPluginConfig {
575 name: "invalid".to_string(),
576 wasm_path,
577 memory_limit_bytes: default_memory_limit(),
578 execution_timeout_ms: default_execution_timeout_ms(),
579 capabilities: vec![],
580 };
581 let mut plugin = WasmPlugin::new(config);
582 let err = plugin.load().unwrap_err();
583 assert!(err.to_string().contains("WASM compilation failed"));
584 }
585
586 #[test]
587 fn plugin_execute_without_load() {
588 let config = WasmPluginConfig {
589 name: "not-loaded".to_string(),
590 wasm_path: PathBuf::from("/fake.wasm"),
591 memory_limit_bytes: default_memory_limit(),
592 execution_timeout_ms: default_execution_timeout_ms(),
593 capabilities: vec![],
594 };
595 let mut plugin = WasmPlugin::new(config);
596 assert!(plugin.execute(&serde_json::json!({})).is_err());
597 }
598
599 #[test]
600 fn plugin_capabilities() {
601 let config = WasmPluginConfig {
602 name: "caps".to_string(),
603 wasm_path: PathBuf::from("/fake.wasm"),
604 memory_limit_bytes: default_memory_limit(),
605 execution_timeout_ms: default_execution_timeout_ms(),
606 capabilities: vec![WasmCapability::ReadFilesystem, WasmCapability::Network],
607 };
608 let plugin = WasmPlugin::new(config);
609 assert!(plugin.has_capability(&WasmCapability::ReadFilesystem));
610 assert!(plugin.has_capability(&WasmCapability::Network));
611 assert!(!plugin.has_capability(&WasmCapability::WriteFilesystem));
612 }
613
614 #[test]
615 fn capability_enforcement_blocks_network_access() {
616 let dir = TempDir::new().unwrap();
617 let config = test_config(dir.path(), "caps-enforced");
618 let mut plugin = WasmPlugin::new(config);
619 plugin.load().unwrap();
620 let err = plugin
621 .execute(&serde_json::json!({"url": "https://example.com"}))
622 .unwrap_err();
623 assert!(err.to_string().contains("missing required WASM capability"));
624 }
625
626 #[test]
627 fn capability_enforcement_allows_declared_network_access() {
628 let dir = TempDir::new().unwrap();
629 let mut config = test_config(dir.path(), "caps-network");
630 config.capabilities = vec![WasmCapability::Network];
631 let mut plugin = WasmPlugin::new(config);
632 plugin.load().unwrap();
633 let result = plugin
634 .execute(&serde_json::json!({"url": "https://example.com"}))
635 .unwrap();
636 assert_eq!(result["status"], "executed");
637 }
638
639 #[test]
640 fn capability_enforcement_blocks_filesystem_access_for_path_keys() {
641 let dir = TempDir::new().unwrap();
642 let config = test_config(dir.path(), "caps-fs");
643 let mut plugin = WasmPlugin::new(config);
644 plugin.load().unwrap();
645 let err = plugin
646 .execute(&serde_json::json!({"path": "src/main.rs"}))
647 .unwrap_err();
648 assert!(err.to_string().contains("missing required WASM capability"));
649 }
650
651 #[test]
652 fn capability_enforcement_ignores_regex_backslashes_without_path_context() {
653 let dir = TempDir::new().unwrap();
654 let config = test_config(dir.path(), "caps-regex");
655 let mut plugin = WasmPlugin::new(config);
656 plugin.load().unwrap();
657 let result = plugin
658 .execute(&serde_json::json!({"pattern": "\\d+\\w+\\s*"}))
659 .unwrap();
660 assert_eq!(result["status"], "executed");
661 }
662
663 #[test]
664 fn capability_enforcement_matches_shared_scan_for_input_matrix() {
665 let cases = vec![
666 serde_json::json!({}),
667 serde_json::json!({"endpoint": "https://example.com/v1"}),
668 serde_json::json!({"socket": "wss://example.com/stream"}),
669 serde_json::json!({"model": "openai/gpt-4o"}),
670 serde_json::json!({"model": "/etc/passwd"}),
671 serde_json::json!({"path": "src/main.rs"}),
672 serde_json::json!({"input": "secrets/config.yaml"}),
673 serde_json::json!({"pattern": "\\d+\\w+\\s*"}),
674 serde_json::json!({"env_var": "SECRET_TOKEN"}),
675 ];
676
677 for input in cases {
678 let scan = input_capability_scan::scan_input_capabilities(&input);
679 let mut required_caps = Vec::new();
680 if scan.requires_filesystem {
681 required_caps.push(WasmCapability::ReadFilesystem);
682 }
683 if scan.requires_network {
684 required_caps.push(WasmCapability::Network);
685 }
686 if scan.requires_environment {
687 required_caps.push(WasmCapability::Environment);
688 }
689
690 let no_caps = plugin_with_capabilities(vec![]);
691 let no_caps_ok = no_caps.enforce_capabilities(&input).is_ok();
692 assert_eq!(
693 no_caps_ok,
694 required_caps.is_empty(),
695 "no-capability behavior mismatch for input: {input}"
696 );
697
698 let with_required = plugin_with_capabilities(required_caps);
699 assert!(
700 with_required.enforce_capabilities(&input).is_ok(),
701 "required-capability behavior mismatch for input: {input}"
702 );
703 }
704 }
705
706 #[test]
707 fn plugin_unload() {
708 let dir = TempDir::new().unwrap();
709 let config = test_config(dir.path(), "unload-test");
710 let mut plugin = WasmPlugin::new(config);
711 plugin.load().unwrap();
712 assert!(plugin.is_loaded());
713 plugin.unload();
714 assert!(!plugin.is_loaded());
715 assert!(plugin.engine.is_none());
716 assert!(plugin.module.is_none());
717 }
718
719 #[test]
720 fn plugin_no_entry_point() {
721 let dir = TempDir::new().unwrap();
722 let wasm_bytes =
723 wat::parse_str(r#"(module (func (export "other_fn") (result i32) i32.const 1))"#)
724 .unwrap();
725 let wasm_path = dir.path().join("no-entry.wasm");
726 fs::write(&wasm_path, wasm_bytes).unwrap();
727
728 let config = WasmPluginConfig {
729 name: "no-entry".to_string(),
730 wasm_path,
731 memory_limit_bytes: default_memory_limit(),
732 execution_timeout_ms: default_execution_timeout_ms(),
733 capabilities: vec![],
734 };
735 let mut plugin = WasmPlugin::new(config);
736 plugin.load().unwrap();
737
738 let result = plugin.execute(&serde_json::json!({})).unwrap();
739 assert_eq!(result["status"], "no_entry_point");
740 let exports = result["available_exports"].as_array().unwrap();
741 assert!(exports.iter().any(|e| e == "other_fn"));
742 }
743
744 #[test]
745 fn plugin_start_entry_point() {
746 let dir = TempDir::new().unwrap();
747 let wasm_bytes = wat::parse_str(r#"(module (func (export "_start") nop))"#).unwrap();
748 let wasm_path = dir.path().join("start.wasm");
749 fs::write(&wasm_path, wasm_bytes).unwrap();
750
751 let config = WasmPluginConfig {
752 name: "start".to_string(),
753 wasm_path,
754 memory_limit_bytes: default_memory_limit(),
755 execution_timeout_ms: default_execution_timeout_ms(),
756 capabilities: vec![],
757 };
758 let mut plugin = WasmPlugin::new(config);
759 plugin.load().unwrap();
760
761 let result = plugin.execute(&serde_json::json!({})).unwrap();
762 assert_eq!(result["status"], "executed");
763 assert_eq!(result["plugin"], "start");
764 }
765
766 #[test]
767 fn registry_register_and_list() {
768 let dir = TempDir::new().unwrap();
769 let mut reg = WasmPluginRegistry::new();
770 reg.register(test_config(dir.path(), "a")).unwrap();
771 reg.register(test_config(dir.path(), "b")).unwrap();
772 assert_eq!(reg.total_count(), 2);
773 assert_eq!(reg.loaded_count(), 0);
774 }
775
776 #[test]
777 fn registry_load_and_execute() {
778 let dir = TempDir::new().unwrap();
779 let mut reg = WasmPluginRegistry::new();
780 reg.register(test_config(dir.path(), "plugin")).unwrap();
781 reg.load_plugin("plugin").unwrap();
782 assert_eq!(reg.loaded_count(), 1);
783
784 let result = reg
785 .execute("plugin", &serde_json::json!({"q": "test"}))
786 .unwrap();
787 assert_eq!(result["status"], "executed");
788 assert_eq!(result["result"], 42);
789 }
790
791 #[test]
792 fn registry_execute_unknown() {
793 let mut reg = WasmPluginRegistry::new();
794 assert!(reg.execute("nope", &serde_json::json!({})).is_err());
795 }
796
797 #[test]
798 fn registry_unload_all() {
799 let dir = TempDir::new().unwrap();
800 let mut reg = WasmPluginRegistry::new();
801 reg.register(test_config(dir.path(), "a")).unwrap();
802 reg.register(test_config(dir.path(), "b")).unwrap();
803 reg.load_plugin("a").unwrap();
804 reg.load_plugin("b").unwrap();
805 assert_eq!(reg.loaded_count(), 2);
806 reg.unload_all();
807 assert_eq!(reg.loaded_count(), 0);
808 }
809
810 #[test]
811 fn config_serde() {
812 let config = WasmPluginConfig {
813 name: "test".to_string(),
814 wasm_path: PathBuf::from("/tmp/test.wasm"),
815 memory_limit_bytes: 1024,
816 execution_timeout_ms: 5000,
817 capabilities: vec![WasmCapability::Network],
818 };
819 let json = serde_json::to_string(&config).unwrap();
820 let back: WasmPluginConfig = serde_json::from_str(&json).unwrap();
821 assert_eq!(back.name, "test");
822 assert_eq!(back.capabilities, vec![WasmCapability::Network]);
823 }
824
825 fn wasm_bytes_memory_json() -> Vec<u8> {
830 wat::parse_str(
831 r#"(module
832 (memory (export "memory") 1)
833 (data (i32.const 4096) "{\"ok\":true}")
834 (func (export "process") (result i32 i32)
835 i32.const 4096 ;; ptr (beyond input write zone)
836 i32.const 11 ;; len of {"ok":true}
837 )
838 )"#,
839 )
840 .unwrap()
841 }
842
843 fn wasm_bytes_memory_text() -> Vec<u8> {
846 wat::parse_str(
847 r#"(module
848 (memory (export "memory") 1)
849 (data (i32.const 4096) "hello")
850 (func (export "process") (result i32 i32)
851 i32.const 4096 ;; ptr (beyond input write zone)
852 i32.const 5 ;; len
853 )
854 )"#,
855 )
856 .unwrap()
857 }
858
859 fn wasm_bytes_memory_oob() -> Vec<u8> {
861 wat::parse_str(
862 r#"(module
863 (memory (export "memory") 1)
864 (func (export "process") (result i32 i32)
865 i32.const 0
866 i32.const 99999 ;; len far exceeds 1 page (65536 bytes)
867 )
868 )"#,
869 )
870 .unwrap()
871 }
872
873 fn wasm_bytes_memory_single_return() -> Vec<u8> {
876 wat::parse_str(
877 r#"(module
878 (memory (export "memory") 1)
879 (func (export "process") (result i32)
880 i32.const 99
881 )
882 )"#,
883 )
884 .unwrap()
885 }
886
887 fn wasm_bytes_multi_return() -> Vec<u8> {
889 wat::parse_str(
890 r#"(module
891 (func (export "process") (result i32 i32 i32)
892 i32.const 1
893 i32.const 2
894 i32.const 3
895 )
896 )"#,
897 )
898 .unwrap()
899 }
900
901 fn wasm_bytes_void_return() -> Vec<u8> {
903 wat::parse_str(
904 r#"(module
905 (func (export "process") nop)
906 )"#,
907 )
908 .unwrap()
909 }
910
911 #[test]
912 fn execute_memory_json_output() {
913 let dir = TempDir::new().unwrap();
914 let wasm_path = dir.path().join("mem-json.wasm");
915 fs::write(&wasm_path, wasm_bytes_memory_json()).unwrap();
916
917 let config = WasmPluginConfig {
918 name: "mem-json".into(),
919 wasm_path,
920 memory_limit_bytes: default_memory_limit(),
921 execution_timeout_ms: default_execution_timeout_ms(),
922 capabilities: vec![],
923 };
924 let mut plugin = WasmPlugin::new(config);
925 plugin.load().unwrap();
926
927 let result = plugin.execute(&serde_json::json!({})).unwrap();
928 assert_eq!(result["status"], "executed");
929 assert_eq!(result["plugin"], "mem-json");
930 assert_eq!(result["output"]["ok"], true);
932 }
933
934 #[test]
935 fn execute_memory_text_output() {
936 let dir = TempDir::new().unwrap();
937 let wasm_path = dir.path().join("mem-text.wasm");
938 fs::write(&wasm_path, wasm_bytes_memory_text()).unwrap();
939
940 let config = WasmPluginConfig {
941 name: "mem-text".into(),
942 wasm_path,
943 memory_limit_bytes: default_memory_limit(),
944 execution_timeout_ms: default_execution_timeout_ms(),
945 capabilities: vec![],
946 };
947 let mut plugin = WasmPlugin::new(config);
948 plugin.load().unwrap();
949
950 let result = plugin.execute(&serde_json::json!({})).unwrap();
951 assert_eq!(result["status"], "executed");
952 assert_eq!(result["output"], "hello");
953 }
954
955 #[test]
956 fn execute_memory_out_of_bounds() {
957 let dir = TempDir::new().unwrap();
958 let wasm_path = dir.path().join("mem-oob.wasm");
959 fs::write(&wasm_path, wasm_bytes_memory_oob()).unwrap();
960
961 let config = WasmPluginConfig {
962 name: "mem-oob".into(),
963 wasm_path,
964 memory_limit_bytes: default_memory_limit(),
965 execution_timeout_ms: default_execution_timeout_ms(),
966 capabilities: vec![],
967 };
968 let mut plugin = WasmPlugin::new(config);
969 plugin.load().unwrap();
970
971 let err = plugin.execute(&serde_json::json!({})).unwrap_err();
972 assert!(
973 err.to_string().contains("out of bounds"),
974 "expected out-of-bounds error, got: {err}"
975 );
976 }
977
978 #[test]
979 fn execute_memory_single_return_with_exported_memory() {
980 let dir = TempDir::new().unwrap();
981 let wasm_path = dir.path().join("mem-single.wasm");
982 fs::write(&wasm_path, wasm_bytes_memory_single_return()).unwrap();
983
984 let config = WasmPluginConfig {
985 name: "mem-single".into(),
986 wasm_path,
987 memory_limit_bytes: default_memory_limit(),
988 execution_timeout_ms: default_execution_timeout_ms(),
989 capabilities: vec![],
990 };
991 let mut plugin = WasmPlugin::new(config);
992 plugin.load().unwrap();
993
994 let result = plugin.execute(&serde_json::json!({})).unwrap();
995 assert_eq!(result["status"], "executed");
996 assert_eq!(result["result"], 99);
998 }
999
1000 #[test]
1001 fn execute_multi_return_values() {
1002 let dir = TempDir::new().unwrap();
1003 let wasm_path = dir.path().join("multi.wasm");
1004 fs::write(&wasm_path, wasm_bytes_multi_return()).unwrap();
1005
1006 let config = WasmPluginConfig {
1007 name: "multi".into(),
1008 wasm_path,
1009 memory_limit_bytes: default_memory_limit(),
1010 execution_timeout_ms: default_execution_timeout_ms(),
1011 capabilities: vec![],
1012 };
1013 let mut plugin = WasmPlugin::new(config);
1014 plugin.load().unwrap();
1015
1016 let result = plugin.execute(&serde_json::json!({})).unwrap();
1017 assert_eq!(result["status"], "executed");
1018 let arr = result["result"].as_array().unwrap();
1020 assert_eq!(arr.len(), 3);
1021 assert_eq!(arr[0], 1);
1022 assert_eq!(arr[1], 2);
1023 assert_eq!(arr[2], 3);
1024 }
1025
1026 #[test]
1027 fn execute_void_return() {
1028 let dir = TempDir::new().unwrap();
1029 let wasm_path = dir.path().join("void.wasm");
1030 fs::write(&wasm_path, wasm_bytes_void_return()).unwrap();
1031
1032 let config = WasmPluginConfig {
1033 name: "void".into(),
1034 wasm_path,
1035 memory_limit_bytes: default_memory_limit(),
1036 execution_timeout_ms: default_execution_timeout_ms(),
1037 capabilities: vec![],
1038 };
1039 let mut plugin = WasmPlugin::new(config);
1040 plugin.load().unwrap();
1041
1042 let result = plugin.execute(&serde_json::json!({})).unwrap();
1043 assert_eq!(result["status"], "executed");
1044 assert!(result["result"].is_null());
1046 }
1047
1048 #[test]
1049 fn execute_writes_input_to_memory() {
1050 let dir = TempDir::new().unwrap();
1052 let wasm_path = dir.path().join("mem-write.wasm");
1053 fs::write(&wasm_path, wasm_bytes_memory_text()).unwrap();
1054
1055 let config = WasmPluginConfig {
1056 name: "mem-write".into(),
1057 wasm_path,
1058 memory_limit_bytes: default_memory_limit(),
1059 execution_timeout_ms: default_execution_timeout_ms(),
1060 capabilities: vec![],
1061 };
1062 let mut plugin = WasmPlugin::new(config);
1063 plugin.load().unwrap();
1064
1065 let big_input = serde_json::json!({"data": "x".repeat(100)});
1067 let result = plugin.execute(&big_input).unwrap();
1068 assert_eq!(result["status"], "executed");
1069 }
1070
1071 #[test]
1074 fn wasmer_value_to_json_i32() {
1075 let v = wasmer::Value::I32(42);
1076 assert_eq!(wasmer_value_to_json(&v), serde_json::json!(42));
1077 }
1078
1079 #[test]
1080 fn wasmer_value_to_json_i64() {
1081 let v = wasmer::Value::I64(9_999_999_999);
1082 assert_eq!(
1083 wasmer_value_to_json(&v),
1084 serde_json::json!(9_999_999_999i64)
1085 );
1086 }
1087
1088 #[test]
1089 fn wasmer_value_to_json_f32() {
1090 let v = wasmer::Value::F32(1.5);
1091 let json = wasmer_value_to_json(&v);
1092 assert!(json.is_number());
1093 let n = json.as_f64().unwrap();
1094 assert!((n - 1.5).abs() < 0.01);
1095 }
1096
1097 #[test]
1098 fn wasmer_value_to_json_f64() {
1099 let v = wasmer::Value::F64(1.23456);
1100 let json = wasmer_value_to_json(&v);
1101 let n = json.as_f64().unwrap();
1102 assert!((n - 1.23456).abs() < 0.001);
1103 }
1104
1105 #[test]
1108 fn enforce_capabilities_explicit_read_filesystem_aliases() {
1109 let dir = TempDir::new().unwrap();
1110 let mut config = test_config(dir.path(), "cap-explicit");
1111 config.capabilities = vec![WasmCapability::ReadFilesystem];
1112 let mut plugin = WasmPlugin::new(config);
1113 plugin.load().unwrap();
1114
1115 for alias in ["readfilesystem", "read_filesystem", "filesystem_read"] {
1116 let input = serde_json::json!({"required_capabilities": [alias]});
1117 assert!(
1118 plugin.execute(&input).is_ok(),
1119 "ReadFilesystem alias '{alias}' should be granted"
1120 );
1121 }
1122 }
1123
1124 #[test]
1125 fn enforce_capabilities_explicit_write_filesystem_aliases() {
1126 let dir = TempDir::new().unwrap();
1127 let mut config = test_config(dir.path(), "cap-write");
1128 config.capabilities = vec![WasmCapability::WriteFilesystem];
1129 let mut plugin = WasmPlugin::new(config);
1130 plugin.load().unwrap();
1131
1132 for alias in ["writefilesystem", "write_filesystem", "filesystem_write"] {
1133 let input = serde_json::json!({"required_capabilities": [alias]});
1134 assert!(
1135 plugin.execute(&input).is_ok(),
1136 "WriteFilesystem alias '{alias}' should be granted"
1137 );
1138 }
1139 }
1140
1141 #[test]
1142 fn enforce_capabilities_explicit_network() {
1143 let dir = TempDir::new().unwrap();
1144 let mut config = test_config(dir.path(), "cap-net");
1145 config.capabilities = vec![WasmCapability::Network];
1146 let mut plugin = WasmPlugin::new(config);
1147 plugin.load().unwrap();
1148
1149 let input = serde_json::json!({"required_capabilities": ["network"]});
1150 assert!(plugin.execute(&input).is_ok());
1151 }
1152
1153 #[test]
1154 fn enforce_capabilities_explicit_environment_aliases() {
1155 let dir = TempDir::new().unwrap();
1156 let mut config = test_config(dir.path(), "cap-env");
1157 config.capabilities = vec![WasmCapability::Environment];
1158 let mut plugin = WasmPlugin::new(config);
1159 plugin.load().unwrap();
1160
1161 for alias in ["environment", "env"] {
1162 let input = serde_json::json!({"required_capabilities": [alias]});
1163 assert!(
1164 plugin.execute(&input).is_ok(),
1165 "Environment alias '{alias}' should be granted"
1166 );
1167 }
1168 }
1169
1170 #[test]
1171 fn enforce_capabilities_explicit_unknown_ignored() {
1172 let dir = TempDir::new().unwrap();
1173 let config = test_config(dir.path(), "cap-unknown");
1174 let mut plugin = WasmPlugin::new(config);
1175 plugin.load().unwrap();
1176
1177 let input = serde_json::json!({"required_capabilities": ["nonexistent_capability"]});
1179 assert!(plugin.execute(&input).is_ok());
1180 }
1181
1182 #[test]
1183 fn enforce_capabilities_explicit_denied_without_grant() {
1184 let dir = TempDir::new().unwrap();
1185 let config = test_config(dir.path(), "cap-deny");
1187 let mut plugin = WasmPlugin::new(config);
1188 plugin.load().unwrap();
1189
1190 let input = serde_json::json!({"required_capabilities": ["network"]});
1191 let err = plugin.execute(&input).unwrap_err();
1192 assert!(err.to_string().contains("missing required WASM capability"));
1193 }
1194
1195 #[test]
1196 fn enforce_capabilities_deduplicates() {
1197 let dir = TempDir::new().unwrap();
1198 let mut config = test_config(dir.path(), "cap-dedup");
1199 config.capabilities = vec![WasmCapability::Network];
1200 let mut plugin = WasmPlugin::new(config);
1201 plugin.load().unwrap();
1202
1203 let input = serde_json::json!({
1205 "required_capabilities": ["network", "network"],
1206 "url": "https://example.com"
1207 });
1208 assert!(plugin.execute(&input).is_ok());
1209 }
1210
1211 #[test]
1214 fn load_rejects_oversized_memory() {
1215 let dir = TempDir::new().unwrap();
1216 let wasm = wat::parse_str(
1218 r#"(module (memory (export "memory") 256) (func (export "process") nop))"#,
1219 )
1220 .unwrap();
1221 let wasm_path = dir.path().join("big-mem.wasm");
1222 fs::write(&wasm_path, wasm).unwrap();
1223
1224 let config = WasmPluginConfig {
1225 name: "big-mem".into(),
1226 wasm_path,
1227 memory_limit_bytes: 1024 * 1024, execution_timeout_ms: default_execution_timeout_ms(),
1229 capabilities: vec![],
1230 };
1231 let mut plugin = WasmPlugin::new(config);
1232 let err = plugin.load().unwrap_err();
1233 assert!(
1234 err.to_string().contains("exceeds limit"),
1235 "expected memory limit error, got: {err}"
1236 );
1237 }
1238
1239 #[test]
1240 fn debug_impl_for_plugin() {
1241 let config = WasmPluginConfig {
1242 name: "debug-test".into(),
1243 wasm_path: PathBuf::from("/tmp/debug.wasm"),
1244 memory_limit_bytes: 1024,
1245 execution_timeout_ms: 5000,
1246 capabilities: vec![],
1247 };
1248 let plugin = WasmPlugin::new(config);
1249 let dbg = format!("{:?}", plugin);
1250 assert!(dbg.contains("debug-test"));
1251 assert!(dbg.contains("has_engine"));
1252 }
1253}