Skip to main content

gravityfile_plugin/wasm/
runtime.rs

1//! WASM runtime implementation using Extism.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use extism::{Manifest, Plugin, Wasm};
9
10use crate::config::{PluginConfig, PluginMetadata};
11use crate::hooks::{Hook, HookContext, HookResult};
12use crate::runtime::{BoxFuture, IsolatedContext, PluginHandle, PluginRuntime};
13use crate::sandbox::{Permission, SandboxConfig};
14use crate::types::{PluginError, PluginResult, Value};
15
16use super::isolate::WasmIsolatedContext;
17
18/// A loaded WASM plugin.
19struct LoadedWasmPlugin {
20    /// Plugin name/id.
21    name: String,
22
23    /// Plugin metadata.
24    metadata: PluginMetadata,
25
26    /// Hooks implemented by this plugin.
27    hooks: Vec<String>,
28
29    /// The Extism plugin instance.
30    plugin: Arc<Mutex<Plugin>>,
31}
32
33/// WASM plugin runtime.
34pub struct WasmRuntime {
35    /// Loaded plugins by handle.
36    plugins: HashMap<PluginHandle, LoadedWasmPlugin>,
37
38    /// Next plugin handle ID.
39    next_handle: usize,
40
41    /// Runtime configuration.
42    config: Option<PluginConfig>,
43
44    /// Sandbox configuration used to build restricted Extism manifests.
45    sandbox: SandboxConfig,
46
47    /// Whether the runtime has been initialized.
48    initialized: bool,
49}
50
51impl WasmRuntime {
52    /// Create a new WASM runtime.
53    pub fn new() -> PluginResult<Self> {
54        Ok(Self {
55            plugins: HashMap::new(),
56            next_handle: 0,
57            config: None,
58            sandbox: SandboxConfig::default(),
59            initialized: false,
60        })
61    }
62
63    /// Load a WASM plugin, applying sandbox restrictions.
64    ///
65    /// - `allow_wasi` is set only when the sandbox has Execute or Network permission,
66    ///   matching the principle that WASI grants syscall-level access.
67    /// - Filesystem paths from the sandbox are mapped into the Extism manifest.
68    /// - Memory and timeout limits are applied.
69    fn load_plugin_with_sandbox(wasm: Wasm, sandbox: &SandboxConfig) -> Result<Plugin, String> {
70        let allow_wasi = sandbox.has_permission(Permission::Execute)
71            || sandbox.has_permission(Permission::Network);
72
73        let mut manifest = Manifest::new([wasm]);
74
75        if sandbox.max_memory > 0 {
76            let pages = (sandbox.max_memory / (64 * 1024)).max(1) as u32;
77            manifest = manifest.with_memory_max(pages);
78        }
79
80        if sandbox.timeout_ms > 0 {
81            manifest = manifest.with_timeout(Duration::from_millis(sandbox.timeout_ms));
82        }
83
84        for path in &sandbox.allowed_read_paths {
85            if let Some(s) = path.to_str() {
86                manifest = manifest.with_allowed_path(s.to_string(), path);
87            }
88        }
89
90        for path in &sandbox.allowed_write_paths {
91            if let Some(s) = path.to_str()
92                && !sandbox.allowed_read_paths.contains(path)
93            {
94                manifest = manifest.with_allowed_path(s.to_string(), path);
95            }
96        }
97
98        Plugin::new(&manifest, [], allow_wasi).map_err(|e| e.to_string())
99    }
100}
101
102impl Default for WasmRuntime {
103    fn default() -> Self {
104        Self::new().expect("Failed to create WASM runtime")
105    }
106}
107
108impl PluginRuntime for WasmRuntime {
109    fn name(&self) -> &'static str {
110        "wasm"
111    }
112
113    fn file_extensions(&self) -> &'static [&'static str] {
114        &[".wasm"]
115    }
116
117    fn init(&mut self, config: &PluginConfig) -> PluginResult<()> {
118        if self.initialized {
119            return Ok(());
120        }
121
122        // Build a sandbox from plugin config settings.
123        self.sandbox = SandboxConfig {
124            timeout_ms: config.default_timeout_ms,
125            max_memory: config.max_memory_mb * 1024 * 1024,
126            allow_network: config.allow_network,
127            ..SandboxConfig::default()
128        };
129
130        self.config = Some(config.clone());
131        self.initialized = true;
132
133        Ok(())
134    }
135
136    fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle> {
137        let wasm = Wasm::file(source);
138
139        let plugin = Self::load_plugin_with_sandbox(wasm, &self.sandbox).map_err(|e| {
140            PluginError::LoadError {
141                name: id.to_string(),
142                message: e,
143            }
144        })?;
145
146        // Determine which hooks are available by checking exports
147        let mut hooks = vec![];
148        for hook_name in [
149            "on_navigate",
150            "on_drill_down",
151            "on_back",
152            "on_scan_start",
153            "on_scan_progress",
154            "on_scan_complete",
155            "on_delete_start",
156            "on_delete_complete",
157            "on_copy_start",
158            "on_copy_complete",
159            "on_move_start",
160            "on_move_complete",
161            "on_render",
162            "on_action",
163            "on_mode_change",
164            "on_startup",
165            "on_shutdown",
166        ] {
167            if plugin.function_exists(hook_name) {
168                hooks.push(hook_name.to_string());
169            }
170        }
171
172        let handle = PluginHandle::new(self.next_handle);
173        self.next_handle += 1;
174
175        let metadata = PluginMetadata {
176            name: id.to_string(),
177            runtime: "wasm".to_string(),
178            ..Default::default()
179        };
180
181        self.plugins.insert(
182            handle,
183            LoadedWasmPlugin {
184                name: id.to_string(),
185                metadata,
186                hooks,
187                plugin: Arc::new(Mutex::new(plugin)),
188            },
189        );
190
191        Ok(handle)
192    }
193
194    fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()> {
195        self.plugins.remove(&handle);
196        Ok(())
197    }
198
199    fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata> {
200        self.plugins.get(&handle).map(|p| &p.metadata)
201    }
202
203    fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool {
204        self.plugins
205            .get(&handle)
206            .map(|p| p.hooks.contains(&hook_name.to_string()))
207            .unwrap_or(false)
208    }
209
210    fn call_hook_sync(
211        &self,
212        handle: PluginHandle,
213        hook: &Hook,
214        _ctx: &HookContext,
215    ) -> PluginResult<HookResult> {
216        let plugin = self
217            .plugins
218            .get(&handle)
219            .ok_or_else(|| PluginError::NotFound {
220                path: std::path::PathBuf::new(),
221            })?;
222
223        let mut extism_plugin = plugin
224            .plugin
225            .lock()
226            .map_err(|_| PluginError::ExecutionError {
227                name: plugin.name.clone(),
228                message: "Mutex lock failed".to_string(),
229            })?;
230
231        let hook_name = hook.name();
232
233        let payload = serde_json::to_vec(hook).map_err(|e| PluginError::ExecutionError {
234            name: plugin.name.clone(),
235            message: e.to_string(),
236        })?;
237
238        let res = extism_plugin.call::<&[u8], &[u8]>(hook_name, &payload);
239
240        match res {
241            Ok(output) => {
242                if output.is_empty() {
243                    return Ok(HookResult::ok());
244                }
245
246                let result: HookResult =
247                    serde_json::from_slice(output).map_err(|e| PluginError::ExecutionError {
248                        name: plugin.name.clone(),
249                        message: format!("Failed to parse WASM output: {}", e),
250                    })?;
251
252                Ok(result)
253            }
254            Err(e) => {
255                tracing::warn!(
256                    plugin = %plugin.name,
257                    hook = %hook_name,
258                    error = %e,
259                    "WASM plugin hook failed"
260                );
261                Ok(HookResult::ok())
262            }
263        }
264    }
265
266    fn call_hook_async<'a>(
267        &'a self,
268        handle: PluginHandle,
269        hook: &'a Hook,
270        ctx: &'a HookContext,
271    ) -> BoxFuture<'a, PluginResult<HookResult>> {
272        // Run synchronous in a background task
273        Box::pin(async move { self.call_hook_sync(handle, hook, ctx) })
274    }
275
276    fn call_method<'a>(
277        &'a self,
278        handle: PluginHandle,
279        method: &'a str,
280        args: Vec<Value>,
281    ) -> BoxFuture<'a, PluginResult<Value>> {
282        Box::pin(async move {
283            let plugin = self
284                .plugins
285                .get(&handle)
286                .ok_or_else(|| PluginError::NotFound {
287                    path: std::path::PathBuf::new(),
288                })?;
289
290            let mut extism_plugin =
291                plugin
292                    .plugin
293                    .lock()
294                    .map_err(|_| PluginError::ExecutionError {
295                        name: plugin.name.clone(),
296                        message: "Mutex lock failed".to_string(),
297                    })?;
298
299            let payload = serde_json::to_vec(&args).map_err(|e| PluginError::ExecutionError {
300                name: plugin.name.clone(),
301                message: e.to_string(),
302            })?;
303
304            let res = extism_plugin
305                .call::<&[u8], &[u8]>(method, payload.as_slice())
306                .map_err(|e| PluginError::ExecutionError {
307                    name: plugin.name.clone(),
308                    message: e.to_string(),
309                })?;
310
311            if res.is_empty() {
312                return Ok(Value::Null);
313            }
314
315            let val: Value =
316                serde_json::from_slice(res).map_err(|e| PluginError::ExecutionError {
317                    name: plugin.name.clone(),
318                    message: format!("Failed to parse output: {}", e),
319                })?;
320
321            Ok(val)
322        })
323    }
324
325    fn create_isolated_context(
326        &self,
327        sandbox: &SandboxConfig,
328    ) -> PluginResult<Box<dyn IsolatedContext>> {
329        Ok(Box::new(WasmIsolatedContext::new(sandbox.clone())?))
330    }
331
332    fn loaded_plugins(&self) -> Vec<PluginHandle> {
333        self.plugins.keys().copied().collect()
334    }
335
336    fn shutdown(&mut self) -> PluginResult<()> {
337        self.plugins.clear();
338        Ok(())
339    }
340}