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.
16pub(crate) const HOOK_NAMES: &[&str] = &[
17    "on_navigate",
18    "on_scan_start",
19    "on_scan_complete",
20    "on_scan_error",
21    "on_delete_start",
22    "on_delete_complete",
23    "on_copy_complete",
24    "on_move_complete",
25    "on_rename_complete",
26    "on_duplicates_found",
27    "on_age_analysis_complete",
28    "on_startup",
29    "on_shutdown",
30];
31
32use tokio_util::sync::CancellationToken;
33
34use crate::config::{PluginConfig, PluginMetadata};
35use crate::hooks::{Hook, HookContext, HookResult};
36use crate::sandbox::SandboxConfig;
37use crate::types::{PluginError, PluginKind, PluginResult, Value};
38
39/// Bound source loading independently of VM limits: source strings are allocated
40/// by Rust before either scripting engine can enforce its own memory budget.
41pub(crate) fn read_plugin_source(path: &Path) -> PluginResult<String> {
42    read_regular_utf8(path, 1024 * 1024, "plugin source")
43}
44
45fn read_plugin_manifest(path: &Path) -> PluginResult<PluginMetadata> {
46    let content = read_regular_utf8(path, 64 * 1024, "plugin manifest")?;
47    toml::from_str(&content).map_err(|error| PluginError::ConfigError {
48        message: error.to_string(),
49    })
50}
51
52fn read_regular_utf8(path: &Path, limit: u64, kind: &str) -> PluginResult<String> {
53    use std::io::Read;
54    let mut options = std::fs::OpenOptions::new();
55    options.read(true);
56    #[cfg(unix)]
57    {
58        use std::os::unix::fs::OpenOptionsExt;
59        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
60    }
61    let file = options.open(path)?;
62    let metadata = file.metadata()?;
63    if !metadata.is_file() || metadata.len() > limit {
64        return Err(PluginError::LoadError {
65            name: path.display().to_string(),
66            message: format!("{kind} must be a regular UTF-8 file of at most {limit} bytes"),
67        });
68    }
69    let mut source = String::new();
70    file.take(limit + 1).read_to_string(&mut source)?;
71    if source.len() as u64 > limit {
72        return Err(PluginError::LoadError {
73            name: path.display().to_string(),
74            message: format!("{kind} grew beyond {limit} bytes"),
75        });
76    }
77    Ok(source)
78}
79
80/// A handle to a loaded plugin.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub struct PluginHandle(pub(crate) usize);
83
84impl PluginHandle {
85    /// Create a new plugin handle.
86    pub(crate) fn new(id: usize) -> Self {
87        Self(id)
88    }
89
90    /// Get the raw ID.
91    pub fn id(&self) -> usize {
92        self.0
93    }
94}
95
96/// Type alias for boxed futures returned by async plugin methods.
97pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
98
99/// Trait that all plugin runtime implementations must satisfy.
100///
101/// Implemented by the in-process Lua engine and by the worker-process
102/// runtime that hosts it.
103pub trait PluginRuntime: Send + Sync {
104    /// Get the name of this runtime (e.g. "lua").
105    fn name(&self) -> &'static str;
106
107    /// Get the file extensions this runtime handles (e.g., [".lua"]).
108    fn file_extensions(&self) -> &'static [&'static str];
109
110    /// Initialize the runtime with configuration.
111    fn init(&mut self, config: &PluginConfig) -> PluginResult<()>;
112
113    /// Load a plugin from a file path.
114    ///
115    /// Returns a handle that can be used to interact with the plugin.
116    fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle>;
117
118    /// Unload a previously loaded plugin.
119    fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()>;
120
121    /// Get metadata about a loaded plugin.
122    fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata>;
123
124    /// Check if a plugin implements a specific hook.
125    fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool;
126
127    /// Call a hook on a plugin synchronously.
128    ///
129    /// Used for hooks that must complete immediately (e.g., render hooks).
130    fn call_hook_sync(
131        &self,
132        handle: PluginHandle,
133        hook: &Hook,
134        ctx: &HookContext,
135    ) -> PluginResult<HookResult>;
136
137    /// Call a hook on a plugin asynchronously.
138    ///
139    /// Used for hooks that may take time (e.g., scan complete hooks).
140    fn call_hook_async<'a>(
141        &'a self,
142        handle: PluginHandle,
143        hook: &'a Hook,
144        ctx: &'a HookContext,
145    ) -> BoxFuture<'a, PluginResult<HookResult>>;
146
147    /// Call an arbitrary method on a plugin.
148    fn call_method<'a>(
149        &'a self,
150        handle: PluginHandle,
151        method: &'a str,
152        args: Vec<Value>,
153    ) -> BoxFuture<'a, PluginResult<Value>>;
154
155    /// Create an isolated context for running async plugin code.
156    ///
157    /// Isolated contexts have limited API access and their own Lua state,
158    /// making them safe to run in background tasks.
159    fn create_isolated_context(
160        &self,
161        sandbox: &SandboxConfig,
162    ) -> PluginResult<Box<dyn IsolatedContext>>;
163
164    /// Get the list of loaded plugin handles.
165    fn loaded_plugins(&self) -> Vec<PluginHandle>;
166
167    /// Shutdown the runtime and cleanup resources.
168    fn shutdown(&mut self) -> PluginResult<()>;
169}
170
171/// An isolated execution context for running plugin code safely.
172///
173/// Isolated contexts are used for async plugins (previewers, analyzers) that
174/// run in background tasks. They have:
175/// - Their own script state (not shared with main runtime)
176/// - Limited API access (no UI modification)
177/// - Cancellation support
178/// - Resource limits
179pub trait IsolatedContext: Send {
180    /// Execute a chunk of code in this isolated context.
181    fn execute<'a>(
182        &'a self,
183        code: &'a [u8],
184        cancel: CancellationToken,
185    ) -> BoxFuture<'a, PluginResult<Value>>;
186
187    /// Execute a named function with arguments.
188    fn call_function<'a>(
189        &'a self,
190        name: &'a str,
191        args: Vec<Value>,
192        cancel: CancellationToken,
193    ) -> BoxFuture<'a, PluginResult<Value>>;
194
195    /// Set a global variable in this context.
196    fn set_global(&mut self, name: &str, value: Value) -> PluginResult<()>;
197
198    /// Get a global variable from this context.
199    fn get_global(&self, name: &str) -> PluginResult<Value>;
200}
201
202/// Information about a loaded plugin.
203#[derive(Debug, Clone)]
204pub struct LoadedPlugin {
205    /// Manager-global handle for this plugin (unique across all runtimes).
206    pub handle: PluginHandle,
207
208    /// Runtime-local handle, used to call back into the owning runtime.
209    /// Distinct from `handle` because each runtime numbers its plugins from 0,
210    /// so two runtimes can hand out the same local handle.
211    pub(crate) runtime_handle: PluginHandle,
212
213    /// Plugin ID (usually directory name).
214    pub id: String,
215
216    /// Plugin metadata from plugin.toml.
217    pub metadata: PluginMetadata,
218
219    /// Path to the plugin directory.
220    pub path: std::path::PathBuf,
221
222    /// Hooks this plugin implements.
223    pub hooks: Vec<String>,
224}
225
226/// Manager for all plugin runtimes and loaded plugins.
227pub struct PluginManager {
228    /// Available runtimes keyed by name.
229    runtimes: IndexMap<String, Box<dyn PluginRuntime>>,
230
231    /// Loaded plugins keyed by handle, insertion-ordered for deterministic dispatch.
232    plugins: IndexMap<PluginHandle, LoadedPlugin>,
233
234    /// Plugin config.
235    config: PluginConfig,
236
237    /// Next manager-global plugin handle ID.
238    next_handle: usize,
239}
240
241impl PluginManager {
242    /// Validate a plugin name against the allowed character set.
243    ///
244    /// Names must be 1–64 characters long and match `[a-zA-Z0-9_-]+`.
245    fn validate_plugin_name(name: &str) -> PluginResult<()> {
246        if name.is_empty() || name.len() > 64 {
247            return Err(PluginError::ConfigError {
248                message: format!("Plugin name '{}' must be 1–64 characters long", name),
249            });
250        }
251        if !name
252            .chars()
253            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
254        {
255            return Err(PluginError::ConfigError {
256                message: format!(
257                    "Plugin name '{}' contains invalid characters (allowed: [a-zA-Z0-9_-])",
258                    name
259                ),
260            });
261        }
262        Ok(())
263    }
264
265    /// Create a new plugin manager with the given configuration.
266    pub fn new(config: PluginConfig) -> Self {
267        Self {
268            runtimes: IndexMap::new(),
269            plugins: IndexMap::new(),
270            config,
271            next_handle: 0,
272        }
273    }
274
275    fn validate_load_policy(&self, name: &str) -> PluginResult<()> {
276        if self.config.is_disabled(name) {
277            return Err(PluginError::ConfigError {
278                message: format!("Plugin '{name}' is disabled"),
279            });
280        }
281        if self.plugins.values().any(|plugin| plugin.id == name) {
282            return Err(PluginError::ConfigError {
283                message: format!("Plugin '{name}' is already loaded"),
284            });
285        }
286        Ok(())
287    }
288
289    /// Allocate a fresh manager-global plugin handle.
290    fn alloc_handle(&mut self) -> PluginHandle {
291        let handle = PluginHandle(self.next_handle);
292        self.next_handle += 1;
293        handle
294    }
295
296    /// Register a plugin runtime.
297    pub fn register_runtime(&mut self, runtime: Box<dyn PluginRuntime>) -> PluginResult<()> {
298        let name = runtime.name().to_string();
299        if self.runtimes.contains_key(&name) {
300            return Err(PluginError::ConfigError {
301                message: format!("Runtime '{name}' is already registered"),
302            });
303        }
304        self.runtimes.insert(name, runtime);
305        Ok(())
306    }
307
308    /// Get a runtime by name.
309    pub fn get_runtime(&self, name: &str) -> Option<&dyn PluginRuntime> {
310        self.runtimes.get(name).map(|r| r.as_ref())
311    }
312
313    /// Get a mutable runtime by name.
314    pub fn get_runtime_mut(&mut self, name: &str) -> Option<&mut Box<dyn PluginRuntime>> {
315        self.runtimes.get_mut(name)
316    }
317
318    /// Initialize all registered runtimes.
319    pub fn init_runtimes(&mut self) -> PluginResult<()> {
320        for runtime in self.runtimes.values_mut() {
321            runtime.init(&self.config)?;
322        }
323        Ok(())
324    }
325
326    /// Discover and load plugins from the plugin directory.
327    pub async fn discover_plugins(&mut self) -> PluginResult<Vec<LoadedPlugin>> {
328        // Respect the global enable switch — when off, the subsystem does nothing.
329        if !self.config.enabled {
330            tracing::debug!("Plugin system disabled; skipping discovery");
331            return Ok(vec![]);
332        }
333
334        let plugin_dir = self.config.plugin_dir.clone();
335        match std::fs::metadata(&plugin_dir) {
336            Ok(_) => {}
337            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
338            Err(error) => return Err(error.into()),
339        }
340
341        let mut loaded = vec![];
342
343        // Read plugin directories
344        let mut paths = std::fs::read_dir(&plugin_dir)?
345            .map(|entry| entry.map(|entry| entry.path()))
346            .collect::<std::io::Result<Vec<_>>>()?;
347        paths.sort();
348
349        for path in paths {
350            if !path.is_dir() {
351                continue;
352            }
353
354            // Look for plugin.toml
355            let toml_path = path.join("plugin.toml");
356            if !toml_path.exists() {
357                continue;
358            }
359
360            // Explicit loading is the single policy and publication path.
361            // Discovery differs only in continuing after an individual failure.
362            match self.load_plugin(&path) {
363                Ok(plugin) => loaded.push(plugin),
364                Err(error) => tracing::warn!(
365                    path = %path.display(),
366                    error = %error,
367                    "Skipping plugin that could not be loaded"
368                ),
369            }
370        }
371
372        Ok(loaded)
373    }
374
375    /// Load a single plugin from a path.
376    pub fn load_plugin(&mut self, path: &Path) -> PluginResult<LoadedPlugin> {
377        if !self.config.enabled {
378            return Err(PluginError::ConfigError {
379                message: "Plugin system is disabled".into(),
380            });
381        }
382        // Parse metadata
383        let toml_path = path.join("plugin.toml");
384        let metadata = read_plugin_manifest(&toml_path)?;
385
386        // Validate plugin name
387        Self::validate_plugin_name(&metadata.name)?;
388        self.validate_load_policy(&metadata.name)?;
389
390        // Find runtime
391        let runtime = self.runtimes.get_mut(&metadata.runtime).ok_or_else(|| {
392            PluginError::RuntimeNotAvailable {
393                runtime: metadata.runtime.clone(),
394            }
395        })?;
396
397        // Verify entry point stays within the plugin directory (path traversal guard)
398        let entry_file = path.join(&metadata.entry);
399        let canonical_plugin_dir = dunce::canonicalize(path).map_err(PluginError::Io)?;
400        let canonical_entry = dunce::canonicalize(&entry_file).map_err(PluginError::Io)?;
401        if !canonical_entry.starts_with(&canonical_plugin_dir) {
402            return Err(PluginError::ConfigError {
403                message: format!(
404                    "Plugin '{}': entry point '{}' escapes the plugin directory",
405                    metadata.name, metadata.entry
406                ),
407            });
408        }
409
410        // Load plugin (runtime-local handle).
411        let runtime_handle = runtime.load_plugin(&metadata.name, &entry_file)?;
412
413        // Collect hooks
414        let hooks: Vec<String> = HOOK_NAMES
415            .iter()
416            .filter(|h| runtime.has_hook(runtime_handle, h))
417            .map(|s| s.to_string())
418            .collect();
419
420        // The `runtime` borrow ends above; allocate the global handle now.
421        let handle = self.alloc_handle();
422        let loaded_plugin = LoadedPlugin {
423            handle,
424            runtime_handle,
425            id: metadata.name.clone(),
426            metadata,
427            path: path.to_path_buf(),
428            hooks,
429        };
430
431        self.plugins.insert(handle, loaded_plugin.clone());
432        Ok(loaded_plugin)
433    }
434
435    /// Unload a plugin.
436    pub fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()> {
437        let Some(plugin) = self.plugins.get(&handle) else {
438            return Ok(());
439        };
440        let runtime = self
441            .runtimes
442            .get_mut(&plugin.metadata.runtime)
443            .ok_or_else(|| PluginError::RuntimeNotAvailable {
444                runtime: plugin.metadata.runtime.clone(),
445            })?;
446        runtime.unload_plugin(plugin.runtime_handle)?;
447        self.plugins.shift_remove(&handle);
448        Ok(())
449    }
450
451    /// Dispatch a hook to all plugins that implement it.
452    ///
453    /// The overall timeout bounds cooperatively yielding hook futures; worker
454    /// deadlines and the runtimes' instruction hooks bound script execution.
455    pub async fn dispatch_hook(&self, hook: &Hook, ctx: &HookContext) -> Vec<HookResult> {
456        use std::time::Duration;
457
458        // Overall budget scales with the plugin count so each plugin still gets
459        // roughly its per-call timeout even when several are registered.
460        let plugin_budget = self.config.default_timeout_ms.max(1);
461        let active = self.plugins.len().max(1) as u64;
462        let budget =
463            Duration::from_millis(plugin_budget.saturating_mul(active).saturating_add(500));
464
465        match tokio::time::timeout(budget, self.run_hook_dispatch(hook, ctx)).await {
466            Ok(results) => results,
467            Err(_) => {
468                tracing::warn!(
469                    hook = %hook.name(),
470                    "Hook dispatch exceeded its {budget:?} time budget; discarding results"
471                );
472                Vec::new()
473            }
474        }
475    }
476
477    /// The per-plugin dispatch loop.
478    async fn run_hook_dispatch(&self, hook: &Hook, ctx: &HookContext) -> Vec<HookResult> {
479        let hook_name = hook.name();
480        let mut results = vec![];
481
482        for (_handle, plugin) in &self.plugins {
483            if !plugin.hooks.iter().any(|h| h == hook_name) {
484                continue;
485            }
486
487            if let Some(runtime) = self.runtimes.get(&plugin.metadata.runtime) {
488                let rh = plugin.runtime_handle;
489                match runtime.call_hook_async(rh, hook, ctx).await {
490                    Ok(mut r) => {
491                        r.plugin = Some(plugin.id.clone());
492                        let stop = r.stop_propagation;
493                        results.push(r);
494                        if stop {
495                            break;
496                        }
497                    }
498                    Err(e) => {
499                        // Report it to the host, and continue with other plugins.
500                        tracing::warn!(
501                            plugin_id = %plugin.id,
502                            error = %e,
503                            "Plugin hook dispatch error"
504                        );
505                        let mut failed = HookResult::error(e.to_string());
506                        failed.plugin = Some(plugin.id.clone());
507                        results.push(failed);
508                    }
509                }
510            }
511        }
512
513        results
514    }
515
516    /// Returns true if any hook result requested that the host's default action
517    /// be prevented. Callers that gate a default action on plugins (navigation,
518    /// deletion, …) can use this on the [`dispatch_hook`](Self::dispatch_hook)
519    /// results.
520    pub fn any_prevent_default(results: &[HookResult]) -> bool {
521        results.iter().any(|r| r.prevent_default)
522    }
523
524    /// Get all loaded plugins.
525    pub fn plugins(&self) -> impl Iterator<Item = &LoadedPlugin> {
526        self.plugins.values()
527    }
528
529    /// Get a loaded plugin by handle.
530    pub fn get_plugin(&self, handle: PluginHandle) -> Option<&LoadedPlugin> {
531        self.plugins.get(&handle)
532    }
533
534    /// Get plugins of a specific kind.
535    pub fn plugins_of_kind(&self, kind: PluginKind) -> impl Iterator<Item = &LoadedPlugin> {
536        self.plugins
537            .values()
538            .filter(move |p| p.metadata.kind == kind)
539    }
540
541    /// Shutdown all runtimes.
542    pub fn shutdown(&mut self) -> PluginResult<()> {
543        let mut first_error = None;
544        for (name, runtime) in self.runtimes.iter_mut() {
545            match runtime.shutdown() {
546                Ok(()) => self
547                    .plugins
548                    .retain(|_, plugin| plugin.metadata.runtime != *name),
549                Err(error) => {
550                    tracing::warn!(runtime = %name, error = %error, "Plugin runtime shutdown failed");
551                    if first_error.is_none() {
552                        first_error = Some(error);
553                    }
554                }
555            }
556        }
557        first_error.map_or(Ok(()), Err)
558    }
559}
560
561impl Default for PluginManager {
562    fn default() -> Self {
563        Self::new(PluginConfig::default())
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::config::PluginMetadata;
571    use crate::hooks::HookResult;
572    use std::time::Duration;
573
574    #[tokio::test]
575    async fn discovery_order_defines_stable_identity_selection_and_hook_order() {
576        let root = tempfile::tempdir().unwrap();
577        // Deliberately create in a different order than the documented path order.
578        for (directory, name, value) in [
579            ("zeta", "second", 2),
580            ("mirror", "first", 99),
581            ("alpha", "first", 1),
582        ] {
583            let path = root.path().join(directory);
584            std::fs::create_dir(&path).unwrap();
585            std::fs::write(path.join("plugin.toml"), format!("name = '{name}'\n")).unwrap();
586            std::fs::write(path.join("main.lua"), format!("return {{ on_startup = function(self, hook) return {{ handled = true, value = {value} }} end }}")).unwrap();
587        }
588        let mut manager =
589            PluginManager::new(PluginConfig::default().with_plugin_dir(root.path().to_path_buf()));
590        manager
591            .register_runtime(Box::new(crate::lua::LuaRuntime::new().unwrap()))
592            .unwrap();
593        manager.init_runtimes().unwrap();
594        let loaded = manager.discover_plugins().await.unwrap();
595        assert_eq!(
596            loaded
597                .iter()
598                .map(|plugin| plugin.id.as_str())
599                .collect::<Vec<_>>(),
600            ["first", "second"]
601        );
602        assert_eq!(loaded[0].path, root.path().join("alpha"));
603        let results = manager
604            .dispatch_hook(&Hook::OnStartup, &HookContext::new())
605            .await;
606        assert_eq!(
607            results
608                .iter()
609                .map(|result| result.value.as_ref().and_then(Value::as_i64))
610                .collect::<Vec<_>>(),
611            [Some(1), Some(2)]
612        );
613    }
614
615    #[tokio::test]
616    async fn oversized_manifest_is_rejected_without_blocking_healthy_discovery() {
617        let root = tempfile::tempdir().unwrap();
618        let bad = root.path().join("oversized");
619        let good = root.path().join("healthy");
620        std::fs::create_dir(&bad).unwrap();
621        std::fs::create_dir(&good).unwrap();
622        std::fs::File::create(bad.join("plugin.toml"))
623            .unwrap()
624            .set_len(64 * 1024 + 1)
625            .unwrap();
626        std::fs::write(good.join("plugin.toml"), "name = 'healthy'\n").unwrap();
627        std::fs::write(good.join("main.lua"), "return {}").unwrap();
628        let mut manager =
629            PluginManager::new(PluginConfig::default().with_plugin_dir(root.path().to_path_buf()));
630        manager
631            .register_runtime(Box::new(crate::lua::LuaRuntime::new().unwrap()))
632            .unwrap();
633        manager.init_runtimes().unwrap();
634        assert!(manager.load_plugin(&bad).is_err());
635        let loaded = manager.discover_plugins().await.unwrap();
636        assert_eq!(loaded.len(), 1);
637        assert_eq!(loaded[0].id, "healthy");
638    }
639
640    #[tokio::test]
641    async fn explicit_loading_obeys_disable_policy_before_runtime_execution() {
642        let root = tempfile::tempdir().unwrap();
643        let mut manager = PluginManager::new(PluginConfig {
644            enabled: false,
645            ..Default::default()
646        });
647        assert!(matches!(
648            manager.load_plugin(&root.path().join("absent")),
649            Err(PluginError::ConfigError { .. })
650        ));
651        assert!(manager.discover_plugins().await.unwrap().is_empty());
652        std::fs::write(root.path().join("plugin.toml"), "name = 'blocked'\n").unwrap();
653        let mut manager = PluginManager::new(PluginConfig::default().disable_plugin("blocked"));
654        // No runtime or entry file: policy must reject before either is consulted.
655        let error = manager.load_plugin(root.path()).unwrap_err();
656        assert!(
657            matches!(error, PluginError::ConfigError { message } if message.contains("disabled"))
658        );
659    }
660
661    #[tokio::test]
662    async fn rediscovery_and_explicit_load_do_not_duplicate_plugin_identity() {
663        let root = tempfile::tempdir().unwrap();
664        let plugin = root.path().join("one");
665        std::fs::create_dir(&plugin).unwrap();
666        std::fs::write(plugin.join("plugin.toml"), "name = 'one'\n").unwrap();
667        std::fs::write(plugin.join("main.lua"), "return {}").unwrap();
668        let mut manager =
669            PluginManager::new(PluginConfig::default().with_plugin_dir(root.path().to_path_buf()));
670        manager
671            .register_runtime(Box::new(crate::lua::LuaRuntime::new().unwrap()))
672            .unwrap();
673        manager.init_runtimes().unwrap();
674        let first = manager.discover_plugins().await.unwrap();
675        assert_eq!(first.len(), 1);
676        assert!(manager.discover_plugins().await.unwrap().is_empty());
677        assert!(manager.load_plugin(&plugin).is_err());
678        assert_eq!(manager.plugins.len(), 1);
679        manager.unload_plugin(first[0].handle).unwrap();
680        assert!(manager.load_plugin(&plugin).is_ok());
681    }
682
683    #[cfg(unix)]
684    #[test]
685    fn manifest_reader_rejects_symlinks_and_fifos() {
686        use std::os::unix::ffi::OsStrExt;
687        let root = tempfile::tempdir().unwrap();
688        let target = root.path().join("actual.toml");
689        std::fs::write(&target, "name = 'healthy'\n").unwrap();
690        let link = root.path().join("link.toml");
691        std::os::unix::fs::symlink(&target, &link).unwrap();
692        assert!(read_plugin_manifest(&link).is_err());
693        let fifo = root.path().join("pipe.toml");
694        let native = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
695        // SAFETY: the NUL-terminated path is valid for this call, in our private fixture.
696        assert_eq!(unsafe { libc::mkfifo(native.as_ptr(), 0o600) }, 0);
697        assert!(read_plugin_manifest(&fifo).is_err());
698    }
699
700    #[test]
701    fn source_loading_rejects_oversize_and_nonregular_files() {
702        let root = tempfile::tempdir().unwrap();
703        let path = root.path().join("large.lua");
704        std::fs::File::create(&path)
705            .unwrap()
706            .set_len(1024 * 1024 + 1)
707            .unwrap();
708        assert!(read_plugin_source(&path).is_err());
709        assert!(read_plugin_source(root.path()).is_err());
710        std::fs::write(&path, "return {}").unwrap();
711        assert_eq!(read_plugin_source(&path).unwrap(), "return {}");
712        #[cfg(unix)]
713        {
714            let link = root.path().join("link.lua");
715            std::os::unix::fs::symlink(&path, &link).unwrap();
716            assert!(read_plugin_source(&link).is_err());
717        }
718    }
719
720    /// A minimal runtime whose async hook sleeps for a configurable delay,
721    /// letting us exercise the Tower dispatch timeout budget deterministically.
722    #[derive(Default)]
723    struct SleepRuntime {
724        delay: Duration,
725        runtime_name: Option<&'static str>,
726        fail_cleanup: std::sync::Arc<std::sync::atomic::AtomicBool>,
727        shutdown_calls: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
728    }
729
730    impl PluginRuntime for SleepRuntime {
731        fn name(&self) -> &'static str {
732            self.runtime_name.unwrap_or("mock")
733        }
734        fn file_extensions(&self) -> &'static [&'static str] {
735            &[".mock"]
736        }
737        fn init(&mut self, _config: &PluginConfig) -> PluginResult<()> {
738            Ok(())
739        }
740        fn load_plugin(&mut self, _id: &str, _source: &Path) -> PluginResult<PluginHandle> {
741            Ok(PluginHandle(0))
742        }
743        fn unload_plugin(&mut self, _handle: PluginHandle) -> PluginResult<()> {
744            if self.fail_cleanup.load(std::sync::atomic::Ordering::SeqCst) {
745                return Err(PluginError::ConfigError {
746                    message: "injected cleanup failure".into(),
747                });
748            }
749            Ok(())
750        }
751        fn get_metadata(&self, _handle: PluginHandle) -> Option<&PluginMetadata> {
752            None
753        }
754        fn has_hook(&self, _handle: PluginHandle, _hook_name: &str) -> bool {
755            true
756        }
757        fn call_hook_sync(
758            &self,
759            _handle: PluginHandle,
760            _hook: &Hook,
761            _ctx: &HookContext,
762        ) -> PluginResult<HookResult> {
763            Ok(HookResult {
764                handled: true,
765                ..Default::default()
766            })
767        }
768        fn call_hook_async<'a>(
769            &'a self,
770            _handle: PluginHandle,
771            _hook: &'a Hook,
772            _ctx: &'a HookContext,
773        ) -> BoxFuture<'a, PluginResult<HookResult>> {
774            let delay = self.delay;
775            Box::pin(async move {
776                tokio::time::sleep(delay).await;
777                Ok(HookResult {
778                    handled: true,
779                    ..Default::default()
780                })
781            })
782        }
783        fn call_method<'a>(
784            &'a self,
785            _handle: PluginHandle,
786            _method: &'a str,
787            _args: Vec<Value>,
788        ) -> BoxFuture<'a, PluginResult<Value>> {
789            Box::pin(async { Ok(Value::Null) })
790        }
791        fn create_isolated_context(
792            &self,
793            _sandbox: &SandboxConfig,
794        ) -> PluginResult<Box<dyn IsolatedContext>> {
795            Err(PluginError::RuntimeNotAvailable {
796                runtime: "mock".into(),
797            })
798        }
799        fn loaded_plugins(&self) -> Vec<PluginHandle> {
800            vec![PluginHandle(0)]
801        }
802        fn shutdown(&mut self) -> PluginResult<()> {
803            self.shutdown_calls.lock().unwrap().push(self.name());
804            self.unload_plugin(PluginHandle(0))
805        }
806    }
807
808    fn mock_metadata() -> PluginMetadata {
809        PluginMetadata {
810            name: "mock".into(),
811            version: "0.0.0".into(),
812            description: String::new(),
813            author: String::new(),
814            url: String::new(),
815            runtime: "mock".into(),
816            kind: PluginKind::default(),
817            entry: "main.mock".into(),
818            min_version: None,
819            hooks: Default::default(),
820            permissions: Default::default(),
821            dependencies: vec![],
822        }
823    }
824
825    /// Build a manager wired with a single mock plugin subscribed to `on_startup`,
826    /// whose async hook sleeps for `delay`.
827    fn manager_with_sleeping_plugin(timeout_ms: u64, delay: Duration) -> PluginManager {
828        let config = PluginConfig {
829            default_timeout_ms: timeout_ms,
830            ..Default::default()
831        };
832        let mut pm = PluginManager::new(config);
833        pm.register_runtime(Box::new(SleepRuntime {
834            delay,
835            ..Default::default()
836        }))
837        .unwrap();
838        let handle = pm.alloc_handle();
839        pm.plugins.insert(
840            handle,
841            LoadedPlugin {
842                handle,
843                runtime_handle: PluginHandle(0),
844                id: "mock".into(),
845                metadata: mock_metadata(),
846                path: std::path::PathBuf::from("/tmp/mock"),
847                hooks: vec!["on_startup".into()],
848            },
849        );
850        pm
851    }
852
853    #[test]
854    fn runtime_registration_cannot_replace_loaded_handle_ownership() {
855        let mut pm = manager_with_sleeping_plugin(100, Duration::ZERO);
856        assert!(pm.register_runtime(Box::<SleepRuntime>::default()).is_err());
857        assert_eq!(pm.plugins.len(), 1);
858        assert_eq!(pm.runtimes.len(), 1);
859        pm.unload_plugin(PluginHandle(0)).unwrap();
860        assert!(pm.plugins.is_empty());
861    }
862
863    #[test]
864    fn failed_unload_preserves_handle_for_retry() {
865        let mut pm = manager_with_sleeping_plugin(100, Duration::ZERO);
866        let fail = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
867        pm.runtimes.clear();
868        pm.register_runtime(Box::new(SleepRuntime {
869            fail_cleanup: fail.clone(),
870            ..Default::default()
871        }))
872        .unwrap();
873        assert!(pm.unload_plugin(PluginHandle(0)).is_err());
874        assert!(pm.plugins.contains_key(&PluginHandle(0)));
875        fail.store(false, std::sync::atomic::Ordering::SeqCst);
876        pm.unload_plugin(PluginHandle(0)).unwrap();
877        assert!(pm.plugins.is_empty());
878    }
879
880    #[test]
881    fn shutdown_attempts_every_runtime_and_retains_only_failed_ownership() {
882        let mut pm = manager_with_sleeping_plugin(100, Duration::ZERO);
883        let fail = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
884        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
885        pm.runtimes.clear();
886        pm.register_runtime(Box::new(SleepRuntime {
887            fail_cleanup: fail.clone(),
888            shutdown_calls: calls.clone(),
889            ..Default::default()
890        }))
891        .unwrap();
892        pm.register_runtime(Box::new(SleepRuntime {
893            runtime_name: Some("healthy"),
894            shutdown_calls: calls.clone(),
895            ..Default::default()
896        }))
897        .unwrap();
898        let mut healthy = pm.plugins[&PluginHandle(0)].clone();
899        healthy.handle = pm.alloc_handle();
900        healthy.metadata.runtime = "healthy".into();
901        pm.plugins.insert(healthy.handle, healthy);
902        assert!(pm.shutdown().is_err());
903        assert_eq!(*calls.lock().unwrap(), ["mock", "healthy"]);
904        assert_eq!(pm.plugins.len(), 1);
905        assert!(pm.plugins.contains_key(&PluginHandle(0)));
906        fail.store(false, std::sync::atomic::Ordering::SeqCst);
907        pm.shutdown().unwrap();
908        assert!(pm.plugins.is_empty());
909    }
910
911    #[tokio::test]
912    async fn dispatch_returns_result_within_budget() {
913        // Hook completes well inside the budget → its result is returned.
914        let pm = manager_with_sleeping_plugin(50, Duration::from_millis(0));
915        let ctx = HookContext::new();
916        let results = pm.dispatch_hook(&Hook::OnStartup, &ctx).await;
917        assert_eq!(results.len(), 1);
918        assert!(results[0].handled);
919    }
920
921    #[test]
922    fn any_prevent_default_reflects_veto() {
923        let allow = HookResult {
924            handled: true,
925            ..Default::default()
926        };
927        let veto = HookResult {
928            handled: true,
929            prevent_default: true,
930            ..Default::default()
931        };
932        assert!(!PluginManager::any_prevent_default(&[]));
933        assert!(!PluginManager::any_prevent_default(std::slice::from_ref(
934            &allow
935        )));
936        assert!(PluginManager::any_prevent_default(&[allow, veto]));
937    }
938
939    #[tokio::test]
940    async fn dispatch_discards_results_past_time_budget() {
941        // budget = default_timeout_ms * active(1) + 500ms ≈ 510ms.
942        // A 2s hook blows the budget, so dispatch sheds and returns nothing.
943        let pm = manager_with_sleeping_plugin(10, Duration::from_secs(2));
944        let ctx = HookContext::new();
945        let results = pm.dispatch_hook(&Hook::OnStartup, &ctx).await;
946        assert!(
947            results.is_empty(),
948            "expected empty results when dispatch exceeds its time budget"
949        );
950    }
951}