Add Phase 5 enhancements: security, i18n, analysis, backup, notifications
- Database v8 migration: tags, pinned, avg_startup_ms columns - Security scanning with CVE matching and batch scan - Bundled library extraction and vulnerability reports - Desktop notification system for security alerts - Backup/restore system for AppImage configurations - i18n framework with gettext support - Runtime analysis and Wayland compatibility detection - AppStream metadata and Flatpak-style build support - File watcher module for live directory monitoring - Preferences panel with GSettings integration - CLI interface for headless operation - Detail view: tabbed layout with ViewSwitcher in title bar, health score, sandbox controls, changelog links - Library view: sort dropdown, context menu enhancements - Dashboard: system status, disk usage, launch history - Security report page with scan and export - Packaging: meson build, PKGBUILD, metainfo
This commit is contained in:
405
src/core/sandbox.rs
Normal file
405
src/core/sandbox.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
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<i64>,
|
||||
pub app_name: String,
|
||||
pub profile_version: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub description: Option<String>,
|
||||
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 = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.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<PathBuf, SandboxError> {
|
||||
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<Option<SandboxProfile>, 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<SandboxProfile> {
|
||||
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<Vec<CommunityProfileEntry>, 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<CommunityProfileEntry> = 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<SandboxProfile, SanboxError> {
|
||||
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<PathBuf> {
|
||||
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<CommunityProfileEntry>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
}
|
||||
|
||||
// --- 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::<String>()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user