//! Plugin loader for discovering and loading plugins from the filesystem use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use pinakes_plugin_api::PluginManifest; use tracing::{debug, info, warn}; use walkdir::WalkDir; /// Plugin loader handles discovery and loading of plugins from directories pub struct PluginLoader { /// Directories to search for plugins plugin_dirs: Vec, } impl PluginLoader { /// Create a new plugin loader #[must_use] pub const fn new(plugin_dirs: Vec) -> Self { Self { plugin_dirs } } /// Discover all plugins in configured directories /// /// # Errors /// /// Returns an error if a plugin directory cannot be searched. pub fn discover_plugins(&self) -> Result> { let mut manifests = Vec::new(); for dir in &self.plugin_dirs { if !dir.exists() { warn!("Plugin directory does not exist: {:?}", dir); continue; } info!("Discovering plugins in: {:?}", dir); let found = Self::discover_in_directory(dir); info!("Found {} plugins in {:?}", found.len(), dir); manifests.extend(found); } Ok(manifests) } /// Discover plugins in a specific directory fn discover_in_directory(dir: &Path) -> Vec { let mut manifests = Vec::new(); // Walk the directory looking for plugin.toml files for entry in WalkDir::new(dir) .max_depth(3) // Don't go too deep .follow_links(false) { let entry = match entry { Ok(e) => e, Err(e) => { warn!("Error reading directory entry: {}", e); continue; }, }; let path = entry.path(); // Look for plugin.toml files if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) { debug!("Found plugin manifest: {:?}", path); match PluginManifest::from_file(path) { Ok(manifest) => { info!("Loaded manifest for plugin: {}", manifest.plugin.name); manifests.push(manifest); }, Err(e) => { warn!("Failed to load manifest from {:?}: {}", path, e); }, } } } manifests } /// Resolve the WASM binary path from a manifest /// /// # Errors /// /// Returns an error if the WASM binary is not found or its path escapes the /// plugin directory. pub fn resolve_wasm_path( &self, manifest: &PluginManifest, ) -> Result { // The WASM path in the manifest is relative to the manifest file // We need to search for it in the plugin directories for dir in &self.plugin_dirs { // Look for a directory matching the plugin name let plugin_dir = dir.join(&manifest.plugin.name); if !plugin_dir.exists() { continue; } // Check for plugin.toml in this directory let manifest_path = plugin_dir.join("plugin.toml"); if !manifest_path.exists() { continue; } // Resolve WASM path relative to this directory let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm); if wasm_path.exists() { // Verify the resolved path is within the plugin directory (prevent path // traversal) let canonical_wasm = wasm_path .canonicalize() .map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?; let canonical_plugin_dir = plugin_dir .canonicalize() .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?; if !canonical_wasm.starts_with(&canonical_plugin_dir) { return Err(anyhow!( "WASM binary path escapes plugin directory: {}", wasm_path.display() )); } return Ok(canonical_wasm); } } Err(anyhow!( "WASM binary not found for plugin: {}", manifest.plugin.name )) } /// Download a plugin from a URL /// /// # Errors /// /// Returns an error if the URL is not HTTPS, no plugin directories are /// configured, the download fails, the archive is too large, or extraction /// fails. pub async fn download_plugin(&self, url: &str) -> Result { const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB // Only allow HTTPS downloads if !url.starts_with("https://") { return Err(anyhow!( "Only HTTPS URLs are allowed for plugin downloads: {url}" )); } let dest_dir = self .plugin_dirs .first() .ok_or_else(|| anyhow!("No plugin directories configured"))?; std::fs::create_dir_all(dest_dir)?; // Download the archive with timeout and size limits let client = reqwest::Client::builder() .timeout(std::time::Duration::from_mins(5)) .build() .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?; let response = client .get(url) .send() .await .map_err(|e| anyhow!("Failed to download plugin: {e}"))?; if !response.status().is_success() { return Err(anyhow!( "Plugin download failed with status: {}", response.status() )); } // Check content-length header before downloading if let Some(content_length) = response.content_length() && content_length > MAX_PLUGIN_SIZE { return Err(anyhow!( "Plugin archive too large: {content_length} bytes (max \ {MAX_PLUGIN_SIZE} bytes)" )); } let bytes = response .bytes() .await .map_err(|e| anyhow!("Failed to read plugin response: {e}"))?; // Check actual size after download if bytes.len() as u64 > MAX_PLUGIN_SIZE { return Err(anyhow!( "Plugin archive too large: {} bytes (max {} bytes)", bytes.len(), MAX_PLUGIN_SIZE )); } // Write archive to a unique temp file let temp_archive = dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7())); std::fs::write(&temp_archive, &bytes)?; // Extract using tar with -C to target directory let canonical_dest = dest_dir .canonicalize() .map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?; let output = std::process::Command::new("tar") .args([ "xzf", &temp_archive.to_string_lossy(), "-C", &canonical_dest.to_string_lossy(), ]) .output() .map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?; // Clean up the archive let _ = std::fs::remove_file(&temp_archive); if !output.status.success() { return Err(anyhow!( "Failed to extract plugin archive: {}", String::from_utf8_lossy(&output.stderr) )); } // Validate that all extracted files are within dest_dir for entry in WalkDir::new(&canonical_dest).follow_links(false) { let entry = entry?; let entry_canonical = entry.path().canonicalize()?; if !entry_canonical.starts_with(&canonical_dest) { return Err(anyhow!( "Extracted file escapes destination directory: {}", entry.path().display() )); } } // Find the extracted plugin directory by looking for plugin.toml for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) { let entry = entry?; if entry.file_name() == "plugin.toml" { let plugin_dir = entry .path() .parent() .ok_or_else(|| anyhow!("Invalid plugin.toml location"))?; // Validate the manifest let manifest = PluginManifest::from_file(entry.path())?; info!("Downloaded and extracted plugin: {}", manifest.plugin.name); return Ok(plugin_dir.to_path_buf()); } } Err(anyhow!( "No plugin.toml found after extracting archive from: {url}" )) } /// Validate a plugin package /// /// # Errors /// /// Returns an error if the path does not exist, is missing `plugin.toml`, /// the WASM binary is not found, or the WASM file is invalid. pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { // Check that the path exists if !path.exists() { return Err(anyhow!("Plugin path does not exist: {}", path.display())); } // Check for plugin.toml let manifest_path = path.join("plugin.toml"); if !manifest_path.exists() { return Err(anyhow!("Missing plugin.toml in {}", path.display())); } // Parse and validate manifest let manifest = PluginManifest::from_file(&manifest_path)?; // Check that WASM binary exists let wasm_path = path.join(&manifest.plugin.binary.wasm); if !wasm_path.exists() { return Err(anyhow!( "WASM binary not found: {}", manifest.plugin.binary.wasm )); } // Verify the WASM path is within the plugin directory (prevent path // traversal) let canonical_wasm = wasm_path.canonicalize()?; let canonical_path = path.canonicalize()?; if !canonical_wasm.starts_with(&canonical_path) { return Err(anyhow!( "WASM binary path escapes plugin directory: {}", wasm_path.display() )); } // Validate WASM file let wasm_bytes = std::fs::read(&wasm_path)?; if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { return Err(anyhow!("Invalid WASM file: {}", wasm_path.display())); } Ok(()) } /// Get plugin directory path for a given plugin name #[must_use] pub fn get_plugin_dir(&self, plugin_name: &str) -> Option { for dir in &self.plugin_dirs { let plugin_dir = dir.join(plugin_name); if plugin_dir.exists() { return Some(plugin_dir); } } None } } #[cfg(test)] mod tests { use tempfile::TempDir; use super::*; #[test] fn test_discover_plugins_empty() { let temp_dir = TempDir::new().unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); let manifests = loader.discover_plugins().unwrap(); assert_eq!(manifests.len(), 0); } #[test] fn test_discover_plugins_with_manifest() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("test-plugin"); std::fs::create_dir(&plugin_dir).unwrap(); // Create a valid manifest let manifest_content = r#" [plugin] name = "test-plugin" version = "1.0.0" api_version = "1.0" kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); // Create dummy WASM file std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") .unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); let manifests = loader.discover_plugins().unwrap(); assert_eq!(manifests.len(), 1); assert_eq!(manifests[0].plugin.name, "test-plugin"); } #[test] fn test_validate_plugin_package() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("test-plugin"); std::fs::create_dir(&plugin_dir).unwrap(); // Create a valid manifest let manifest_content = r#" [plugin] name = "test-plugin" version = "1.0.0" api_version = "1.0" kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); let loader = PluginLoader::new(vec![]); // Should fail without WASM file assert!(loader.validate_plugin_package(&plugin_dir).is_err()); // Create valid WASM file (magic number only) std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") .unwrap(); // Should succeed now assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); } #[test] fn test_validate_invalid_wasm() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("test-plugin"); std::fs::create_dir(&plugin_dir).unwrap(); let manifest_content = r#" [plugin] name = "test-plugin" version = "1.0.0" api_version = "1.0" kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); // Create invalid WASM file std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); let loader = PluginLoader::new(vec![]); assert!(loader.validate_plugin_package(&plugin_dir).is_err()); } }