Skip to main content

gravityfile_plugin/
runtime.rs

1//! Plugin runtime trait and manager.
2//!
3//! This module defines the language-agnostic [`PluginRuntime`] trait that
4//! all scripting language implementations must satisfy.
5
6use std::future::Future;
7use std::path::Path;
8use std::pin::Pin;
9
10use indexmap::IndexMap;
11
12/// All hook names recognized by the plugin system.
13///
14/// This is the single source of truth used by both `discover_plugins` and
15/// `load_plugin` when collecting the hooks a plugin implements.
16const HOOK_NAMES: &[&str] = &[
17    "on_navigate",
18    "on_drill_down",
19    "on_back",
20    "on_scan_start",
21    "on_scan_progress",
22    "on_scan_complete",
23    "on_delete_start",
24    "on_delete_complete",
25    "on_copy_start",
26    "on_copy_complete",
27    "on_move_start",
28    "on_move_complete",
29    "on_render",
30    "on_action",
31    "on_mode_change",
32    "on_startup",
33    "on_shutdown",
34];
35
36use tokio_util::sync::CancellationToken;
37
38use crate::config::{PluginConfig, PluginMetadata};
39use crate::hooks::{Hook, HookContext, HookResult};
40use crate::sandbox::SandboxConfig;
41use crate::types::{PluginError, PluginKind, PluginResult, Value};
42
43/// A handle to a loaded plugin.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct PluginHandle(pub(crate) usize);
46
47impl PluginHandle {
48    /// Create a new plugin handle.
49    pub(crate) fn new(id: usize) -> Self {
50        Self(id)
51    }
52
53    /// Get the raw ID.
54    pub fn id(&self) -> usize {
55        self.0
56    }
57}
58
59/// Type alias for boxed futures returned by async plugin methods.
60pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
61
62/// Trait that all plugin runtime implementations must satisfy.
63///
64/// This trait abstracts over the specific scripting language (Lua, Rhai, WASM)
65/// allowing plugins to be written in any supported language.
66pub trait PluginRuntime: Send + Sync {
67    /// Get the name of this runtime (e.g., "lua", "rhai", "wasm").
68    fn name(&self) -> &'static str;
69
70    /// Get the file extensions this runtime handles (e.g., [".lua"]).
71    fn file_extensions(&self) -> &'static [&'static str];
72
73    /// Initialize the runtime with configuration.
74    fn init(&mut self, config: &PluginConfig) -> PluginResult<()>;
75
76    /// Load a plugin from a file path.
77    ///
78    /// Returns a handle that can be used to interact with the plugin.
79    fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle>;
80
81    /// Unload a previously loaded plugin.
82    fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()>;
83
84    /// Get metadata about a loaded plugin.
85    fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata>;
86
87    /// Check if a plugin implements a specific hook.
88    fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool;
89
90    /// Call a hook on a plugin synchronously.
91    ///
92    /// Used for hooks that must complete immediately (e.g., render hooks).
93    fn call_hook_sync(
94        &self,
95        handle: PluginHandle,
96        hook: &Hook,
97        ctx: &HookContext,
98    ) -> PluginResult<HookResult>;
99
100    /// Call a hook on a plugin asynchronously.
101    ///
102    /// Used for hooks that may take time (e.g., scan complete hooks).
103    fn call_hook_async<'a>(
104        &'a self,
105        handle: PluginHandle,
106        hook: &'a Hook,
107        ctx: &'a HookContext,
108    ) -> BoxFuture<'a, PluginResult<HookResult>>;
109
110    /// Call an arbitrary method on a plugin.
111    fn call_method<'a>(
112        &'a self,
113        handle: PluginHandle,
114        method: &'a str,
115        args: Vec<Value>,
116    ) -> BoxFuture<'a, PluginResult<Value>>;
117
118    /// Create an isolated context for running async plugin code.
119    ///
120    /// Isolated contexts have limited API access and their own Lua/Rhai state,
121    /// making them safe to run in background tasks.
122    fn create_isolated_context(
123        &self,
124        sandbox: &SandboxConfig,
125    ) -> PluginResult<Box<dyn IsolatedContext>>;
126
127    /// Get the list of loaded plugin handles.
128    fn loaded_plugins(&self) -> Vec<PluginHandle>;
129
130    /// Shutdown the runtime and cleanup resources.
131    fn shutdown(&mut self) -> PluginResult<()>;
132}
133
134/// An isolated execution context for running plugin code safely.
135///
136/// Isolated contexts are used for async plugins (previewers, analyzers) that
137/// run in background tasks. They have:
138/// - Their own script state (not shared with main runtime)
139/// - Limited API access (no UI modification)
140/// - Cancellation support
141/// - Resource limits
142pub trait IsolatedContext: Send {
143    /// Execute a chunk of code in this isolated context.
144    fn execute<'a>(
145        &'a self,
146        code: &'a [u8],
147        cancel: CancellationToken,
148    ) -> BoxFuture<'a, PluginResult<Value>>;
149
150    /// Execute a named function with arguments.
151    fn call_function<'a>(
152        &'a self,
153        name: &'a str,
154        args: Vec<Value>,
155        cancel: CancellationToken,
156    ) -> BoxFuture<'a, PluginResult<Value>>;
157
158    /// Set a global variable in this context.
159    fn set_global(&mut self, name: &str, value: Value) -> PluginResult<()>;
160
161    /// Get a global variable from this context.
162    fn get_global(&self, name: &str) -> PluginResult<Value>;
163}
164
165/// Information about a loaded plugin.
166#[derive(Debug, Clone)]
167pub struct LoadedPlugin {
168    /// Unique handle for this plugin.
169    pub handle: PluginHandle,
170
171    /// Plugin ID (usually directory name).
172    pub id: String,
173
174    /// Plugin metadata from plugin.toml.
175    pub metadata: PluginMetadata,
176
177    /// Path to the plugin directory.
178    pub path: std::path::PathBuf,
179
180    /// Hooks this plugin implements.
181    pub hooks: Vec<String>,
182}
183
184/// Manager for all plugin runtimes and loaded plugins.
185pub struct PluginManager {
186    /// Available runtimes keyed by name.
187    runtimes: IndexMap<String, Box<dyn PluginRuntime>>,
188
189    /// Loaded plugins keyed by handle, insertion-ordered for deterministic dispatch.
190    plugins: IndexMap<PluginHandle, LoadedPlugin>,
191
192    /// Plugin config.
193    config: PluginConfig,
194
195    /// Next plugin handle ID (reserved for future use).
196    #[allow(dead_code)]
197    next_handle: usize,
198}
199
200impl PluginManager {
201    /// Validate a plugin name against the allowed character set.
202    ///
203    /// Names must be 1–64 characters long and match `[a-zA-Z0-9_-]+`.
204    fn validate_plugin_name(name: &str) -> PluginResult<()> {
205        if name.is_empty() || name.len() > 64 {
206            return Err(PluginError::ConfigError {
207                message: format!("Plugin name '{}' must be 1–64 characters long", name),
208            });
209        }
210        if !name
211            .chars()
212            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
213        {
214            return Err(PluginError::ConfigError {
215                message: format!(
216                    "Plugin name '{}' contains invalid characters (allowed: [a-zA-Z0-9_-])",
217                    name
218                ),
219            });
220        }
221        Ok(())
222    }
223
224    /// Create a new plugin manager with the given configuration.
225    pub fn new(config: PluginConfig) -> Self {
226        Self {
227            runtimes: IndexMap::new(),
228            plugins: IndexMap::new(),
229            config,
230            next_handle: 0,
231        }
232    }
233
234    /// Register a plugin runtime.
235    pub fn register_runtime(&mut self, runtime: Box<dyn PluginRuntime>) -> PluginResult<()> {
236        let name = runtime.name().to_string();
237        self.runtimes.insert(name, runtime);
238        Ok(())
239    }
240
241    /// Get a runtime by name.
242    pub fn get_runtime(&self, name: &str) -> Option<&dyn PluginRuntime> {
243        self.runtimes.get(name).map(|r| r.as_ref())
244    }
245
246    /// Get a mutable runtime by name.
247    pub fn get_runtime_mut(&mut self, name: &str) -> Option<&mut Box<dyn PluginRuntime>> {
248        self.runtimes.get_mut(name)
249    }
250
251    /// Initialize all registered runtimes.
252    pub fn init_runtimes(&mut self) -> PluginResult<()> {
253        for runtime in self.runtimes.values_mut() {
254            runtime.init(&self.config)?;
255        }
256        Ok(())
257    }
258
259    /// Discover and load plugins from the plugin directory.
260    pub async fn discover_plugins(&mut self) -> PluginResult<Vec<LoadedPlugin>> {
261        let plugin_dir = self.config.plugin_dir.clone();
262        if !plugin_dir.exists() {
263            return Ok(vec![]);
264        }
265
266        let mut loaded = vec![];
267
268        // Read plugin directories
269        let entries = std::fs::read_dir(&plugin_dir).map_err(PluginError::Io)?;
270
271        for entry in entries.flatten() {
272            let path = entry.path();
273            if !path.is_dir() {
274                continue;
275            }
276
277            // Look for plugin.toml
278            let toml_path = path.join("plugin.toml");
279            if !toml_path.exists() {
280                continue;
281            }
282
283            // Parse metadata
284            let toml_content = std::fs::read_to_string(&toml_path)?;
285            let metadata: PluginMetadata =
286                toml::from_str(&toml_content).map_err(|e| PluginError::ConfigError {
287                    message: e.to_string(),
288                })?;
289
290            // Validate plugin name to prevent injection or path-manipulation attacks
291            if let Err(e) = Self::validate_plugin_name(&metadata.name) {
292                tracing::warn!(
293                    path = %path.display(),
294                    error = %e,
295                    "Skipping plugin with invalid name"
296                );
297                continue;
298            }
299
300            // Find the appropriate runtime
301            let runtime_name = &metadata.runtime;
302            let runtime = self.runtimes.get_mut(runtime_name).ok_or_else(|| {
303                PluginError::RuntimeNotAvailable {
304                    runtime: runtime_name.clone(),
305                }
306            })?;
307
308            // Find the entry point file and verify it stays within the plugin dir
309            let entry_file = path.join(&metadata.entry);
310            let canonical_plugin_dir = match std::fs::canonicalize(&path) {
311                Ok(c) => c,
312                Err(_) => continue,
313            };
314            let canonical_entry = match std::fs::canonicalize(&entry_file) {
315                Ok(c) => c,
316                Err(_) => continue,
317            };
318            if !canonical_entry.starts_with(&canonical_plugin_dir) {
319                tracing::warn!(
320                    plugin = %metadata.name,
321                    entry = %metadata.entry,
322                    "Skipping plugin: entry point escapes plugin directory"
323                );
324                continue;
325            }
326            if !entry_file.exists() {
327                continue;
328            }
329
330            // Load the plugin
331            let handle = runtime.load_plugin(&metadata.name, &entry_file)?;
332
333            // Collect hooks
334            let hooks: Vec<String> = HOOK_NAMES
335                .iter()
336                .filter(|h| runtime.has_hook(handle, h))
337                .map(|s| s.to_string())
338                .collect();
339
340            let loaded_plugin = LoadedPlugin {
341                handle,
342                id: metadata.name.clone(),
343                metadata,
344                path: path.clone(),
345                hooks,
346            };
347
348            self.plugins.insert(handle, loaded_plugin.clone());
349            loaded.push(loaded_plugin);
350        }
351
352        Ok(loaded)
353    }
354
355    /// Load a single plugin from a path.
356    pub fn load_plugin(&mut self, path: &Path) -> PluginResult<LoadedPlugin> {
357        // Parse metadata
358        let toml_path = path.join("plugin.toml");
359        let toml_content = std::fs::read_to_string(&toml_path)?;
360        let metadata: PluginMetadata =
361            toml::from_str(&toml_content).map_err(|e| PluginError::ConfigError {
362                message: e.to_string(),
363            })?;
364
365        // Validate plugin name
366        Self::validate_plugin_name(&metadata.name)?;
367
368        // Find runtime
369        let runtime = self.runtimes.get_mut(&metadata.runtime).ok_or_else(|| {
370            PluginError::RuntimeNotAvailable {
371                runtime: metadata.runtime.clone(),
372            }
373        })?;
374
375        // Verify entry point stays within the plugin directory (path traversal guard)
376        let entry_file = path.join(&metadata.entry);
377        let canonical_plugin_dir = std::fs::canonicalize(path).map_err(PluginError::Io)?;
378        let canonical_entry = std::fs::canonicalize(&entry_file).map_err(PluginError::Io)?;
379        if !canonical_entry.starts_with(&canonical_plugin_dir) {
380            return Err(PluginError::ConfigError {
381                message: format!(
382                    "Plugin '{}': entry point '{}' escapes the plugin directory",
383                    metadata.name, metadata.entry
384                ),
385            });
386        }
387
388        // Load plugin
389        let handle = runtime.load_plugin(&metadata.name, &entry_file)?;
390
391        // Collect hooks
392        let hooks: Vec<String> = HOOK_NAMES
393            .iter()
394            .filter(|h| runtime.has_hook(handle, h))
395            .map(|s| s.to_string())
396            .collect();
397
398        let loaded_plugin = LoadedPlugin {
399            handle,
400            id: metadata.name.clone(),
401            metadata,
402            path: path.to_path_buf(),
403            hooks,
404        };
405
406        self.plugins.insert(handle, loaded_plugin.clone());
407        Ok(loaded_plugin)
408    }
409
410    /// Unload a plugin.
411    pub fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()> {
412        if let Some(plugin) = self.plugins.shift_remove(&handle)
413            && let Some(runtime) = self.runtimes.get_mut(&plugin.metadata.runtime)
414        {
415            runtime.unload_plugin(handle)?;
416        }
417        Ok(())
418    }
419
420    /// Dispatch a hook to all plugins that implement it.
421    pub async fn dispatch_hook(&self, hook: &Hook, ctx: &HookContext) -> Vec<HookResult> {
422        let hook_name = hook.name();
423        let mut results = vec![];
424
425        for (handle, plugin) in &self.plugins {
426            if !plugin.hooks.contains(&hook_name.to_string()) {
427                continue;
428            }
429
430            if let Some(runtime) = self.runtimes.get(&plugin.metadata.runtime) {
431                let result = if hook.is_sync() {
432                    runtime.call_hook_sync(*handle, hook, ctx)
433                } else {
434                    runtime.call_hook_async(*handle, hook, ctx).await
435                };
436
437                match result {
438                    Ok(r) => {
439                        results.push(r.clone());
440                        if r.stop_propagation {
441                            break;
442                        }
443                    }
444                    Err(e) => {
445                        // Log error but continue to other plugins
446                        tracing::warn!(
447                            plugin_id = %plugin.id,
448                            error = %e,
449                            "Plugin hook dispatch error"
450                        );
451                    }
452                }
453            }
454        }
455
456        results
457    }
458
459    /// Get all loaded plugins.
460    pub fn plugins(&self) -> impl Iterator<Item = &LoadedPlugin> {
461        self.plugins.values()
462    }
463
464    /// Get a loaded plugin by handle.
465    pub fn get_plugin(&self, handle: PluginHandle) -> Option<&LoadedPlugin> {
466        self.plugins.get(&handle)
467    }
468
469    /// Get plugins of a specific kind.
470    pub fn plugins_of_kind(&self, kind: PluginKind) -> impl Iterator<Item = &LoadedPlugin> {
471        self.plugins
472            .values()
473            .filter(move |p| p.metadata.kind == kind)
474    }
475
476    /// Shutdown all runtimes.
477    pub fn shutdown(&mut self) -> PluginResult<()> {
478        for runtime in self.runtimes.values_mut() {
479            runtime.shutdown()?;
480        }
481        self.plugins.clear();
482        Ok(())
483    }
484}
485
486impl Default for PluginManager {
487    fn default() -> Self {
488        Self::new(PluginConfig::default())
489    }
490}