use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; const DATABASE_NAME: &str = "firelink.sqlite"; const LEGACY_STORE_NAME: &str = "store.bin"; const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; const CURRENT_SCHEMA_VERSION: i64 = 1; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; const KEYCHAIN_SERVICE: &str = "com.firelink.app"; static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(()); fn is_database_path(path: &Path) -> bool { path.file_name().is_some_and(|name| { name == DATABASE_NAME || name .to_string_lossy() .starts_with(&format!("{DATABASE_NAME}.backup-")) }) } pub struct DbState { conn: Mutex, portable: bool, } impl DbState { pub fn lock(&self) -> Result, String> { self.conn .lock() .map_err(|_| "persistence database lock is unavailable".to_string()) } } #[derive(Default)] struct LegacyData { settings: Option, downloads: Vec, queues: Vec, ownership: Vec<(String, String)>, pairing_token: Option, } pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result { init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable()) } #[cfg(test)] fn init_at_path(app_data_dir: &Path) -> Result { init_at_path_internal(app_data_dir, false) } fn init_at_path_internal( app_data_dir: &Path, portable: bool, ) -> Result { fs::create_dir_all(app_data_dir) .map_err(|error| format!("failed to create app data directory: {error}"))?; let database_path = app_data_dir.join(DATABASE_NAME); let existed = database_path.exists(); let mut connection = Connection::open(&database_path) .map_err(|error| format!("failed to open database: {error}"))?; let version = connection .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .map_err(|error| format!("failed to read database schema version: {error}"))?; // Portable mode intentionally does not create raw migration backups: // those backups would duplicate any legacy transfer secrets beside the // executable. The imported data is sanitized before the portable DB is // used, and any legacy source is sanitized in place after a successful // import so it cannot remain as an unsanitized sidecar. if existed && version < CURRENT_SCHEMA_VERSION && !portable { backup_database(&connection, &database_path, &format!("schema-v{version}"))?; } migrate_schema(&mut connection, version)?; import_legacy_data(&mut connection, app_data_dir, portable)?; if portable { sanitize_persisted_downloads(&mut connection)?; } Ok(DbState { conn: Mutex::new(connection), portable, }) } impl DbState { pub fn is_portable(&self) -> bool { self.portable } } fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), String> { if from_version > CURRENT_SCHEMA_VERSION { return Err(format!( "database schema version {from_version} is newer than supported version {CURRENT_SCHEMA_VERSION}" )); } let transaction = connection .transaction() .map_err(|error| format!("failed to begin database migration: {error}"))?; if from_version < 1 { transaction .execute_batch( " CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS queues ( id TEXT PRIMARY KEY, data TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS download_ownership ( id TEXT PRIMARY KEY, primary_path TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS migration_events ( key TEXT PRIMARY KEY, consumed INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); ", ) .map_err(|error| format!("failed to create persistence tables: {error}"))?; if table_exists(&transaction, "downloads")? { let queue_id_not_null = column_is_not_null(&transaction, "downloads", "queue_id")?; if queue_id_not_null { transaction .execute_batch( " DROP TABLE IF EXISTS downloads_v0; ALTER TABLE downloads RENAME TO downloads_v0; CREATE TABLE downloads ( id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL ); INSERT INTO downloads (id, status, queue_id, data) SELECT id, status, queue_id, data FROM downloads_v0; DROP TABLE downloads_v0; ", ) .map_err(|error| format!("failed to migrate downloads table: {error}"))?; } } else { transaction .execute_batch( " CREATE TABLE downloads ( id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL ); ", ) .map_err(|error| format!("failed to create downloads table: {error}"))?; } } transaction .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) .map_err(|error| format!("failed to update database schema version: {error}"))?; transaction .commit() .map_err(|error| format!("failed to commit database migration: {error}")) } fn import_legacy_data( connection: &mut Connection, app_data_dir: &Path, portable: bool, ) -> Result<(), String> { let legacy_app_dir = app_data_dir .parent() .map(|parent| parent.join(LEGACY_BUNDLE_IDENTIFIER)); let candidates = [ Some(app_data_dir.join(LEGACY_STORE_NAME)), legacy_app_dir.as_ref().map(|dir| dir.join(DATABASE_NAME)), legacy_app_dir .as_ref() .map(|dir| dir.join(LEGACY_STORE_NAME)), ]; for candidate in candidates.into_iter().flatten() { if !candidate.exists() { continue; } let marker = format!("legacy-import:{}", candidate.to_string_lossy()); if metadata_exists(connection, &marker)? { sanitize_legacy_source(&candidate, !portable)?; continue; } let backup = if !portable { Some(backup_file(&candidate, "legacy-import")?) } else { None }; let mut legacy = if candidate .file_name() .is_some_and(|name| name == DATABASE_NAME) { read_legacy_database(&candidate, !portable)? } else { read_legacy_store(&candidate, !portable)? }; let mut pending_pairing_token = None; if !portable { if let Some(token) = legacy.pairing_token.take() { // Legacy migration is deliberately deferred until the // explicit frontend consent action. Database initialization // must never touch the OS credential store. pending_pairing_token = Some(token); } } if portable { // Sanitize before importing as well as sanitizing the legacy // source afterward. A crash between those two operations must // not leave raw transfer credentials in the portable database. sanitize_download_strings(&mut legacy.downloads)?; } merge_legacy_data(connection, legacy)?; if let Some(token) = pending_pairing_token { if load_pairing_token_from_settings(connection)?.is_none() { save_pairing_token_to_settings(connection, &token, true)?; } } if let Some(backup) = backup.as_deref() { sanitize_legacy_source(backup, !portable)?; } sanitize_legacy_source(&candidate, !portable)?; connection .execute( "INSERT INTO metadata (key, value) VALUES (?1, 'complete') ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![marker], ) .map_err(|error| format!("failed to record legacy import: {error}"))?; } Ok(()) } fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> { if is_database_path(path) { let mut connection = Connection::open(path).map_err(|error| { format!( "failed to open legacy database '{}' for sanitization: {error}", path.display() ) })?; if remove_pairing_token && table_exists(&connection, "settings")? { if let Some(settings) = connection .query_row("SELECT data FROM settings WHERE id = 1", [], |row| { row.get::<_, String>(0) }) .optional() .map_err(|error| format!("failed to read legacy settings for sanitization: {error}"))? { let sanitized = strip_pairing_token_from_settings(&settings)?; connection .execute( "UPDATE settings SET data = ?1 WHERE id = 1", params![sanitized], ) .map_err(|error| format!("failed to sanitize legacy settings: {error}"))?; } } if table_exists(&connection, "downloads")? { sanitize_persisted_downloads(&mut connection)?; } return Ok(()); } let text = fs::read_to_string(path).map_err(|error| { format!( "failed to read legacy store '{}' for sanitization: {error}", path.display() ) })?; let mut document: Value = serde_json::from_str(&text).map_err(|error| { format!( "failed to decode legacy store '{}' for sanitization: {error}", path.display() ) })?; if remove_pairing_token { if let Some(settings) = document.get_mut("settings") { let was_string = settings.is_string(); let (sanitized, _, _) = sanitize_settings_value(settings, true)?; *settings = if was_string { Value::String(sanitized) } else { serde_json::from_str(&sanitized).map_err(|error| { format!("failed to decode sanitized legacy settings: {error}") })? }; } } if let Some(downloads) = document .get_mut("download_queue") .and_then(Value::as_array_mut) { for download in downloads { remove_persisted_transfer_secrets(download); } } write_sanitized_legacy_store(path, &text, &document) } fn write_sanitized_legacy_store( path: &Path, original: &str, document: &Value, ) -> Result<(), String> { let sanitized = serde_json::to_string(&document).map_err(|error| { format!( "failed to encode legacy store '{}' for sanitization: {error}", path.display() ) })?; if sanitized == original { return Ok(()); } let parent = path .parent() .ok_or_else(|| format!("legacy store path has no parent: '{}'", path.display()))?; use std::io::Write; let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { format!( "failed to create temporary sanitized legacy store beside '{}': {error}", path.display() ) })?; temporary .write_all(sanitized.as_bytes()) .and_then(|_| temporary.flush()) .map_err(|error| { format!( "failed to write temporary sanitized legacy store beside '{}': {error}", path.display() ) })?; temporary.persist(path).map_err(|error| { format!( "failed to replace legacy store '{}' without losing the original: {}", path.display(), error.error ) })?; Ok(()) } fn merge_legacy_data(connection: &mut Connection, legacy: LegacyData) -> Result<(), String> { let transaction = connection .transaction() .map_err(|error| format!("failed to begin legacy import: {error}"))?; let has_settings = transaction .query_row( "SELECT EXISTS(SELECT 1 FROM settings WHERE id = 1)", [], |row| row.get::<_, bool>(0), ) .map_err(|error| format!("failed to inspect persisted settings: {error}"))?; if !has_settings { if let Some(settings) = legacy.settings { save_settings_tx(&transaction, &settings)?; } } let download_count: i64 = transaction .query_row("SELECT COUNT(*) FROM downloads", [], |row| row.get(0)) .map_err(|error| format!("failed to inspect persisted downloads: {error}"))?; if download_count == 0 { replace_downloads_tx(&transaction, &legacy.downloads)?; } let queue_count: i64 = transaction .query_row("SELECT COUNT(*) FROM queues", [], |row| row.get(0)) .map_err(|error| format!("failed to inspect persisted queues: {error}"))?; if queue_count == 0 { replace_queues_tx(&transaction, &legacy.queues)?; } for (id, primary_path) in legacy.ownership { transaction .execute( "INSERT OR IGNORE INTO download_ownership (id, primary_path) VALUES (?1, ?2)", params![id, primary_path], ) .map_err(|error| format!("failed to import download ownership: {error}"))?; } transaction .commit() .map_err(|error| format!("failed to commit legacy import: {error}"))?; Ok(()) } fn read_legacy_store(path: &Path, force_migrate: bool) -> Result { let text = fs::read_to_string(path) .map_err(|error| format!("failed to read legacy store '{}': {error}", path.display()))?; let document: Value = serde_json::from_str(&text).map_err(|error| { format!( "failed to decode legacy store '{}': {error}", path.display() ) })?; let mut data = LegacyData::default(); if let Some(settings) = document.get("settings") { let (sanitized, token, _) = sanitize_settings_value(settings, force_migrate)?; data.settings = Some(sanitized); data.pairing_token = token; } data.downloads = json_array_as_strings(document.get("download_queue"))?; data.queues = json_array_as_strings(document.get("queues"))?; data.ownership = document .get("download_ownership") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|record| { Some(( record.get("id")?.as_str()?.to_string(), record.get("primaryPath")?.as_str()?.to_string(), )) }) .collect(); Ok(data) } fn read_legacy_database(path: &Path, force_migrate: bool) -> Result { let connection = Connection::open(path).map_err(|error| { format!( "failed to open legacy database '{}': {error}", path.display() ) })?; let mut data = LegacyData::default(); if table_exists(&connection, "settings")? { if let Some(settings) = connection .query_row("SELECT data FROM settings WHERE id = 1", [], |row| { row.get::<_, String>(0) }) .optional() .map_err(|error| format!("failed to read legacy settings: {error}"))? { let (sanitized, token, _) = sanitize_settings_text(&settings, force_migrate)?; data.settings = Some(sanitized); data.pairing_token = token; } } if table_exists(&connection, "downloads")? { data.downloads = query_string_column(&connection, "SELECT data FROM downloads")?; } if table_exists(&connection, "queues")? { data.queues = query_string_column(&connection, "SELECT data FROM queues")?; } Ok(data) } fn sanitize_settings_value( value: &Value, force_migrate: bool, ) -> Result<(String, Option, bool), String> { match value { Value::String(text) => sanitize_settings_text(text, force_migrate), _ => sanitize_settings_document(value.clone(), force_migrate), } } fn sanitize_settings_text( text: &str, force_migrate: bool, ) -> Result<(String, Option, bool), String> { let document: Value = serde_json::from_str(text) .map_err(|error| format!("failed to decode persisted settings: {error}"))?; sanitize_settings_document(document, force_migrate) } fn sanitize_settings_document( mut document: Value, force_migrate: bool, ) -> Result<(String, Option, bool), String> { let state_value = if document.get("state").is_some() { document .get_mut("state") .ok_or_else(|| "persisted settings state is missing".to_string())? } else { &mut document }; let state = state_value .as_object_mut() .ok_or_else(|| "persisted settings state must be an object".to_string())?; let keychain_granted = state .get("keychainAccessGranted") .and_then(|v| v.as_bool()) .unwrap_or(false); let should_migrate = force_migrate || keychain_granted; let token = if should_migrate { state .remove("extensionPairingToken") .and_then(|value| value.as_str().map(str::to_string)) } else { None }; let serialized = serde_json::to_string(&document) .map_err(|error| format!("failed to encode persisted settings: {error}"))?; Ok((serialized, token, keychain_granted)) } fn json_array_as_strings(value: Option<&Value>) -> Result, String> { value .and_then(Value::as_array) .map(|items| { items .iter() .map(|item| { serde_json::to_string(item) .map_err(|error| format!("failed to encode legacy item: {error}")) }) .collect() }) .unwrap_or_else(|| Ok(Vec::new())) } fn query_string_column(connection: &Connection, query: &str) -> Result, String> { let mut statement = connection .prepare(query) .map_err(|error| format!("failed to prepare legacy query: {error}"))?; let rows = statement .query_map([], |row| row.get::<_, String>(0)) .map_err(|error| format!("failed to query legacy data: {error}"))?; rows.collect::, _>>() .map_err(|error| format!("failed to read legacy data: {error}")) } fn backup_file(path: &Path, reason: &str) -> Result { let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let file_name = path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("invalid persistence file path '{}'", path.display()))?; let backup_prefix = format!("{file_name}.backup-{reason}-"); if let Some(existing) = path.parent().and_then(|parent| { fs::read_dir(parent).ok()?.flatten().find_map(|entry| { entry .file_name() .to_string_lossy() .starts_with(&backup_prefix) .then_some(entry.path()) }) }) { return Ok(existing); } let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); if backup_path.exists() { return Ok(backup_path); } fs::copy(path, &backup_path).map_err(|error| { format!( "failed to back up persistence file '{}' to '{}': {error}", path.display(), backup_path.display() ) })?; Ok(backup_path) } fn backup_database(connection: &Connection, path: &Path, reason: &str) -> Result { let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let file_name = path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("invalid database path '{}'", path.display()))?; let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); connection .execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()]) .map_err(|error| { format!( "failed to back up database '{}' to '{}': {error}", path.display(), backup_path.display() ) })?; Ok(backup_path) } fn table_exists(connection: &Connection, table: &str) -> Result { connection .query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", params![table], |row| row.get(0), ) .map_err(|error| format!("failed to inspect database table '{table}': {error}")) } fn metadata_exists(connection: &Connection, key: &str) -> Result { connection .query_row( "SELECT EXISTS(SELECT 1 FROM metadata WHERE key = ?1)", params![key], |row| row.get(0), ) .map_err(|error| format!("failed to inspect migration metadata: {error}")) } fn column_is_not_null(connection: &Connection, table: &str, column: &str) -> Result { let mut statement = connection .prepare(&format!("PRAGMA table_info({table})")) .map_err(|error| format!("failed to inspect table '{table}': {error}"))?; let rows = statement .query_map([], |row| { Ok((row.get::<_, String>(1)?, row.get::<_, bool>(3)?)) }) .map_err(|error| format!("failed to inspect table '{table}': {error}"))?; for row in rows { let (name, not_null) = row.map_err(|error| format!("failed to inspect table '{table}': {error}"))?; if name == column { return Ok(not_null); } } Ok(false) } pub fn load_settings(connection: &Connection) -> Result, String> { connection .query_row("SELECT data FROM settings WHERE id = 1", [], |row| { row.get(0) }) .optional() .map_err(|error| format!("failed to load settings: {error}")) } #[allow(dead_code)] pub fn is_keychain_access_granted(connection: &Connection) -> Result { let Some(settings) = load_settings(connection)? else { return Ok(false); }; let document: Value = serde_json::from_str(&settings) .map_err(|error| format!("failed to decode settings: {error}"))?; let granted = document .get("state") .and_then(|s| s.get("keychainAccessGranted")) .and_then(|v| v.as_bool()) .unwrap_or(false); Ok(granted) } pub fn save_settings(connection: &Connection, data: &str) -> Result<(), String> { connection .execute( "INSERT INTO settings (id, data) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET data = excluded.data", params![data], ) .map_err(|error| format!("failed to save settings: {error}"))?; Ok(()) } fn settings_state_mut( document: &mut Value, ) -> Result<&mut serde_json::Map, String> { if document.get("state").is_some() { document .get_mut("state") .and_then(Value::as_object_mut) .ok_or_else(|| "persisted settings state must be an object".to_string()) } else { document .as_object_mut() .ok_or_else(|| "persisted settings must be an object".to_string()) } } fn add_site_login_to_settings( data: &str, id: &str, url_pattern: &str, username: &str, ) -> Result { let mut document: Value = serde_json::from_str(data) .map_err(|error| format!("failed to decode settings: {error}"))?; let state = settings_state_mut(&mut document)?; let logins = state .entry("siteLogins") .or_insert_with(|| Value::Array(Vec::new())); let logins = logins .as_array_mut() .ok_or_else(|| "persisted site logins must be an array".to_string())?; if logins.iter().any(|login| { login .get("id") .and_then(Value::as_str) .is_some_and(|existing_id| existing_id == id) }) { return Err("site login already exists".to_string()); } logins.push(serde_json::json!({ "id": id, "urlPattern": url_pattern, "username": username, })); serde_json::to_string(&document) .map_err(|error| format!("failed to encode settings: {error}")) } fn remove_site_login_from_settings( data: &str, id: &str, ) -> Result<(String, bool), String> { let mut document: Value = serde_json::from_str(data) .map_err(|error| format!("failed to decode settings: {error}"))?; let state = settings_state_mut(&mut document)?; let Some(logins) = state.get_mut("siteLogins") else { return Ok((data.to_string(), false)); }; let logins = logins .as_array_mut() .ok_or_else(|| "persisted site logins must be an array".to_string())?; let original_len = logins.len(); logins.retain(|login| { login .get("id") .and_then(Value::as_str) .is_none_or(|existing_id| existing_id != id) }); if logins.len() == original_len { return Ok((data.to_string(), false)); } let updated = serde_json::to_string(&document) .map_err(|error| format!("failed to encode settings: {error}"))?; Ok((updated, true)) } fn validate_site_login_id(id: &str) -> Result<&str, String> { let trimmed = id.trim(); if trimmed.is_empty() || trimmed != id || trimmed == PAIRING_TOKEN_KEYCHAIN_ID { return Err("invalid site login identifier".to_string()); } Ok(trimmed) } fn validate_site_login_input( id: &str, url_pattern: &str, username: &str, password: &str, ) -> Result<(), String> { validate_site_login_id(id)?; if url_pattern.trim().is_empty() || url_pattern.chars().any(char::is_whitespace) { return Err("site login URL pattern must be non-empty and contain no whitespace".to_string()); } if username.trim().is_empty() { return Err("site login username must be non-empty".to_string()); } if password.is_empty() { return Err("site login password must be non-empty".to_string()); } Ok(()) } pub fn save_site_login( connection: &Connection, id: &str, url_pattern: &str, username: &str, password: &str, ) -> Result<(), String> { validate_site_login_input(id, url_pattern, username, password)?; let id = validate_site_login_id(id)?; let _keyring_guard = lock_keyring_operations()?; let original = load_settings(connection)?.unwrap_or_else(|| { // A first-run standard-mode install can grant keychain access before // any frontend setting has been persisted. Start with a valid // Zustand envelope so the first site login is not rejected. serde_json::json!({ "state": {}, "version": 3 }).to_string() }); if get_keychain_password_if_present_unlocked(id)?.is_some() { return Err("a credential already exists for this site login".to_string()); } let updated = add_site_login_to_settings(&original, id, url_pattern, username)?; // Persist metadata before creating the secret. If the credential-store // write fails, restore the exact previous settings document so a failed // add cannot leave an orphaned keychain entry or a visible login row. save_settings(connection, &updated)?; if let Err(error) = set_keychain_password_unlocked(id, password) { let keychain_rollback = delete_keychain_password_unlocked(id); let settings_rollback = save_settings(connection, &original); if let Err(rollback_error) = keychain_rollback { return Err(format!( "failed to save site login credential: {error}; credential rollback also failed: {rollback_error}" )); } if let Err(rollback_error) = settings_rollback { return Err(format!( "failed to save site login credential: {error}; settings rollback also failed: {rollback_error}" )); } return Err(format!("failed to save site login credential: {error}")); } Ok(()) } pub fn delete_site_login(connection: &Connection, id: &str) -> Result<(), String> { let id = validate_site_login_id(id)?; let _keyring_guard = lock_keyring_operations()?; let Some(original) = load_settings(connection)? else { return Err("settings are not persisted yet".to_string()); }; let (updated, removed) = remove_site_login_from_settings(&original, id)?; if !removed { return Err("site login was not found".to_string()); } let _existing_password = get_keychain_password_if_present_unlocked(id)?; // Remove the metadata first only after the keychain has been checked. If // deleting the secret fails, restore the metadata so the UI cannot lose a // credential that still exists. save_settings(connection, &updated)?; if let Err(error) = delete_keychain_password_unlocked(id) { if matches!( get_keychain_password_if_present_unlocked(id), Ok(None) ) { return Ok(()); } if let Err(rollback_error) = save_settings(connection, &original) { return Err(format!( "failed to delete site login credential: {error}; settings rollback also failed: {rollback_error}" )); } return Err(format!("failed to delete site login credential: {error}")); } Ok(()) } fn save_settings_tx(transaction: &Transaction<'_>, data: &str) -> Result<(), String> { transaction .execute( "INSERT INTO settings (id, data) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET data = excluded.data", params![data], ) .map_err(|error| format!("failed to import settings: {error}"))?; Ok(()) } pub fn load_downloads(connection: &Connection) -> Result, String> { query_string_column(connection, "SELECT data FROM downloads ORDER BY rowid") } pub fn replace_downloads( connection: &mut Connection, data: &str, portable: bool, ) -> Result<(), String> { let values: Vec = serde_json::from_str(data) .map_err(|error| format!("failed to decode downloads: {error}"))?; let strings = values .into_iter() .map(|mut value| { if portable { remove_persisted_transfer_secrets(&mut value); } serde_json::to_string(&value) .map_err(|error| format!("failed to encode download: {error}")) }) .collect::, _>>()?; let transaction = connection .transaction() .map_err(|error| format!("failed to begin download save: {error}"))?; replace_downloads_tx(&transaction, &strings)?; transaction .commit() .map_err(|error| format!("failed to commit download save: {error}")) } fn remove_persisted_transfer_secrets(value: &mut Value) { let Some(object) = value.as_object_mut() else { return; }; // These values are accepted from users, browser extensions, or URLs and // may contain credentials or bearer tokens. Portable queues keep their // useful metadata, but never persist these values beside the executable. let mut removed_transfer_context = false; for key in ["password", "cookies", "headers", "mirrors", "proxy"] { if object .get(key) .is_some_and(|value| !value.is_null() && !value_is_empty(value)) { removed_transfer_context = true; } object.remove(key); } if let Some(last_error) = object.get("lastError").and_then(Value::as_str) { object.insert( "lastError".to_string(), Value::String(crate::redact_sensitive_text(last_error)), ); } if let Some(url) = object.get("url").and_then(Value::as_str) { if let Ok(mut parsed) = url::Url::parse(url) { let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some(); let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); if had_userinfo || had_query_or_fragment { let _ = parsed.set_username(""); let _ = parsed.set_password(None); parsed.set_query(None); parsed.set_fragment(None); object.insert("url".to_string(), Value::String(parsed.to_string())); // A queued transfer whose URL depended on query/fragment // credentials must not silently auto-resume with a truncated // URL after a portable restart. if had_userinfo || had_query_or_fragment { mark_portable_download_unresumable(object); } } } else { object.insert("url".to_string(), Value::String(String::new())); mark_portable_download_unresumable(object); } } // Do not silently resume a queued transfer after removing request // credentials or other transfer-specific context. The URL may still be // valid, but its semantics have changed and the user must re-add it with // the required request settings. if removed_transfer_context { mark_portable_download_unresumable(object); } } fn value_is_empty(value: &Value) -> bool { value.as_str().is_some_and(str::is_empty) || value.as_array().is_some_and(Vec::is_empty) || value.as_object().is_some_and(serde_json::Map::is_empty) } fn mark_portable_download_unresumable(object: &mut serde_json::Map) { if object .get("status") .and_then(Value::as_str) .is_some_and(|status| status != "completed") { object.insert("status".to_string(), Value::String("failed".to_string())); object.insert("resumable".to_string(), Value::Bool(false)); object.insert( "lastError".to_string(), Value::String( "Portable mode removed credentials or transfer settings from this persisted download; add it again to resume." .to_string(), ), ); } } fn sanitize_download_strings(downloads: &mut [String]) -> Result<(), String> { for data in downloads { let mut value: Value = serde_json::from_str(data).map_err(|error| { format!("failed to decode download for portable sanitization: {error}") })?; remove_persisted_transfer_secrets(&mut value); *data = serde_json::to_string(&value).map_err(|error| { format!("failed to encode download for portable sanitization: {error}") })?; } Ok(()) } fn sanitize_persisted_downloads(connection: &mut Connection) -> Result<(), String> { let transaction = connection .transaction() .map_err(|error| format!("failed to begin portable download sanitization: {error}"))?; let records = { let mut statement = transaction .prepare("SELECT id, data FROM downloads") .map_err(|error| { format!("failed to prepare portable download sanitization: {error}") })?; let rows = statement .query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(|error| { format!("failed to read downloads for portable sanitization: {error}") })?; rows.collect::, _>>().map_err(|error| { format!("failed to read download for portable sanitization: {error}") })? }; for (id, data) in records { let mut value: Value = serde_json::from_str(&data).map_err(|error| { format!("failed to decode download '{id}' for portable sanitization: {error}") })?; remove_persisted_transfer_secrets(&mut value); let sanitized = serde_json::to_string(&value).map_err(|error| { format!("failed to encode download '{id}' for portable sanitization: {error}") })?; if sanitized != data { transaction .execute( "UPDATE downloads SET data = ?1 WHERE id = ?2", params![sanitized, id], ) .map_err(|error| format!("failed to sanitize download '{id}': {error}"))?; } } transaction .commit() .map_err(|error| format!("failed to commit portable download sanitization: {error}")) } fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> { transaction .execute("DELETE FROM downloads", []) .map_err(|error| format!("failed to clear downloads: {error}"))?; for data in downloads { let value: Value = serde_json::from_str(data) .map_err(|error| format!("failed to decode download: {error}"))?; let id = required_string(&value, "id")?; let status = required_string(&value, "status")?; let queue_id = value.get("queueId").and_then(Value::as_str); transaction .execute( "INSERT INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)", params![id, status, queue_id, data], ) .map_err(|error| format!("failed to save download '{id}': {error}"))?; } Ok(()) } pub fn load_queues(connection: &Connection) -> Result, String> { query_string_column(connection, "SELECT data FROM queues ORDER BY rowid") } pub fn replace_queues(connection: &mut Connection, data: &str) -> Result<(), String> { let values: Vec = serde_json::from_str(data).map_err(|error| format!("failed to decode queues: {error}"))?; let strings = values .iter() .map(|value| { serde_json::to_string(value).map_err(|error| format!("failed to encode queue: {error}")) }) .collect::, _>>()?; let transaction = connection .transaction() .map_err(|error| format!("failed to begin queue save: {error}"))?; replace_queues_tx(&transaction, &strings)?; transaction .commit() .map_err(|error| format!("failed to commit queue save: {error}")) } fn replace_queues_tx(transaction: &Transaction<'_>, queues: &[String]) -> Result<(), String> { transaction .execute("DELETE FROM queues", []) .map_err(|error| format!("failed to clear queues: {error}"))?; for data in queues { let value: Value = serde_json::from_str(data) .map_err(|error| format!("failed to decode queue: {error}"))?; let id = required_string(&value, "id")?; transaction .execute( "INSERT INTO queues (id, data) VALUES (?1, ?2)", params![id, data], ) .map_err(|error| format!("failed to save queue '{id}': {error}"))?; } Ok(()) } fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { value .get(key) .and_then(Value::as_str) .filter(|value| !value.is_empty()) .ok_or_else(|| format!("persisted item is missing '{key}'")) } pub fn load_ownership(connection: &Connection) -> Result, String> { let mut statement = connection .prepare("SELECT id, primary_path FROM download_ownership") .map_err(|error| format!("failed to prepare ownership query: {error}"))?; let rows = statement .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .map_err(|error| format!("failed to query ownership data: {error}"))?; rows.collect::, _>>() .map_err(|error| format!("failed to read ownership data: {error}")) } pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> { let existing_owner = connection .query_row( "SELECT id FROM download_ownership WHERE primary_path = ?1 AND id <> ?2 LIMIT 1", params![path, id], |row| row.get::<_, String>(0), ) .optional() .map_err(|error| format!("failed to check download ownership path: {error}"))?; if existing_owner.is_some() { return Err("Download destination is already owned by another Firelink download".to_string()); } connection .execute( "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path", params![id, path], ) .map_err(|error| format!("failed to save ownership data: {error}"))?; Ok(()) } pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> { connection .execute("DELETE FROM download_ownership WHERE id = ?1", params![id]) .map_err(|error| format!("failed to delete ownership data: {error}"))?; Ok(()) } pub fn has_user_data(connection: &Connection) -> Result { connection .query_row( "SELECT EXISTS(SELECT 1 FROM settings WHERE id = 1) OR EXISTS(SELECT 1 FROM downloads) OR EXISTS(SELECT 1 FROM queues)", [], |row| row.get(0), ) .map_err(|error| format!("failed to inspect existing user data: {error}")) } pub fn record_notice(connection: &Connection, key: &str) -> Result<(), String> { connection .execute( "INSERT INTO migration_events (key, consumed) VALUES (?1, 0) ON CONFLICT(key) DO NOTHING", params![key], ) .map_err(|error| format!("failed to record migration notice: {error}"))?; Ok(()) } pub fn has_pending_notice(connection: &Connection, key: &str) -> Result { connection .query_row( "SELECT EXISTS(SELECT 1 FROM migration_events WHERE key = ?1 AND consumed = 0)", params![key], |row| row.get::<_, bool>(0), ) .map_err(|error| format!("failed to read migration notice: {error}")) } pub fn consume_notice(connection: &Connection, key: &str) -> Result<(), String> { connection .execute( "UPDATE migration_events SET consumed = 1 WHERE key = ?1", params![key], ) .map_err(|error| format!("failed to consume migration notice: {error}"))?; Ok(()) } pub fn acknowledge_pairing_token_notice(connection: &Connection) -> Result<(), String> { consume_notice(connection, TOKEN_CHANGED_NOTICE) } pub(crate) fn generate_pairing_token() -> String { format!( "{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ) } /// Read the extension pairing token from portable settings JSON. /// Standard-mode settings are sanitized so this field is never a credential /// source outside the explicit portable-storage exception. pub fn load_pairing_token_from_settings(connection: &Connection) -> Result, String> { let Some(settings_json) = load_settings(connection)? else { return Ok(None); }; let value: serde_json::Value = serde_json::from_str(&settings_json) .map_err(|error| format!("failed to decode settings: {error}"))?; let state = value.get("state").unwrap_or(&value); let token = state .get("extensionPairingToken") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); Ok(token) } /// Write (or update) the extension pairing token inside portable settings JSON. /// Keeps all other settings fields intact. pub fn save_pairing_token_to_settings( connection: &Connection, token: &str, initialize_if_missing: bool, ) -> Result<(), String> { let Some(settings_json) = load_settings(connection)? else { if !initialize_if_missing { // Settings have not been persisted yet. Standard mode keeps the // first-run token session-only until the user grants credential- // store access; portable mode opts into initialization explicitly. return Ok(()); } let initial = serde_json::json!({ "state": { "extensionPairingToken": token }, "version": 3 }); let serialized = serde_json::to_string(&initial) .map_err(|error| format!("failed to encode initial settings: {error}"))?; return save_settings(connection, &serialized); }; let mut value: serde_json::Value = serde_json::from_str(&settings_json) .map_err(|error| format!("failed to decode settings: {error}"))?; let state = if value.get("state").is_some() { value .get_mut("state") .and_then(serde_json::Value::as_object_mut) .ok_or_else(|| "persisted settings state must be an object".to_string())? } else { value .as_object_mut() .ok_or_else(|| "persisted settings must be an object".to_string())? }; state.insert( "extensionPairingToken".to_string(), serde_json::Value::String(token.to_string()), ); let updated = serde_json::to_string(&value) .map_err(|error| format!("failed to encode settings: {error}"))?; save_settings(connection, &updated) } /// Remove a pairing token from a serialized settings document. /// /// Standard-mode settings must never carry the extension HMAC secret. The /// portable path deliberately preserves it separately through /// `preserve_portable_pairing_token`. pub fn strip_pairing_token_from_settings(data: &str) -> Result { let (sanitized, _, _) = sanitize_settings_text(data, true)?; Ok(sanitized) } /// Keep a legacy token in the standard settings document while credential-store /// migration is pending. The backend never returns this copy to the frontend; /// it is retained only so an unavailable credential store cannot turn a later /// settings save into permanent pairing loss. pub fn preserve_legacy_pairing_token( existing: Option<&str>, incoming: &str, ) -> Result { let Some(existing) = existing else { return Ok(incoming.to_string()); }; let (_, token, _) = sanitize_settings_text(existing, true)?; let Some(token) = token.filter(|value| !value.trim().is_empty()) else { return Ok(incoming.to_string()); }; let mut document: Value = serde_json::from_str(incoming) .map_err(|error| format!("failed to decode settings for legacy token preservation: {error}"))?; let state = if document.get("state").is_some() { document .get_mut("state") .and_then(Value::as_object_mut) .ok_or_else(|| "persisted settings state must be an object".to_string())? } else { document .as_object_mut() .ok_or_else(|| "persisted settings must be an object".to_string())? }; state.insert("extensionPairingToken".to_string(), Value::String(token)); serde_json::to_string(&document) .map_err(|error| format!("failed to encode settings with pending pairing token: {error}")) } /// Read a legacy pairing token from the settings database without changing it. pub fn read_pairing_token_from_settings( connection: &Connection, ) -> Result, String> { let Some(settings) = load_settings(connection)? else { return Ok(None); }; let (_, token, _) = sanitize_settings_text(&settings, true)?; Ok(token.filter(|value| !value.trim().is_empty())) } /// Remove a legacy pairing token from the settings database. pub fn remove_pairing_token_from_settings(connection: &Connection) -> Result<(), String> { let Some(settings) = load_settings(connection)? else { return Ok(()); }; let (sanitized, _, _) = sanitize_settings_text(&settings, true)?; if sanitized != settings { save_settings(connection, &sanitized)?; } Ok(()) } /// Migrate any legacy standard-mode token into the OS credential store. /// /// The settings copy is removed only after the credential-store write succeeds. /// If cleanup fails after creating a new credential, the new entry is rolled /// back so a later retry can complete the migration without losing the token. pub fn migrate_legacy_pairing_token(connection: &Connection) -> Result<(), String> { let Some(legacy_token) = read_pairing_token_from_settings(connection)? else { return Ok(()); }; // Hold the same lock used by the public credential-store commands across // the complete read/write/cleanup sequence. Otherwise a concurrent grant, // regeneration, or delete could invalidate the rollback decision. let _keyring_guard = lock_keyring_operations()?; let keychain_has_token = get_keychain_password_unlocked(PAIRING_TOKEN_KEYCHAIN_ID) .ok() .is_some_and(|value| !value.trim().is_empty()); let created_keychain_entry = !keychain_has_token; if created_keychain_entry { set_keychain_password_unlocked(PAIRING_TOKEN_KEYCHAIN_ID, &legacy_token)?; } if let Err(error) = remove_pairing_token_from_settings(connection) { if created_keychain_entry { if let Err(rollback_error) = delete_keychain_password_unlocked(PAIRING_TOKEN_KEYCHAIN_ID) { return Err(format!( "failed to remove the legacy pairing token after credential-store migration: {error}; credential-store rollback also failed: {rollback_error}" )); } } return Err(format!( "failed to remove the legacy pairing token after credential-store migration: {error}" )); } Ok(()) } fn ensure_keyring_store() -> Result<(), String> { if keyring_core::get_default_store().is_some() { return Ok(()); } static STORE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _guard = STORE_INIT_LOCK .lock() .map_err(|_| "keyring store initialization lock is unavailable".to_string())?; if keyring_core::get_default_store().is_some() { return Ok(()); } #[cfg(target_os = "macos")] { let store = apple_native_keyring_store::keychain::Store::new() .map_err(|error| error.to_string())?; keyring_core::set_default_store(store); Ok(()) } #[cfg(target_os = "windows")] { let store = windows_native_keyring_store::Store::new().map_err(|error| error.to_string())?; keyring_core::set_default_store(store); Ok(()) } #[cfg(target_os = "linux")] { let store = zbus_secret_service_keyring_store::Store::new().map_err(|error| error.to_string())?; keyring_core::set_default_store(store); Ok(()) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Err("No native keyring store is available for this platform".to_string()) } } fn keychain_entry_with_target( id: &str, target: Option<&str>, ) -> Result { ensure_keyring_store()?; if let Some(target) = target { return keyring_core::Entry::new_with_modifiers( KEYCHAIN_SERVICE, id, &std::collections::HashMap::from([("target", target)]), ) .map_err(|error| error.to_string()); } keyring_core::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string()) } fn keychain_entry(id: &str) -> Result { #[cfg(target_os = "linux")] { return keychain_entry_with_target(id, Some("default")); } #[cfg(not(target_os = "linux"))] keychain_entry_with_target(id, None) } fn lock_keyring_operations() -> Result, String> { KEYRING_OPERATION_LOCK .lock() .map_err(|_| "keyring operation lock is unavailable".to_string()) } #[cfg(target_os = "linux")] fn legacy_linux_keychain_entries(id: &str) -> Result, String> { ensure_keyring_store()?; let entries = keyring_core::Entry::search(&std::collections::HashMap::from([ ("service", KEYCHAIN_SERVICE), ("username", id), ])) .map_err(|error| error.to_string())?; let mut legacy = Vec::new(); for entry in entries { let attributes = entry.get_attributes().map_err(|error| error.to_string())?; if !attributes.contains_key("target") { legacy.push(entry); } } Ok(legacy) } #[cfg(target_os = "linux")] fn unique_legacy_linux_keychain_entry(id: &str) -> Result, String> { let mut entries = legacy_linux_keychain_entries(id)?; match entries.len() { 0 => Ok(None), 1 => Ok(entries.pop()), count => Err(format!( "Entry is matched by {count} legacy Linux credentials" )), } } fn set_keychain_password_unlocked(id: &str, password: &str) -> Result<(), String> { let entry = keychain_entry(id)?; #[cfg(target_os = "linux")] if let Err(error) = entry.get_credential() { match error { keyring_core::Error::NoEntry => { if let Some(legacy) = unique_legacy_linux_keychain_entry(id)? { return legacy .set_password(password) .map_err(|error| error.to_string()); } } error => return Err(error.to_string()), } } entry .set_password(password) .map_err(|error| error.to_string()) } pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> { let _guard = lock_keyring_operations()?; set_keychain_password_unlocked(id, password) } fn get_keychain_password_if_present_unlocked(id: &str) -> Result, String> { let entry = keychain_entry(id)?; match entry.get_password() { Ok(password) => Ok(Some(password)), #[cfg(target_os = "linux")] Err(keyring_core::Error::NoEntry) => unique_legacy_linux_keychain_entry(id)? .map(|legacy| legacy.get_password().map_err(|error| error.to_string())) .transpose(), #[cfg(not(target_os = "linux"))] Err(keyring_core::Error::NoEntry) => Ok(None), Err(error) => Err(error.to_string()), } } fn get_keychain_password_unlocked(id: &str) -> Result { get_keychain_password_if_present_unlocked(id)? .ok_or_else(|| keyring_core::Error::NoEntry.to_string()) } pub fn get_keychain_password(id: &str) -> Result { let _guard = lock_keyring_operations()?; get_keychain_password_unlocked(id) } fn delete_keychain_password_unlocked(id: &str) -> Result<(), String> { let entry = keychain_entry(id)?; match entry.delete_credential() { Ok(()) | Err(keyring_core::Error::NoEntry) => {} Err(error) => return Err(error.to_string()), } #[cfg(target_os = "linux")] for legacy in legacy_linux_keychain_entries(id)? { match legacy.delete_credential() { Ok(()) | Err(keyring_core::Error::NoEntry) => {} Err(error) => return Err(error.to_string()), } } Ok(()) } pub fn delete_keychain_password(id: &str) -> Result<(), String> { let _guard = lock_keyring_operations()?; delete_keychain_password_unlocked(id) } #[cfg(test)] mod tests { use super::*; use serde_json::json; use tempfile::TempDir; #[test] fn site_login_settings_update_preserves_envelope_without_password() { let original = json!({ "state": { "theme": "dark", "siteLogins": [] }, "version": 3 }) .to_string(); let updated = add_site_login_to_settings( &original, "login-1", "https://example.com/*", "nima", ) .unwrap(); let document: Value = serde_json::from_str(&updated).unwrap(); assert_eq!(document["state"]["theme"], "dark"); assert_eq!(document["version"], 3); assert_eq!(document["state"]["siteLogins"][0]["id"], "login-1"); assert_eq!(document["state"]["siteLogins"][0]["username"], "nima"); assert!(!updated.contains("password")); } #[test] fn site_login_settings_update_rejects_duplicate_ids() { let original = json!({ "state": { "siteLogins": [{ "id": "login-1", "urlPattern": "https://example.com/*", "username": "old-user" }] }, "version": 3 }) .to_string(); let error = add_site_login_to_settings( &original, "login-1", "https://example.com/*", "new-user", ) .unwrap_err(); assert_eq!(error, "site login already exists"); } #[test] fn site_login_settings_removal_reports_whether_metadata_changed() { let original = json!({ "state": { "siteLogins": [{ "id": "login-1", "urlPattern": "https://example.com/*", "username": "nima" }] }, "version": 3 }) .to_string(); let (updated, removed) = remove_site_login_from_settings(&original, "login-1").unwrap(); assert!(removed); let document: Value = serde_json::from_str(&updated).unwrap(); assert!(document["state"]["siteLogins"].as_array().unwrap().is_empty()); let (unchanged, removed) = remove_site_login_from_settings(&updated, "missing").unwrap(); assert!(!removed); assert_eq!(unchanged, updated); } #[test] fn site_login_ids_cannot_alias_the_pairing_token_or_hide_whitespace() { assert_eq!(validate_site_login_id("login-1").unwrap(), "login-1"); assert!(validate_site_login_id(" extension-pairing-token").is_err()); assert!(validate_site_login_id("extension-pairing-token ").is_err()); assert!(validate_site_login_id(" login-1").is_err()); } #[test] fn migrates_v0_database_and_creates_backup() { let temp = TempDir::new().unwrap(); let path = temp.path().join(DATABASE_NAME); let connection = Connection::open(&path).unwrap(); connection .execute_batch( " CREATE TABLE downloads ( id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT NOT NULL, data TEXT NOT NULL ); CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); INSERT INTO downloads VALUES ( 'one', 'queued', 'main', '{\"id\":\"one\",\"status\":\"queued\",\"queueId\":\"main\"}' ); ", ) .unwrap(); drop(connection); let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); let version: i64 = connection .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); assert_eq!(version, CURRENT_SCHEMA_VERSION); assert!(!column_is_not_null(&connection, "downloads", "queue_id").unwrap()); assert_eq!(load_downloads(&connection).unwrap().len(), 1); assert!(fs::read_dir(temp.path()).unwrap().flatten().any(|entry| { entry .file_name() .to_string_lossy() .starts_with("firelink.sqlite.backup-schema-v0-") })); } #[test] fn portable_migration_does_not_create_raw_schema_backup() { let temp = TempDir::new().unwrap(); let path = temp.path().join(DATABASE_NAME); let connection = Connection::open(&path).unwrap(); connection .execute_batch( " CREATE TABLE downloads ( id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT NOT NULL, data TEXT NOT NULL ); CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); INSERT INTO downloads VALUES ( 'one', 'queued', 'main', '{\"id\":\"one\",\"status\":\"queued\",\"password\":\"secret\"}' ); ", ) .unwrap(); drop(connection); let state = init_at_path_internal(temp.path(), true).unwrap(); let connection = state.lock().unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("password").is_none()); assert!(!fs::read_dir(temp.path()).unwrap().flatten().any(|entry| { entry .file_name() .to_string_lossy() .starts_with("firelink.sqlite.backup-schema-v0-") })); } #[test] fn imports_legacy_bundle_store_with_pending_token_for_deferred_migration() { let root = TempDir::new().unwrap(); let current = root.path().join("com.nimbold.firelink"); let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); fs::create_dir_all(&legacy).unwrap(); let store = json!({ "settings": json!({ "state": { "theme": "dark", "extensionPairingToken": "legacy-secret" }, "version": 0 }).to_string(), "download_queue": [{ "id": "download-1", "status": "ready", "url": "https://example.com/file", "fileName": "file", "category": "Other", "dateAdded": "" }], "queues": [{ "id": "main", "name": "Main Queue", "isMain": true }] }); fs::write( legacy.join(LEGACY_STORE_NAME), serde_json::to_vec(&store).unwrap(), ) .unwrap(); let state = init_at_path(¤t).unwrap(); let connection = state.lock().unwrap(); assert_eq!(load_downloads(&connection).unwrap().len(), 1); assert_eq!(load_queues(&connection).unwrap().len(), 1); let settings = load_settings(&connection).unwrap().unwrap(); assert!(settings.contains("\"theme\":\"dark\"")); assert!(settings.contains("legacy-secret")); let backup = fs::read_dir(&legacy) .unwrap() .flatten() .find(|entry| { entry .file_name() .to_string_lossy() .starts_with("store.bin.backup-legacy-import-") }) .expect("legacy import should retain a sanitized backup"); assert!(!fs::read_to_string(backup.path()) .unwrap() .contains("legacy-secret")); assert!(!fs::read_to_string(legacy.join(LEGACY_STORE_NAME)) .unwrap() .contains("legacy-secret")); } #[test] fn portable_import_sanitizes_legacy_source_after_success() { let root = TempDir::new().unwrap(); let current = root.path().join("com.nimbold.firelink"); let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); fs::create_dir_all(&legacy).unwrap(); let store_path = legacy.join(LEGACY_STORE_NAME); let store = json!({ "settings": json!({"state": {"theme": "dark"}}).to_string(), "download_queue": [{ "id": "download-1", "status": "queued", "url": "https://example.com/file", "password": "legacy-secret" }], "queues": [] }); fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap(); let state = init_at_path_internal(¤t, true).unwrap(); let connection = state.lock().unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("password").is_none()); let sanitized_store = fs::read_to_string(&store_path).unwrap(); assert!(!sanitized_store.contains("legacy-secret")); } #[test] fn imports_legacy_bundle_sqlite_database() { let root = TempDir::new().unwrap(); let current = root.path().join("com.nimbold.firelink"); let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); fs::create_dir_all(&legacy).unwrap(); let legacy_path = legacy.join(DATABASE_NAME); let connection = Connection::open(&legacy_path).unwrap(); connection .execute_batch( " CREATE TABLE downloads ( id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT NOT NULL, data TEXT NOT NULL ); CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); INSERT INTO downloads VALUES ( 'legacy-download', 'queued', 'legacy-main', '{\"id\":\"legacy-download\",\"status\":\"queued\",\"queueId\":\"legacy-main\"}' ); INSERT INTO queues VALUES ( 'legacy-main', '{\"id\":\"legacy-main\",\"name\":\"Legacy Main\",\"isMain\":true}' ); INSERT INTO settings VALUES ( 1, '{\"state\":{\"theme\":\"nord\",\"extensionPairingToken\":\"legacy-sqlite-secret\"},\"version\":0}' ); ", ) .unwrap(); drop(connection); let state = init_at_path(¤t).unwrap(); let connection = state.lock().unwrap(); assert_eq!(load_downloads(&connection).unwrap().len(), 1); assert_eq!(load_queues(&connection).unwrap().len(), 1); let settings = load_settings(&connection).unwrap().unwrap(); assert!(settings.contains("\"nord\"")); assert!(settings.contains("legacy-sqlite-secret")); let backup = fs::read_dir(&legacy) .unwrap() .flatten() .find(|entry| { entry .file_name() .to_string_lossy() .starts_with("firelink.sqlite.backup-legacy-import-") }) .unwrap(); let backup_connection = Connection::open(backup.path()).unwrap(); let backup_settings: String = backup_connection .query_row("SELECT data FROM settings WHERE id = 1", [], |row| row.get(0)) .unwrap(); assert!(!backup_settings.contains("legacy-sqlite-secret")); drop(backup_connection); let source_connection = Connection::open(legacy.join(DATABASE_NAME)).unwrap(); let source_settings: String = source_connection .query_row("SELECT data FROM settings WHERE id = 1", [], |row| row.get(0)) .unwrap(); assert!(!source_settings.contains("legacy-sqlite-secret")); } #[test] fn portable_download_persistence_removes_transfer_secrets() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let mut connection = state.lock().unwrap(); let data = json!([{ "id": "download-1", "status": "queued", "queueId": "main", "url": "https://user:secret@example.com/file?token=secret#fragment", "password": "secret", "cookies": "session=secret", "headers": "Authorization: Bearer secret", "mirrors": "https://user:secret@example.com/mirror", "proxy": "http://user:secret@example.com:8080" }]) .to_string(); replace_downloads(&mut connection, &data, true).unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert_eq!(saved["url"], "https://example.com/file"); assert_eq!(saved["status"], "failed"); assert_eq!(saved["resumable"], false); assert!(!saved.to_string().contains("secret")); for key in ["password", "cookies", "headers", "mirrors", "proxy"] { assert!(saved.get(key).is_none(), "portable data retained {key}"); } } #[test] fn portable_download_persistence_marks_context_dependent_queue_items_unresumable() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let mut connection = state.lock().unwrap(); let data = json!([{ "id": "download-context", "status": "queued", "queueId": "main", "url": "https://example.com/file", "headers": "Authorization: Bearer secret" }]) .to_string(); replace_downloads(&mut connection, &data, true).unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert_eq!(saved["url"], "https://example.com/file"); assert_eq!(saved["status"], "failed"); assert_eq!(saved["resumable"], false); assert_eq!( saved["lastError"], "Portable mode removed credentials or transfer settings from this persisted download; add it again to resume." ); assert!(saved.get("headers").is_none()); } #[test] fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let mut connection = state.lock().unwrap(); let data = json!([ { "id": "download-secret-error", "status": "failed", "queueId": "main", "url": "https://example.com/file", "lastError": "HTTP 500 for https://example.com/file?token=PORTABLE_TEST_QUERY_TOKEN" }, { "id": "download-safe-error", "status": "failed", "queueId": "main", "url": "https://example.com/other-file", "lastError": "connection refused" } ]) .to_string(); replace_downloads(&mut connection, &data, true).unwrap(); let saved = load_downloads(&connection).unwrap(); let secret_error: Value = serde_json::from_str(&saved[0]).unwrap(); let safe_error: Value = serde_json::from_str(&saved[1]).unwrap(); assert!(!secret_error .to_string() .contains("PORTABLE_TEST_QUERY_TOKEN")); assert_eq!(safe_error["lastError"], "connection refused"); replace_downloads(&mut connection, &data, false).unwrap(); let standard: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(standard.to_string().contains("PORTABLE_TEST_QUERY_TOKEN")); } #[test] fn standard_pairing_token_is_stripped_from_settings_documents() { let input = json!({ "state": { "theme": "dark", "extensionPairingToken": "redacted-pairing-token" }, "version": 3 }) .to_string(); let stripped = strip_pairing_token_from_settings(&input).unwrap(); assert!(!stripped.contains("redacted-pairing-token")); assert!(stripped.contains("\"theme\":\"dark\"")); } #[test] fn pending_legacy_pairing_token_survives_standard_settings_save() { let existing = json!({ "state": { "theme": "dark", "extensionPairingToken": "pending-token" }, "version": 3 }) .to_string(); let incoming = json!({ "state": { "theme": "light" }, "version": 3 }) .to_string(); let sanitized = strip_pairing_token_from_settings(&incoming).unwrap(); let preserved = preserve_legacy_pairing_token(Some(&existing), &sanitized).unwrap(); assert!(preserved.contains("pending-token")); assert!(preserved.contains("\"theme\":\"light\"")); } #[test] fn reading_legacy_pairing_token_does_not_remove_it_before_migration() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); save_settings( &connection, &json!({ "state": { "extensionPairingToken": "redacted-legacy-token" }, "version": 3 }) .to_string(), ) .unwrap(); assert_eq!( read_pairing_token_from_settings(&connection) .unwrap() .as_deref(), Some("redacted-legacy-token") ); assert!(load_settings(&connection) .unwrap() .unwrap() .contains("redacted-legacy-token")); remove_pairing_token_from_settings(&connection).unwrap(); assert!(!load_settings(&connection) .unwrap() .unwrap() .contains("redacted-legacy-token")); } #[test] fn portable_persistence_redacts_unparseable_download_urls() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let mut connection = state.lock().unwrap(); let data = json!([{ "id": "download-1", "status": "queued", "url": "not a URL secret=secret" }]) .to_string(); replace_downloads(&mut connection, &data, true).unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert_eq!(saved["url"], ""); assert_eq!(saved["status"], "failed"); assert!(!saved.to_string().contains("secret")); } #[test] fn portable_initialization_sanitizes_existing_downloads() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let mut connection = state.lock().unwrap(); let data = json!([{ "id": "download-1", "status": "queued", "url": "https://example.com/file", "password": "secret", "lastError": "request failed with token=PORTABLE_EXISTING_QUERY_TOKEN" }]) .to_string(); replace_downloads(&mut connection, &data, false).unwrap(); drop(connection); drop(state); let state = init_at_path_internal(temp.path(), true).unwrap(); let connection = state.lock().unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("password").is_none()); assert!(!saved.to_string().contains("PORTABLE_EXISTING_QUERY_TOKEN")); } #[test] fn rejects_malformed_settings_state_without_panicking() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); save_settings( &connection, &json!({ "state": "corrupted", "version": 3 }).to_string(), ) .unwrap(); let result = save_pairing_token_to_settings(&connection, "token", true); assert_eq!( result.unwrap_err(), "persisted settings state must be an object" ); } #[test] fn pairing_token_is_persisted_before_frontend_settings_exist() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); save_pairing_token_to_settings(&connection, "initial-token", true).unwrap(); assert_eq!( load_pairing_token_from_settings(&connection).unwrap().as_deref(), Some("initial-token") ); } #[test] fn migration_notice_is_persistent_until_acknowledged() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); record_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap(); assert!(has_pending_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap()); assert!(has_pending_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap()); acknowledge_pairing_token_notice(&connection).unwrap(); assert!(!has_pending_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap()); } #[test] fn rejects_two_download_ids_from_claiming_the_same_primary_path() { let temp = TempDir::new().unwrap(); let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); set_ownership(&connection, "first", "/downloads/file.bin").unwrap(); let error = set_ownership(&connection, "second", "/downloads/file.bin") .expect_err("a primary path must have one live owner"); assert!(error.contains("already owned")); assert_eq!(load_ownership(&connection).unwrap(), vec![( "first".to_string(), "/downloads/file.bin".to_string() )]); set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap(); } }