//! Capability-based security for plugins use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use pinakes_plugin_api::Capabilities; /// Capability enforcer validates and enforces plugin capabilities pub struct CapabilityEnforcer { /// Maximum allowed memory per plugin (bytes) max_memory_limit: usize, /// Maximum allowed CPU time per plugin (milliseconds) max_cpu_time_limit: u64, /// Allowed filesystem read paths (system-wide) allowed_read_paths: Vec, /// Allowed filesystem write paths (system-wide) allowed_write_paths: Vec, /// Whether to allow network access by default allow_network_default: bool, } impl CapabilityEnforcer { /// Create a new capability enforcer with default limits #[must_use] pub const fn new() -> Self { Self { max_memory_limit: 512 * 1024 * 1024, // 512 MB max_cpu_time_limit: 60 * 1000, // 60 seconds allowed_read_paths: vec![], allowed_write_paths: vec![], allow_network_default: false, } } /// Set maximum memory limit #[must_use] pub const fn with_max_memory(mut self, bytes: usize) -> Self { self.max_memory_limit = bytes; self } /// Set maximum CPU time limit #[must_use] pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { self.max_cpu_time_limit = milliseconds; self } /// Add allowed read path #[must_use] pub fn allow_read_path(mut self, path: PathBuf) -> Self { self.allowed_read_paths.push(path); self } /// Add allowed write path #[must_use] pub fn allow_write_path(mut self, path: PathBuf) -> Self { self.allowed_write_paths.push(path); self } /// Set default network access policy #[must_use] pub const fn with_network_default(mut self, allow: bool) -> Self { self.allow_network_default = allow; self } /// Validate capabilities requested by a plugin /// /// # Errors /// /// Returns an error if the plugin requests capabilities that exceed the /// configured system limits, such as memory, CPU time, filesystem paths, or /// network access. pub fn validate_capabilities( &self, capabilities: &Capabilities, ) -> Result<()> { // Validate memory limit if let Some(memory) = capabilities.max_memory_bytes && memory > self.max_memory_limit { return Err(anyhow!( "Requested memory ({} bytes) exceeds limit ({} bytes)", memory, self.max_memory_limit )); } // Validate CPU time limit if let Some(cpu_time) = capabilities.max_cpu_time_ms && cpu_time > self.max_cpu_time_limit { return Err(anyhow!( "Requested CPU time ({} ms) exceeds limit ({} ms)", cpu_time, self.max_cpu_time_limit )); } // Validate filesystem access self.validate_filesystem_access(capabilities)?; // Validate network access if capabilities.network.enabled && !self.allow_network_default { return Err(anyhow!( "Plugin requests network access, but network access is disabled by \ policy" )); } Ok(()) } /// Validate filesystem access capabilities fn validate_filesystem_access( &self, capabilities: &Capabilities, ) -> Result<()> { // Check read paths for path in &capabilities.filesystem.read { if !self.is_read_allowed(path) { return Err(anyhow!( "Plugin requests read access to {} which is not in allowed paths", path.display() )); } } // Check write paths for path in &capabilities.filesystem.write { if !self.is_write_allowed(path) { return Err(anyhow!( "Plugin requests write access to {} which is not in allowed paths", path.display() )); } } Ok(()) } /// Check if a path is allowed for reading #[must_use] pub fn is_read_allowed(&self, path: &Path) -> bool { if self.allowed_read_paths.is_empty() { return false; // deny-all when unconfigured } let Ok(canonical) = path.canonicalize() else { return false; }; self.allowed_read_paths.iter().any(|allowed| { allowed .canonicalize() .is_ok_and(|a| canonical.starts_with(a)) }) } /// Check if a path is allowed for writing #[must_use] pub fn is_write_allowed(&self, path: &Path) -> bool { if self.allowed_write_paths.is_empty() { return false; // deny-all when unconfigured } let canonical = if path.exists() { path.canonicalize().ok() } else { path .parent() .and_then(|p| p.canonicalize().ok()) .map(|p| p.join(path.file_name().unwrap_or_default())) }; let Some(canonical) = canonical else { return false; }; self.allowed_write_paths.iter().any(|allowed| { allowed .canonicalize() .is_ok_and(|a| canonical.starts_with(a)) }) } /// Check if network access is allowed for a plugin #[must_use] pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { capabilities.network.enabled && self.allow_network_default } /// Check if a specific domain is allowed #[must_use] pub fn is_domain_allowed( &self, capabilities: &Capabilities, domain: &str, ) -> bool { if !capabilities.network.enabled { return false; } // If no domain restrictions, allow all domains if capabilities.network.allowed_domains.is_none() { return self.allow_network_default; } // Check against allowed domains list capabilities .network .allowed_domains .as_ref() .is_some_and(|domains| { domains.iter().any(|d| d.eq_ignore_ascii_case(domain)) }) } /// Get effective memory limit for a plugin #[must_use] pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { capabilities .max_memory_bytes .unwrap_or(self.max_memory_limit) .min(self.max_memory_limit) } /// Get effective CPU time limit for a plugin #[must_use] pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { capabilities .max_cpu_time_ms .unwrap_or(self.max_cpu_time_limit) .min(self.max_cpu_time_limit) } /// Validate that a function call is allowed for a plugin's declared kinds. /// /// Defense-in-depth: even though the pipeline filters by kind, this prevents /// bugs from calling wrong functions on plugins. Returns `true` if allowed. #[must_use] pub fn validate_function_call( &self, plugin_kinds: &[String], function_name: &str, ) -> bool { match function_name { // Lifecycle functions are always allowed "initialize" | "shutdown" | "health_check" => true, // MediaTypeProvider "supported_media_types" | "can_handle" => { plugin_kinds.iter().any(|k| k == "media_type") }, // supported_types is shared by metadata_extractor and thumbnail_generator "supported_types" => { plugin_kinds .iter() .any(|k| k == "metadata_extractor" || k == "thumbnail_generator") }, // MetadataExtractor "extract_metadata" => { plugin_kinds.iter().any(|k| k == "metadata_extractor") }, // ThumbnailGenerator "generate_thumbnail" => { plugin_kinds.iter().any(|k| k == "thumbnail_generator") }, // SearchBackend "search" | "index_item" | "remove_item" | "get_stats" => { plugin_kinds.iter().any(|k| k == "search_backend") }, // EventHandler "interested_events" | "handle_event" => { plugin_kinds.iter().any(|k| k == "event_handler") }, // ThemeProvider "get_themes" | "load_theme" => { plugin_kinds.iter().any(|k| k == "theme_provider") }, // Unknown function names are not allowed _ => false, } } } impl Default for CapabilityEnforcer { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { #[allow(unused_imports)] use pinakes_plugin_api::{FilesystemCapability, NetworkCapability}; use super::*; #[test] fn test_validate_memory_limit() { let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB let mut caps = Capabilities::default(); caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK assert!(enforcer.validate_capabilities(&caps).is_ok()); caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit assert!(enforcer.validate_capabilities(&caps).is_err()); } #[test] fn test_validate_cpu_time_limit() { let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds let mut caps = Capabilities::default(); caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK assert!(enforcer.validate_capabilities(&caps).is_ok()); caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit assert!(enforcer.validate_capabilities(&caps).is_err()); } #[test] fn test_filesystem_read_allowed() { // Use real temp directories so canonicalize works let tmp = tempfile::tempdir().unwrap(); let allowed_dir = tmp.path().join("allowed"); std::fs::create_dir_all(&allowed_dir).unwrap(); let test_file = allowed_dir.join("test.txt"); std::fs::write(&test_file, "test").unwrap(); let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir); assert!(enforcer.is_read_allowed(&test_file)); assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); } #[test] fn test_filesystem_read_denied_when_empty() { let enforcer = CapabilityEnforcer::new(); assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt"))); } #[test] fn test_filesystem_write_allowed() { let tmp = tempfile::tempdir().unwrap(); let output_dir = tmp.path().join("output"); std::fs::create_dir_all(&output_dir).unwrap(); // Existing file in allowed dir let existing = output_dir.join("file.txt"); std::fs::write(&existing, "test").unwrap(); let enforcer = CapabilityEnforcer::new().allow_write_path(output_dir.clone()); assert!(enforcer.is_write_allowed(&existing)); // New file in allowed dir (parent exists) assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt"))); assert!(!enforcer.is_write_allowed(Path::new("/etc/config"))); } #[test] fn test_filesystem_write_denied_when_empty() { let enforcer = CapabilityEnforcer::new(); assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt"))); } #[test] fn test_network_allowed() { let enforcer = CapabilityEnforcer::new().with_network_default(true); let mut caps = Capabilities::default(); caps.network.enabled = true; assert!(enforcer.is_network_allowed(&caps)); caps.network.enabled = false; assert!(!enforcer.is_network_allowed(&caps)); } #[test] fn test_domain_restrictions() { let enforcer = CapabilityEnforcer::new().with_network_default(true); let mut caps = Capabilities::default(); caps.network.enabled = true; caps.network.allowed_domains = Some(vec![ "api.example.com".to_string(), "cdn.example.com".to_string(), ]); assert!(enforcer.is_domain_allowed(&caps, "api.example.com")); assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com")); assert!(!enforcer.is_domain_allowed(&caps, "evil.com")); } #[test] fn test_get_effective_limits() { let enforcer = CapabilityEnforcer::new() .with_max_memory(100 * 1024 * 1024) .with_max_cpu_time(30_000); let mut caps = Capabilities::default(); // No limits specified, use the defaults assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); // Plugin requests lower limits, use plugin's caps.max_memory_bytes = Some(50 * 1024 * 1024); caps.max_cpu_time_ms = Some(10_000); assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024); assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000); // Plugin requests higher limits, cap at system max caps.max_memory_bytes = Some(200 * 1024 * 1024); caps.max_cpu_time_ms = Some(60_000); assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); } #[test] fn test_validate_function_call_lifecycle_always_allowed() { let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; assert!(enforcer.validate_function_call(&kinds, "initialize")); assert!(enforcer.validate_function_call(&kinds, "shutdown")); assert!(enforcer.validate_function_call(&kinds, "health_check")); } #[test] fn test_validate_function_call_metadata_extractor() { let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); assert!(enforcer.validate_function_call(&kinds, "supported_types")); assert!(!enforcer.validate_function_call(&kinds, "search")); assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); assert!(!enforcer.validate_function_call(&kinds, "can_handle")); } #[test] fn test_validate_function_call_multi_kind() { let enforcer = CapabilityEnforcer::new(); let kinds = vec!["media_type".to_string(), "metadata_extractor".to_string()]; assert!(enforcer.validate_function_call(&kinds, "can_handle")); assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); assert!(!enforcer.validate_function_call(&kinds, "search")); } #[test] fn test_validate_function_call_unknown_function() { let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); assert!(!enforcer.validate_function_call(&kinds, "")); } #[test] fn test_validate_function_call_shared_supported_types() { let enforcer = CapabilityEnforcer::new(); let extractor = vec!["metadata_extractor".to_string()]; let generator = vec!["thumbnail_generator".to_string()]; let search = vec!["search_backend".to_string()]; assert!(enforcer.validate_function_call(&extractor, "supported_types")); assert!(enforcer.validate_function_call(&generator, "supported_types")); assert!(!enforcer.validate_function_call(&search, "supported_types")); } }