Skip to main content

rustant_plugins/
wasm_loader.rs

1//! WASM plugin loader — loads sandboxed plugins via wasmi.
2//!
3//! WASM plugins run in a sandboxed environment with capability-based permissions.
4
5use crate::{Plugin, PluginError, PluginMetadata, PluginToolDef};
6use async_trait::async_trait;
7use std::path::Path;
8
9/// Loader for WASM plugins.
10pub struct WasmPluginLoader;
11
12impl WasmPluginLoader {
13    /// Create a new WASM plugin loader.
14    pub fn new() -> Self {
15        Self
16    }
17
18    /// Load a WASM plugin from bytes.
19    pub fn load_from_bytes(
20        &self,
21        name: &str,
22        wasm_bytes: &[u8],
23    ) -> Result<Box<dyn Plugin>, PluginError> {
24        // Validate the WASM module
25        let engine = wasmi::Engine::default();
26        let module = wasmi::Module::new(&engine, wasm_bytes).map_err(|e| {
27            PluginError::LoadFailed(format!("Invalid WASM module '{}': {}", name, e))
28        })?;
29
30        Ok(Box::new(WasmPlugin {
31            name: name.into(),
32            engine,
33            module,
34            store: None,
35        }))
36    }
37
38    /// Load a WASM plugin from a file.
39    pub fn load_from_file(&self, path: &Path) -> Result<Box<dyn Plugin>, PluginError> {
40        let bytes = std::fs::read(path).map_err(|e| {
41            PluginError::LoadFailed(format!("Failed to read '{}': {}", path.display(), e))
42        })?;
43        let name = path
44            .file_stem()
45            .and_then(|s| s.to_str())
46            .unwrap_or("unknown");
47        self.load_from_bytes(name, &bytes)
48    }
49}
50
51impl Default for WasmPluginLoader {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57/// A WASM plugin loaded via wasmi.
58struct WasmPlugin {
59    name: String,
60    #[allow(dead_code)]
61    engine: wasmi::Engine,
62    #[allow(dead_code)]
63    module: wasmi::Module,
64    #[allow(dead_code)]
65    store: Option<wasmi::Store<()>>,
66}
67
68// Safety: WasmPlugin is single-threaded internally but we only access it under a mutex
69unsafe impl Send for WasmPlugin {}
70unsafe impl Sync for WasmPlugin {}
71
72#[async_trait]
73impl Plugin for WasmPlugin {
74    fn metadata(&self) -> PluginMetadata {
75        PluginMetadata {
76            name: self.name.clone(),
77            version: "0.1.0".into(),
78            description: format!("WASM plugin: {}", self.name),
79            author: None,
80            min_core_version: None,
81            capabilities: vec![],
82        }
83    }
84
85    async fn on_load(&mut self) -> Result<(), PluginError> {
86        // Create a store for the module
87        self.store = Some(wasmi::Store::new(&self.engine, ()));
88        tracing::info!(plugin = %self.name, "WASM plugin loaded");
89        Ok(())
90    }
91
92    async fn on_unload(&mut self) -> Result<(), PluginError> {
93        self.store = None;
94        tracing::info!(plugin = %self.name, "WASM plugin unloaded");
95        Ok(())
96    }
97
98    fn tools(&self) -> Vec<PluginToolDef> {
99        // WASM plugins can export tool definitions
100        // For now, return empty — full implementation would call exported functions
101        Vec::new()
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    // Minimal valid WASM module (empty module)
110    const MINIMAL_WASM: &[u8] = &[
111        0x00, 0x61, 0x73, 0x6d, // magic: \0asm
112        0x01, 0x00, 0x00, 0x00, // version: 1
113    ];
114
115    #[test]
116    fn test_wasm_loader_from_bytes() {
117        let loader = WasmPluginLoader::new();
118        let result = loader.load_from_bytes("test", MINIMAL_WASM);
119        assert!(result.is_ok());
120
121        let plugin = result.unwrap();
122        let meta = plugin.metadata();
123        assert_eq!(meta.name, "test");
124    }
125
126    #[test]
127    fn test_wasm_loader_invalid_bytes() {
128        let loader = WasmPluginLoader::new();
129        let result = loader.load_from_bytes("bad", &[0x00, 0x01, 0x02]);
130        assert!(result.is_err());
131    }
132
133    #[tokio::test]
134    async fn test_wasm_plugin_lifecycle() {
135        let loader = WasmPluginLoader::new();
136        let mut plugin = loader.load_from_bytes("lifecycle", MINIMAL_WASM).unwrap();
137
138        plugin.on_load().await.unwrap();
139        assert_eq!(plugin.metadata().name, "lifecycle");
140
141        let tools = plugin.tools();
142        assert!(tools.is_empty());
143
144        plugin.on_unload().await.unwrap();
145    }
146
147    #[test]
148    fn test_wasm_loader_from_file_not_found() {
149        let loader = WasmPluginLoader::new();
150        let result = loader.load_from_file(Path::new("/nonexistent/plugin.wasm"));
151        assert!(result.is_err());
152    }
153
154    #[test]
155    fn test_wasm_loader_from_file() {
156        let dir = tempfile::TempDir::new().unwrap();
157        let wasm_path = dir.path().join("test.wasm");
158        std::fs::write(&wasm_path, MINIMAL_WASM).unwrap();
159
160        let loader = WasmPluginLoader::new();
161        let result = loader.load_from_file(&wasm_path);
162        assert!(result.is_ok());
163    }
164}