//! Filesystem discovery: scan a directory for plugin `manifest.toml` files. use std::path::Path; use tracing::instrument; use crate::error::PluginError; use crate::manifest::{parse_manifest, PluginManifest}; /// A discovered plugin: its manifest and the directory it lives in. pub struct DiscoveredPlugin { /// Parsed TOML manifest. pub manifest: PluginManifest, /// Directory containing the manifest (for resolving relative hook paths). pub plugin_dir: std::path::PathBuf, } /// Scan a directory for plugin subdirectories, each containing a `manifest.toml`. /// /// Returns all successfully parsed plugins. Invalid plugins are silently skipped /// (the caller can log warnings if needed by checking the count). #[instrument(skip_all, fields(dir = ?dir))] pub fn discover_plugins(dir: &Path) -> Vec { let mut plugins = Vec::new(); let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => return plugins, }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let manifest_path = path.join("manifest.toml"); if !manifest_path.exists() { continue; } let toml_str = match std::fs::read_to_string(&manifest_path) { Ok(s) => s, Err(e) => { tracing::warn!(path = %manifest_path.display(), "failed to read plugin manifest: {e}"); continue; } }; match parse_manifest(&toml_str) { Ok(manifest) => { plugins.push(DiscoveredPlugin { manifest, plugin_dir: path, }); } Err(e) => { tracing::warn!(path = %manifest_path.display(), "invalid plugin manifest: {e}"); continue; } } } plugins } /// Load a Rhai hook script from a plugin directory by relative path. /// /// Validates that the resolved path does not escape the plugin directory /// (prevents path traversal via `..` in manifest-supplied paths). #[instrument(skip_all, fields(path = ?plugin_dir))] pub fn load_hook_script( plugin_dir: &Path, relative_path: &str, ) -> Result { let full_path = plugin_dir.join(relative_path); let canonical = full_path.canonicalize().map_err(PluginError::Io)?; let canonical_base = plugin_dir.canonicalize().map_err(PluginError::Io)?; if !canonical.starts_with(&canonical_base) { return Err(PluginError::Io(std::io::Error::new( std::io::ErrorKind::PermissionDenied, format!("hook script path escapes plugin directory: {relative_path}"), ))); } std::fs::read_to_string(&canonical).map_err(PluginError::Io) } #[cfg(test)] mod tests { use super::*; #[test] fn discover_empty_dir() { let dir = tempfile::tempdir().unwrap(); let plugins = discover_plugins(dir.path()); assert!(plugins.is_empty()); } #[test] fn discover_valid_plugin() { let dir = tempfile::tempdir().unwrap(); let plugin_dir = dir.path().join("my-device"); std::fs::create_dir(&plugin_dir).unwrap(); std::fs::write( plugin_dir.join("manifest.toml"), r#" [device] name = "Test Device" manufacturer = "Test" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100] bit_depths = [16] channels = "both" "#, ) .unwrap(); let plugins = discover_plugins(dir.path()); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].manifest.device.name, "Test Device"); } #[test] fn discover_skips_invalid_manifest() { let dir = tempfile::tempdir().unwrap(); // Valid plugin let valid_dir = dir.path().join("valid"); std::fs::create_dir(&valid_dir).unwrap(); std::fs::write( valid_dir.join("manifest.toml"), r#" [device] name = "Valid" manufacturer = "Test" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100] bit_depths = [16] channels = "both" "#, ) .unwrap(); // Invalid plugin (bad TOML) let invalid_dir = dir.path().join("invalid"); std::fs::create_dir(&invalid_dir).unwrap(); std::fs::write(invalid_dir.join("manifest.toml"), "not valid toml {{").unwrap(); let plugins = discover_plugins(dir.path()); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].manifest.device.name, "Valid"); } #[test] fn load_hook_script_works() { let dir = tempfile::tempdir().unwrap(); let hooks_dir = dir.path().join("hooks"); std::fs::create_dir(&hooks_dir).unwrap(); std::fs::write(hooks_dir.join("validate.rhai"), "info.channels == 1").unwrap(); let script = load_hook_script(dir.path(), "hooks/validate.rhai").unwrap(); assert_eq!(script, "info.channels == 1"); } #[test] fn load_hook_script_missing_file() { let dir = tempfile::tempdir().unwrap(); let result = load_hook_script(dir.path(), "nonexistent.rhai"); assert!(result.is_err()); } #[test] fn load_hook_script_rejects_path_traversal() { let dir = tempfile::tempdir().unwrap(); // Create a file outside the plugin dir let outside = dir.path().join("secret.txt"); std::fs::write(&outside, "sensitive data").unwrap(); let plugin_dir = dir.path().join("my-plugin"); std::fs::create_dir(&plugin_dir).unwrap(); let result = load_hook_script(&plugin_dir, "../secret.txt"); assert!(result.is_err(), "path traversal should be rejected"); } }