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
405 lines
13 KiB
Rust
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());
|
|
}
|
|
}
|