//! Plugin system for Pinakes //! //! This module provides a comprehensive plugin architecture that allows extending //! Pinakes with custom media types, metadata extractors, search backends, and more. //! //! # Architecture //! //! - Plugins are compiled to WASM and run in a sandboxed environment //! - Capability-based security controls what plugins can access //! - Hot-reload support for development //! - Automatic plugin discovery from configured directories use anyhow::Result; use pinakes_plugin_api::{PluginContext, PluginMetadata}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; pub mod loader; pub mod registry; pub mod runtime; pub mod security; pub use loader::PluginLoader; pub use registry::{PluginRegistry, RegisteredPlugin}; pub use runtime::{WasmPlugin, WasmRuntime}; pub use security::CapabilityEnforcer; /// Plugin manager coordinates plugin lifecycle and operations pub struct PluginManager { /// Plugin registry registry: Arc>, /// WASM runtime for executing plugins runtime: Arc, /// Plugin loader for discovery and loading loader: PluginLoader, /// Capability enforcer for security enforcer: CapabilityEnforcer, /// Plugin data directory data_dir: PathBuf, /// Plugin cache directory cache_dir: PathBuf, /// Configuration config: PluginManagerConfig, } /// Configuration for the plugin manager #[derive(Debug, Clone)] pub struct PluginManagerConfig { /// Directories to search for plugins pub plugin_dirs: Vec, /// Whether to enable hot-reload (for development) pub enable_hot_reload: bool, /// Whether to allow unsigned plugins pub allow_unsigned: bool, /// Maximum number of concurrent plugin operations pub max_concurrent_ops: usize, /// Plugin timeout in seconds pub plugin_timeout_secs: u64, } impl Default for PluginManagerConfig { fn default() -> Self { Self { plugin_dirs: vec![], enable_hot_reload: false, allow_unsigned: false, max_concurrent_ops: 4, plugin_timeout_secs: 30, } } } impl From for PluginManagerConfig { fn from(cfg: crate::config::PluginsConfig) -> Self { Self { plugin_dirs: cfg.plugin_dirs, enable_hot_reload: cfg.enable_hot_reload, allow_unsigned: cfg.allow_unsigned, max_concurrent_ops: cfg.max_concurrent_ops, plugin_timeout_secs: cfg.plugin_timeout_secs, } } } impl PluginManager { /// Create a new plugin manager pub fn new(data_dir: PathBuf, cache_dir: PathBuf, config: PluginManagerConfig) -> Result { // Ensure directories exist std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&cache_dir)?; let runtime = Arc::new(WasmRuntime::new()?); let registry = Arc::new(RwLock::new(PluginRegistry::new())); let loader = PluginLoader::new(config.plugin_dirs.clone()); let enforcer = CapabilityEnforcer::new(); Ok(Self { registry, runtime, loader, enforcer, data_dir, cache_dir, config, }) } /// Discover and load all plugins from configured directories pub async fn discover_and_load_all(&self) -> Result> { info!("Discovering plugins from {:?}", self.config.plugin_dirs); let manifests = self.loader.discover_plugins().await?; let mut loaded_plugins = Vec::new(); for manifest in manifests { match self.load_plugin_from_manifest(&manifest).await { Ok(plugin_id) => { info!("Loaded plugin: {}", plugin_id); loaded_plugins.push(plugin_id); } Err(e) => { warn!("Failed to load plugin {}: {}", manifest.plugin.name, e); } } } Ok(loaded_plugins) } /// Load a plugin from a manifest file async fn load_plugin_from_manifest( &self, manifest: &pinakes_plugin_api::PluginManifest, ) -> Result { let plugin_id = manifest.plugin_id(); // Validate plugin_id to prevent path traversal if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") { return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); } // Check if already loaded { let registry = self.registry.read().await; if registry.is_loaded(&plugin_id) { return Ok(plugin_id); } } // Validate capabilities let capabilities = manifest.to_capabilities(); self.enforcer.validate_capabilities(&capabilities)?; // Create plugin context let plugin_data_dir = self.data_dir.join(&plugin_id); let plugin_cache_dir = self.cache_dir.join(&plugin_id); tokio::fs::create_dir_all(&plugin_data_dir).await?; tokio::fs::create_dir_all(&plugin_cache_dir).await?; let context = PluginContext { data_dir: plugin_data_dir, cache_dir: plugin_cache_dir, config: manifest .config .iter() .map(|(k, v)| { ( k.clone(), serde_json::to_value(v).unwrap_or_else(|e| { tracing::warn!("failed to serialize config value for key {}: {}", k, e); serde_json::Value::Null }), ) }) .collect(), capabilities: capabilities.clone(), }; // Load WASM binary let wasm_path = self.loader.resolve_wasm_path(manifest)?; let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?; // Initialize plugin let init_succeeded = match wasm_plugin.call_function("initialize", &[]).await { Ok(_) => true, Err(e) => { tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e); false } }; // Register plugin let metadata = PluginMetadata { id: plugin_id.clone(), name: manifest.plugin.name.clone(), version: manifest.plugin.version.clone(), author: manifest.plugin.author.clone().unwrap_or_default(), description: manifest.plugin.description.clone().unwrap_or_default(), api_version: manifest.plugin.api_version.clone(), capabilities_required: capabilities, }; // Derive manifest_path from the loader's plugin directories let manifest_path = self .loader .get_plugin_dir(&manifest.plugin.name) .map(|dir| dir.join("plugin.toml")); let registered = RegisteredPlugin { id: plugin_id.clone(), metadata, wasm_plugin, manifest: manifest.clone(), manifest_path, enabled: init_succeeded, }; let mut registry = self.registry.write().await; registry.register(registered)?; Ok(plugin_id) } /// Install a plugin from a file or URL pub async fn install_plugin(&self, source: &str) -> Result { info!("Installing plugin from: {}", source); // Download/copy plugin to plugins directory let plugin_path = if source.starts_with("http://") || source.starts_with("https://") { // Download from URL self.loader.download_plugin(source).await? } else { // Copy from local file PathBuf::from(source) }; // Load the manifest let manifest_path = plugin_path.join("plugin.toml"); let manifest = pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?; // Load the plugin self.load_plugin_from_manifest(&manifest).await } /// Uninstall a plugin pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { // Validate plugin_id to prevent path traversal if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") { return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); } info!("Uninstalling plugin: {}", plugin_id); // Shutdown plugin first self.shutdown_plugin(plugin_id).await?; // Remove from registry let mut registry = self.registry.write().await; registry.unregister(plugin_id)?; // Remove plugin data and cache let plugin_data_dir = self.data_dir.join(plugin_id); let plugin_cache_dir = self.cache_dir.join(plugin_id); if plugin_data_dir.exists() { std::fs::remove_dir_all(&plugin_data_dir)?; } if plugin_cache_dir.exists() { std::fs::remove_dir_all(&plugin_cache_dir)?; } Ok(()) } /// Enable a plugin pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { let mut registry = self.registry.write().await; registry.enable(plugin_id) } /// Disable a plugin pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { let mut registry = self.registry.write().await; registry.disable(plugin_id) } /// Shutdown a specific plugin pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { debug!("Shutting down plugin: {}", plugin_id); let registry = self.registry.read().await; if let Some(plugin) = registry.get(plugin_id) { plugin.wasm_plugin.call_function("shutdown", &[]).await.ok(); Ok(()) } else { Err(anyhow::anyhow!("Plugin not found: {}", plugin_id)) } } /// Shutdown all plugins pub async fn shutdown_all(&self) -> Result<()> { info!("Shutting down all plugins"); let registry = self.registry.read().await; let plugin_ids: Vec = registry.list_all().iter().map(|p| p.id.clone()).collect(); for plugin_id in plugin_ids { if let Err(e) = self.shutdown_plugin(&plugin_id).await { error!("Failed to shutdown plugin {}: {}", plugin_id, e); } } Ok(()) } /// Get list of all registered plugins pub async fn list_plugins(&self) -> Vec { let registry = self.registry.read().await; registry .list_all() .iter() .map(|p| p.metadata.clone()) .collect() } /// Get plugin metadata by ID pub async fn get_plugin(&self, plugin_id: &str) -> Option { let registry = self.registry.read().await; registry.get(plugin_id).map(|p| p.metadata.clone()) } /// Check if a plugin is loaded and enabled pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { let registry = self.registry.read().await; registry.is_enabled(plugin_id).unwrap_or(false) } /// Reload a plugin (for hot-reload during development) pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { if !self.config.enable_hot_reload { return Err(anyhow::anyhow!("Hot-reload is disabled")); } info!("Reloading plugin: {}", plugin_id); // Re-read the manifest from disk if possible, falling back to cached version let manifest = { let registry = self.registry.read().await; let plugin = registry .get(plugin_id) .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; if let Some(ref manifest_path) = plugin.manifest_path { pinakes_plugin_api::PluginManifest::from_file(manifest_path).unwrap_or_else(|e| { warn!("Failed to re-read manifest from disk, using cached: {}", e); plugin.manifest.clone() }) } else { plugin.manifest.clone() } }; // Shutdown and unload current version self.shutdown_plugin(plugin_id).await?; { let mut registry = self.registry.write().await; registry.unregister(plugin_id)?; } // Reload from manifest self.load_plugin_from_manifest(&manifest).await?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[tokio::test] async fn test_plugin_manager_creation() { let temp_dir = TempDir::new().unwrap(); let data_dir = temp_dir.path().join("data"); let cache_dir = temp_dir.path().join("cache"); let config = PluginManagerConfig::default(); let manager = PluginManager::new(data_dir.clone(), cache_dir.clone(), config); assert!(manager.is_ok()); assert!(data_dir.exists()); assert!(cache_dir.exists()); } #[tokio::test] async fn test_list_plugins_empty() { let temp_dir = TempDir::new().unwrap(); let data_dir = temp_dir.path().join("data"); let cache_dir = temp_dir.path().join("cache"); let config = PluginManagerConfig::default(); let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); let plugins = manager.list_plugins().await; assert_eq!(plugins.len(), 0); } }