1use std::future::Future;
7use std::path::Path;
8use std::pin::Pin;
9
10use indexmap::IndexMap;
11
12pub(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
39pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub struct PluginHandle(pub(crate) usize);
83
84impl PluginHandle {
85 pub(crate) fn new(id: usize) -> Self {
87 Self(id)
88 }
89
90 pub fn id(&self) -> usize {
92 self.0
93 }
94}
95
96pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
98
99pub trait PluginRuntime: Send + Sync {
104 fn name(&self) -> &'static str;
106
107 fn file_extensions(&self) -> &'static [&'static str];
109
110 fn init(&mut self, config: &PluginConfig) -> PluginResult<()>;
112
113 fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle>;
117
118 fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()>;
120
121 fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata>;
123
124 fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool;
126
127 fn call_hook_sync(
131 &self,
132 handle: PluginHandle,
133 hook: &Hook,
134 ctx: &HookContext,
135 ) -> PluginResult<HookResult>;
136
137 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 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 fn create_isolated_context(
160 &self,
161 sandbox: &SandboxConfig,
162 ) -> PluginResult<Box<dyn IsolatedContext>>;
163
164 fn loaded_plugins(&self) -> Vec<PluginHandle>;
166
167 fn shutdown(&mut self) -> PluginResult<()>;
169}
170
171pub trait IsolatedContext: Send {
180 fn execute<'a>(
182 &'a self,
183 code: &'a [u8],
184 cancel: CancellationToken,
185 ) -> BoxFuture<'a, PluginResult<Value>>;
186
187 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 fn set_global(&mut self, name: &str, value: Value) -> PluginResult<()>;
197
198 fn get_global(&self, name: &str) -> PluginResult<Value>;
200}
201
202#[derive(Debug, Clone)]
204pub struct LoadedPlugin {
205 pub handle: PluginHandle,
207
208 pub(crate) runtime_handle: PluginHandle,
212
213 pub id: String,
215
216 pub metadata: PluginMetadata,
218
219 pub path: std::path::PathBuf,
221
222 pub hooks: Vec<String>,
224}
225
226pub struct PluginManager {
228 runtimes: IndexMap<String, Box<dyn PluginRuntime>>,
230
231 plugins: IndexMap<PluginHandle, LoadedPlugin>,
233
234 config: PluginConfig,
236
237 next_handle: usize,
239}
240
241impl PluginManager {
242 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 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 fn alloc_handle(&mut self) -> PluginHandle {
291 let handle = PluginHandle(self.next_handle);
292 self.next_handle += 1;
293 handle
294 }
295
296 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 pub fn get_runtime(&self, name: &str) -> Option<&dyn PluginRuntime> {
310 self.runtimes.get(name).map(|r| r.as_ref())
311 }
312
313 pub fn get_runtime_mut(&mut self, name: &str) -> Option<&mut Box<dyn PluginRuntime>> {
315 self.runtimes.get_mut(name)
316 }
317
318 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 pub async fn discover_plugins(&mut self) -> PluginResult<Vec<LoadedPlugin>> {
328 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 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 let toml_path = path.join("plugin.toml");
356 if !toml_path.exists() {
357 continue;
358 }
359
360 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 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 let toml_path = path.join("plugin.toml");
384 let metadata = read_plugin_manifest(&toml_path)?;
385
386 Self::validate_plugin_name(&metadata.name)?;
388 self.validate_load_policy(&metadata.name)?;
389
390 let runtime = self.runtimes.get_mut(&metadata.runtime).ok_or_else(|| {
392 PluginError::RuntimeNotAvailable {
393 runtime: metadata.runtime.clone(),
394 }
395 })?;
396
397 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 let runtime_handle = runtime.load_plugin(&metadata.name, &entry_file)?;
412
413 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 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 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 pub async fn dispatch_hook(&self, hook: &Hook, ctx: &HookContext) -> Vec<HookResult> {
456 use std::time::Duration;
457
458 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 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 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 pub fn any_prevent_default(results: &[HookResult]) -> bool {
521 results.iter().any(|r| r.prevent_default)
522 }
523
524 pub fn plugins(&self) -> impl Iterator<Item = &LoadedPlugin> {
526 self.plugins.values()
527 }
528
529 pub fn get_plugin(&self, handle: PluginHandle) -> Option<&LoadedPlugin> {
531 self.plugins.get(&handle)
532 }
533
534 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 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 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 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 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 #[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 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 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 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}