use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Runtime}; pub const PORTABLE_MARKER: &str = "portable.flag"; const PORTABLE_DATA_DIR: &str = "data"; const PORTABLE_LOG_DIR: &str = "logs"; const PORTABLE_WEBVIEW_DIR: &str = "webview"; const ARIA2_DATA_DIR: &str = "aria2"; const ARIA2_DHT_FILE: &str = "dht.dat"; const ARIA2_DHT6_FILE: &str = "dht6.dat"; const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt"; const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub enum StorageMode { Standard, Portable { root: PathBuf }, } impl StorageMode { pub fn detect() -> Self { let Some(executable) = std::env::current_exe().ok() else { return Self::Standard; }; let Some(root) = executable.parent() else { return Self::Standard; }; if root.join(PORTABLE_MARKER).is_file() { Self::Portable { root: root.to_path_buf(), } } else { Self::Standard } } #[cfg(test)] fn detect_from_root(root: &Path) -> Self { if root.join(PORTABLE_MARKER).is_file() { Self::Portable { root: root.to_path_buf(), } } else { Self::Standard } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct StorageLayout { mode: StorageMode, data_dir: PathBuf, log_dir: PathBuf, webview_dir: PathBuf, } impl StorageLayout { pub fn resolve( app_handle: &AppHandle, mode: StorageMode, ) -> Result { let (mode, data_dir, log_dir, webview_dir) = match mode { StorageMode::Standard => ( StorageMode::Standard, app_handle .path() .app_data_dir() .map_err(|error| format!("failed to resolve app data directory: {error}"))?, app_handle .path() .app_log_dir() .map_err(|error| format!("failed to resolve app log directory: {error}"))?, app_handle.path().app_local_data_dir().map_err(|error| { format!("failed to resolve app local data directory: {error}") })?, ), StorageMode::Portable { root } => { let data_dir = root.join(PORTABLE_DATA_DIR); ( StorageMode::Portable { root }, data_dir.clone(), data_dir.join(PORTABLE_LOG_DIR), data_dir.join(PORTABLE_WEBVIEW_DIR), ) } }; Ok(Self { mode, data_dir: canonicalize_storage_path(&data_dir)?, log_dir: canonicalize_storage_path(&log_dir)?, webview_dir: canonicalize_storage_path(&webview_dir)?, }) } pub fn is_portable(&self) -> bool { matches!(self.mode, StorageMode::Portable { .. }) } pub fn data_dir(&self) -> &Path { &self.data_dir } pub fn log_dir(&self) -> &Path { &self.log_dir } pub fn webview_dir(&self) -> &Path { &self.webview_dir } pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) { let directory = self.data_dir.join(ARIA2_DATA_DIR); ( directory.join(ARIA2_DHT_FILE), directory.join(ARIA2_DHT6_FILE), ) } pub fn aria2_server_stat_path(&self) -> PathBuf { self.data_dir .join(ARIA2_DATA_DIR) .join(ARIA2_SERVER_STAT_FILE) } /// Create and validate only Firelink's Aria2 state directory. Aria2 owns /// the table contents; Firelink owns this exact location and must never /// fall back to a user-global default when it cannot establish it. pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> { let directory = self.data_dir.join(ARIA2_DATA_DIR); if crate::path_has_symlink_component(&directory) { return Err(format!( "Aria2 state directory contains a symlink: '{}'", directory.display() )); } match std::fs::symlink_metadata(&directory) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(format!( "Aria2 state directory is a symlink: '{}'", directory.display() )); } Ok(metadata) if !metadata.is_dir() => { return Err(format!( "Aria2 state path is not a directory: '{}'", directory.display() )); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { std::fs::create_dir(&directory).map_err(|error| { format!( "failed to create Aria2 state directory '{}': {error}", directory.display() ) })?; } Err(error) => { return Err(format!( "failed to inspect Aria2 state directory '{}': {error}", directory.display() )); } } Ok(self.aria2_dht_paths()) } /// Prepare the exact cache file used by Aria2's adaptive URI selector. /// The cache is non-authoritative: malformed or oversized contents are /// reset to empty, while symlinks and non-files disable the cache instead /// of allowing Aria2 to write outside Firelink's storage boundary. pub fn prepare_aria2_server_stat_path(&self) -> Result { let directory = self.data_dir.join(ARIA2_DATA_DIR); if crate::path_has_symlink_component(&directory) { return Err("Aria2 server-stat directory contains a symlink".to_string()); } std::fs::create_dir_all(&directory) .map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?; let path = self.aria2_server_stat_path(); match std::fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err("Aria2 server-stat cache is a symlink".to_string()); } Ok(metadata) if !metadata.is_file() => { return Err("Aria2 server-stat cache is not a regular file".to_string()); } Ok(metadata) => { let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES && std::fs::read_to_string(&path) .ok() .is_some_and(|contents| aria2_server_stat_is_valid(&contents)); if !valid { std::fs::OpenOptions::new() .write(true) .truncate(true) .open(&path) .map_err(|error| { format!("failed to reset Aria2 server-stat cache: {error}") })?; } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { std::fs::OpenOptions::new() .create_new(true) .write(true) .open(&path) .map_err(|error| { format!("failed to create Aria2 server-stat cache: {error}") })?; } Err(error) => { return Err(format!( "failed to inspect Aria2 server-stat cache: {error}" )); } } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) .map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?; } Ok(path) } } fn aria2_server_stat_is_valid(contents: &str) -> bool { contents.lines().all(|line| { let line = line.trim(); if line.is_empty() { return true; } if line.chars().any(char::is_control) { return false; } let fields = line .split(',') .filter_map(|field| field.trim().split_once('=')) .map(|(name, value)| (name.trim(), value.trim())) .collect::>(); ["host", "protocol", "dl_speed", "last_updated", "status"] .iter() .all(|name| fields.get(name).is_some_and(|value| !value.is_empty())) }) } fn canonicalize_storage_path(path: &Path) -> Result { if crate::path_has_symlink_component(path) { return Err(format!( "storage path contains a symlinked component: '{}'", path.display() )); } let mut existing = path; let mut missing = Vec::new(); loop { match std::fs::symlink_metadata(existing) { Ok(metadata) => { if metadata.file_type().is_symlink() { return Err(format!( "storage path contains a symlinked directory: '{}'", path.display() )); } break; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(format!( "failed to inspect storage path '{}': {error}", path.display() )); } } missing.push( existing .file_name() .ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))? .to_owned(), ); existing = existing .parent() .ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?; } let mut canonical = std::fs::canonicalize(existing) .map_err(|error| format!("failed to canonicalize storage path '{}': {error}", path.display()))?; for component in missing.iter().rev() { canonical.push(component); } Ok(canonical) } #[cfg(test)] mod tests { use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER}; use std::fs; use std::path::Path; use tempfile::TempDir; #[test] fn marker_selects_portable_mode() { let root = TempDir::new().unwrap(); fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap(); assert_eq!( StorageMode::detect_from_root(root.path()), StorageMode::Portable { root: root.path().to_path_buf() } ); } #[test] fn missing_marker_keeps_standard_mode() { let root = TempDir::new().unwrap(); assert_eq!( StorageMode::detect_from_root(root.path()), StorageMode::Standard ); } fn test_layout(data_dir: &Path) -> StorageLayout { let data_dir = fs::canonicalize(data_dir).unwrap(); StorageLayout { mode: StorageMode::Standard, data_dir: data_dir.clone(), log_dir: data_dir.join("logs"), webview_dir: data_dir.join("webview"), } } #[test] fn aria2_dht_paths_are_owned_by_the_selected_data_directory() { let root = TempDir::new().unwrap(); let layout = test_layout(root.path()); let root_path = fs::canonicalize(root.path()).unwrap(); assert_eq!( layout.aria2_dht_paths(), ( root_path.join("aria2/dht.dat"), root_path.join("aria2/dht6.dat") ) ); let prepared = layout.prepare_aria2_dht_paths().unwrap(); assert_eq!(prepared, layout.aria2_dht_paths()); assert!(root_path.join("aria2").is_dir()); } #[test] fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() { let root = TempDir::new().unwrap(); let root_path = fs::canonicalize(root.path()).unwrap(); fs::write(root_path.join("aria2"), b"not a directory").unwrap(); let error = test_layout(root.path()) .prepare_aria2_dht_paths() .unwrap_err(); assert!(error.contains("not a directory")); } #[test] fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() { let root = TempDir::new().unwrap(); let layout = test_layout(root.path()); layout.prepare_aria2_dht_paths().unwrap(); let path = layout.prepare_aria2_server_stat_path().unwrap(); assert_eq!(path, layout.aria2_server_stat_path()); assert_eq!(fs::read_to_string(&path).unwrap(), ""); fs::write(&path, "not an aria2 server profile\n").unwrap(); layout.prepare_aria2_server_stat_path().unwrap(); assert_eq!(fs::read_to_string(&path).unwrap(), ""); let valid = "host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n"; fs::write(&path, valid).unwrap(); layout.prepare_aria2_server_stat_path().unwrap(); assert_eq!(fs::read_to_string(&path).unwrap(), valid); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600 ); } } #[cfg(unix)] #[test] fn aria2_server_stat_cache_rejects_symlink_output() { use std::os::unix::fs::symlink; let root = TempDir::new().unwrap(); let target = TempDir::new().unwrap(); let layout = test_layout(root.path()); layout.prepare_aria2_dht_paths().unwrap(); symlink( target.path().join("outside"), layout.aria2_server_stat_path(), ) .unwrap(); assert!(layout.prepare_aria2_server_stat_path().is_err()); } #[cfg(unix)] #[test] fn aria2_dht_preparation_rejects_a_symlinked_directory() { use std::os::unix::fs::symlink; let root = TempDir::new().unwrap(); let target = TempDir::new().unwrap(); let root_path = fs::canonicalize(root.path()).unwrap(); symlink(target.path(), root_path.join("aria2")).unwrap(); let error = test_layout(root.path()) .prepare_aria2_dht_paths() .unwrap_err(); assert!(error.contains("symlink")); } #[cfg(unix)] #[test] fn rejects_symlinked_storage_directories() { use std::os::unix::fs::symlink; let root = TempDir::new().unwrap(); let target = TempDir::new().unwrap(); let root_path = fs::canonicalize(root.path()).unwrap(); let redirected = root_path.join("logs"); symlink(target.path(), &redirected).unwrap(); assert!(canonicalize_storage_path(Path::new(&redirected)).is_err()); } #[cfg(unix)] #[test] fn rejects_dangling_symlinked_storage_directories() { use std::os::unix::fs::symlink; let root = TempDir::new().unwrap(); let root_path = fs::canonicalize(root.path()).unwrap(); let redirected = root_path.join("logs"); symlink(root_path.join("missing-target"), &redirected).unwrap(); assert!(canonicalize_storage_path(Path::new(&redirected)).is_err()); } }