Files
driftwood/src/core/sandbox.rs
lashman 830c3cad9d Fix second audit findings and restore crash detection dialog
Address 29 issues found in comprehensive API/spec audit:
- Fix .desktop Exec key path escaping per Desktop Entry spec
- Fix update dialog double-dispatch with connect_response
- Fix version comparison total ordering with lexicographic fallback
- Use RETURNING id for reliable upsert in database
- Replace tilde-based path fallbacks with proper XDG helpers
- Fix backup create/restore path asymmetry for non-home paths
- HTML-escape severity class in security reports
- Use AppStream <custom> element instead of <metadata>
- Fix has_appimage_update_tool to check .is_ok() not .success()
- Use ListBoxRow instead of ActionRow::set_child in ExpanderRow
- Add ELF magic validation to architecture detection
- Add timeout to extract_update_info_runtime
- Skip symlinks in dir_size calculation
- Use Condvar instead of busy-wait in analysis thread pool
- Restore crash detection to single blocking call architecture
2026-02-27 22:48:43 +02:00

405 lines
13 KiB
Rust

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 = 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<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());
}
}