Skip to main content

roboticus_agent/
wasm.rs

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/// Configuration for a WASM plugin.
10#[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/// Capabilities granted to a WASM plugin (deny-by-default).
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub enum WasmCapability {
33    ReadFilesystem,
34    WriteFilesystem,
35    Network,
36    Environment,
37}
38
39/// Represents a loaded WASM plugin (the sandbox instance).
40pub 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    /// Load and compile the WASM module from disk.
81    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    /// Execute the plugin with JSON input, returning JSON output.
145    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        // Run WASM calls on a dedicated thread with a preemptive timeout.
198        // If the module loops forever, recv_timeout returns Err and we
199        // report failure instead of hanging the caller indefinitely.
200        // The orphaned thread continues (WASM is sandboxed — no host I/O),
201        // but the caller is immediately unblocked.
202
203        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/// Manages multiple WASM plugins.
431#[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    // ── Memory read/write path tests (BUG-079) ─────────────────────
826
827    /// WAT module that exports memory, stores JSON `{"ok":true}` at offset 4096
828    /// (beyond input write area), and returns (ptr=4096, len=11) from `process`.
829    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    /// WAT module that exports memory, stores plain text at offset 4096
844    /// (beyond input write area), and returns (ptr=4096, len=5).
845    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    /// WAT module returning (ptr, len) that goes out of bounds.
860    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    /// WAT module with memory that returns a single i32 — exercises the
874    /// "memory exists but not 2-value return" path.
875    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    /// WAT module returning 3 values — exercises the multi-result fallback path.
888    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    /// WAT module returning no values — exercises the 0-result path.
902    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        // The output should be parsed JSON: {"ok":true}
931        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        // Single return value should go to "result", not "output"
997        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        // No exported memory, 3 return values -> result is an array
1019        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        // 0 return values -> result is null
1045        assert!(result["result"].is_null());
1046    }
1047
1048    #[test]
1049    fn execute_writes_input_to_memory() {
1050        // Use a module with memory to confirm input write path is exercised
1051        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        // Execute with large-ish input; this exercises the memory write path
1066        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    // ── wasmer_value_to_json coverage ────────────────────────────────
1072
1073    #[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    // ── enforce_capabilities explicit capability parsing ─────────────
1106
1107    #[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        // Unknown capabilities in the array are silently ignored
1178        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        // No capabilities granted
1186        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        // Same capability requested via two aliases — should not cause double deny
1204        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    // ── Memory limit enforcement on load ─────────────────────────────
1212
1213    #[test]
1214    fn load_rejects_oversized_memory() {
1215        let dir = TempDir::new().unwrap();
1216        // Module requests 256 pages = 16 MB of memory
1217        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, // 1 MB limit
1228            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}