use std::fs; use std::path::PathBuf; use super::database::Database; /// A sandbox profile that can be applied to an AppImage when launching with Firejail. #[derive(Debug, Clone)] pub struct SandboxProfile { pub id: Option, pub app_name: String, pub profile_version: Option, pub author: Option, pub description: Option, pub content: String, pub source: ProfileSource, } #[derive(Debug, Clone, PartialEq)] pub enum ProfileSource { Local, Community { registry_id: String }, FirejailDefault, } impl ProfileSource { pub fn as_str(&self) -> &str { match self { Self::Local => "local", Self::Community { .. } => "community", Self::FirejailDefault => "firejail-default", } } pub fn from_record(source: &str, registry_id: Option<&str>) -> Self { match source { "community" => Self::Community { registry_id: registry_id.unwrap_or("").to_string(), }, "firejail-default" => Self::FirejailDefault, _ => Self::Local, } } } /// Directory where local sandbox profiles are stored. fn profiles_dir() -> PathBuf { let dir = crate::config::config_dir_fallback() .join("driftwood") .join("sandbox"); fs::create_dir_all(&dir).ok(); dir } /// Save a sandbox profile to local storage and the database. pub fn save_profile(db: &Database, profile: &SandboxProfile) -> Result { let filename = sanitize_profile_name(&profile.app_name); let path = profiles_dir().join(format!("{}.profile", filename)); // Write profile content with metadata header let full_content = format_profile_with_header(profile); fs::write(&path, &full_content).map_err(|e| SandboxError::Io(e.to_string()))?; // Store in database db.insert_sandbox_profile( &profile.app_name, profile.profile_version.as_deref(), profile.author.as_deref(), profile.description.as_deref(), &profile.content, profile.source.as_str(), match &profile.source { ProfileSource::Community { registry_id } => Some(registry_id.as_str()), _ => None, }, ).map_err(|e| SandboxError::Database(e.to_string()))?; Ok(path) } /// Load the most recent sandbox profile for an app from the database. pub fn load_profile(db: &Database, app_name: &str) -> Result, SandboxError> { let record = db.get_sandbox_profile_for_app(app_name) .map_err(|e| SandboxError::Database(e.to_string()))?; Ok(record.map(|r| SandboxProfile { id: Some(r.id), app_name: r.app_name, profile_version: r.profile_version, author: r.author, description: r.description, content: r.content.clone(), source: ProfileSource::from_record(&r.source, r.registry_id.as_deref()), })) } /// Delete a sandbox profile by ID. pub fn delete_profile(db: &Database, profile_id: i64) -> Result<(), SandboxError> { db.delete_sandbox_profile(profile_id) .map_err(|e| SandboxError::Database(e.to_string()))?; Ok(()) } /// List all local sandbox profiles. pub fn list_profiles(db: &Database) -> Vec { let records = db.get_all_sandbox_profiles().unwrap_or_default(); records.into_iter().map(|r| SandboxProfile { id: Some(r.id), app_name: r.app_name, profile_version: r.profile_version, author: r.author, description: r.description, content: r.content.clone(), source: ProfileSource::from_record(&r.source, r.registry_id.as_deref()), }).collect() } /// Search the community registry for sandbox profiles matching an app name. /// Uses the GitHub-based registry approach (fetches a JSON index). pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result, SanboxError> { let index_url = format!("{}/index.json", registry_url.trim_end_matches('/')); let response = ureq::get(&index_url) .call() .map_err(|e| SanboxError::Network(e.to_string()))?; let body = response.into_body().read_to_string() .map_err(|e| SanboxError::Network(e.to_string()))?; let index: CommunityIndex = serde_json::from_str(&body) .map_err(|e| SanboxError::Parse(e.to_string()))?; let query = app_name.to_lowercase(); let matches: Vec = index.profiles .into_iter() .filter(|p| p.app_name.to_lowercase().contains(&query)) .collect(); Ok(matches) } /// Download a community profile by its URL and save it locally. pub fn download_community_profile( db: &Database, entry: &CommunityProfileEntry, ) -> Result { let response = ureq::get(&entry.url) .call() .map_err(|e| SanboxError::Network(e.to_string()))?; let content = response.into_body().read_to_string() .map_err(|e| SanboxError::Network(e.to_string()))?; let profile = SandboxProfile { id: None, app_name: entry.app_name.clone(), profile_version: Some(entry.version.clone()), author: Some(entry.author.clone()), description: Some(entry.description.clone()), content, source: ProfileSource::Community { registry_id: entry.id.clone(), }, }; save_profile(db, &profile) .map_err(|e| SanboxError::Io(e.to_string()))?; Ok(profile) } /// Generate a default restrictive sandbox profile for an app. pub fn generate_default_profile(app_name: &str) -> SandboxProfile { let content = format!( "# Default Driftwood sandbox profile for {}\n\ # Generated automatically - review and customize before use\n\ \n\ include disable-common.inc\n\ include disable-devel.inc\n\ include disable-exec.inc\n\ include disable-interpreters.inc\n\ include disable-programs.inc\n\ \n\ whitelist ${{HOME}}/Documents\n\ whitelist ${{HOME}}/Downloads\n\ \n\ caps.drop all\n\ ipc-namespace\n\ netfilter\n\ no3d\n\ nodvd\n\ nogroups\n\ noinput\n\ nonewprivs\n\ noroot\n\ nosound\n\ notv\n\ nou2f\n\ novideo\n\ seccomp\n\ tracelog\n", app_name, ); SandboxProfile { id: None, app_name: app_name.to_string(), profile_version: Some("1.0".to_string()), author: Some("driftwood".to_string()), description: Some(format!("Default restrictive profile for {}", app_name)), content, source: ProfileSource::Local, } } /// Get the path to the profile file for an app (for passing to firejail --profile=). pub fn profile_path_for_app(app_name: &str) -> Option { let filename = sanitize_profile_name(app_name); let path = profiles_dir().join(format!("{}.profile", filename)); if path.exists() { Some(path) } else { None } } // --- Community registry types --- #[derive(Debug, Clone, serde::Deserialize)] pub struct CommunityIndex { pub profiles: Vec, } #[derive(Debug, Clone, serde::Deserialize)] pub struct CommunityProfileEntry { pub id: String, pub app_name: String, pub author: String, pub version: String, pub description: String, pub url: String, pub downloads: Option, } // --- Error types --- #[derive(Debug)] pub enum SandboxError { Io(String), Database(String), } #[derive(Debug)] pub enum SanboxError { Network(String), Parse(String), Io(String), } impl std::fmt::Display for SandboxError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "I/O error: {}", e), Self::Database(e) => write!(f, "Database error: {}", e), } } } impl std::fmt::Display for SanboxError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Network(e) => write!(f, "Network error: {}", e), Self::Parse(e) => write!(f, "Parse error: {}", e), Self::Io(e) => write!(f, "I/O error: {}", e), } } } // --- Utility functions --- fn sanitize_profile_name(name: &str) -> String { name.chars() .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' }) .collect::() .trim_matches('-') .to_string() } fn format_profile_with_header(profile: &SandboxProfile) -> String { let mut header = String::new(); header.push_str("# Driftwood Sandbox Profile\n"); header.push_str(&format!("# App: {}\n", profile.app_name)); if let Some(v) = &profile.profile_version { header.push_str(&format!("# Version: {}\n", v)); } if let Some(a) = &profile.author { header.push_str(&format!("# Author: {}\n", a)); } if let Some(d) = &profile.description { header.push_str(&format!("# Description: {}\n", d)); } header.push_str(&format!("# Source: {}\n", profile.source.as_str())); header.push('\n'); header.push_str(&profile.content); header } #[cfg(test)] mod tests { use super::*; #[test] fn test_sanitize_profile_name() { assert_eq!(sanitize_profile_name("Firefox"), "firefox"); assert_eq!(sanitize_profile_name("My Cool App"), "my-cool-app"); assert_eq!(sanitize_profile_name("GIMP 2.10"), "gimp-2-10"); } #[test] fn test_profile_source_as_str() { assert_eq!(ProfileSource::Local.as_str(), "local"); assert_eq!(ProfileSource::FirejailDefault.as_str(), "firejail-default"); assert_eq!( ProfileSource::Community { registry_id: "test".to_string() }.as_str(), "community" ); } #[test] fn test_profile_source_from_record() { assert_eq!( ProfileSource::from_record("local", None), ProfileSource::Local ); assert_eq!( ProfileSource::from_record("firejail-default", None), ProfileSource::FirejailDefault ); match ProfileSource::from_record("community", Some("firefox-strict")) { ProfileSource::Community { registry_id } => assert_eq!(registry_id, "firefox-strict"), other => panic!("Expected Community, got {:?}", other), } } #[test] fn test_generate_default_profile() { let profile = generate_default_profile("Firefox"); assert_eq!(profile.app_name, "Firefox"); assert!(profile.content.contains("disable-common.inc")); assert!(profile.content.contains("seccomp")); assert!(profile.content.contains("nonewprivs")); assert!(profile.content.contains("Downloads")); } #[test] fn test_format_profile_with_header() { let profile = SandboxProfile { id: None, app_name: "TestApp".to_string(), profile_version: Some("1.0".to_string()), author: Some("tester".to_string()), description: Some("Test profile".to_string()), content: "include disable-common.inc\n".to_string(), source: ProfileSource::Local, }; let output = format_profile_with_header(&profile); assert!(output.starts_with("# Driftwood Sandbox Profile\n")); assert!(output.contains("# App: TestApp")); assert!(output.contains("# Version: 1.0")); assert!(output.contains("# Author: tester")); assert!(output.contains("include disable-common.inc")); } #[test] fn test_profiles_dir_path() { let dir = profiles_dir(); assert!(dir.to_string_lossy().contains("driftwood")); assert!(dir.to_string_lossy().contains("sandbox")); } #[test] fn test_sandbox_error_display() { let err = SandboxError::Io("permission denied".to_string()); assert!(format!("{}", err).contains("permission denied")); let err = SandboxError::Database("db locked".to_string()); assert!(format!("{}", err).contains("db locked")); } #[test] fn test_save_and_load_profile() { let db = crate::core::database::Database::open_in_memory().unwrap(); let profile = generate_default_profile("TestSaveApp"); let result = save_profile(&db, &profile); assert!(result.is_ok()); let loaded = load_profile(&db, "TestSaveApp").unwrap(); assert!(loaded.is_some()); let loaded = loaded.unwrap(); assert_eq!(loaded.app_name, "TestSaveApp"); assert!(loaded.content.contains("seccomp")); } #[test] fn test_list_profiles_empty() { let db = crate::core::database::Database::open_in_memory().unwrap(); let profiles = list_profiles(&db); assert!(profiles.is_empty()); } }