Files
Firelink/src-tauri/src/db.rs
T
NimBold 6e85c0842f fix(startup): enforce keychain consent before credential access
Keep credential-store operations behind a per-process consent gate, prevent duplicate native grant requests, and defer legacy token migration until explicit consent. Harden the RTL/sidebar, custom window controls, bidi copy, and queue editor fixes.

Refs #17
2026-07-19 07:07:29 +03:30

2151 lines
78 KiB
Rust

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<Connection>,
portable: bool,
}
impl DbState {
pub fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
self.conn
.lock()
.map_err(|_| "persistence database lock is unavailable".to_string())
}
}
#[derive(Default)]
struct LegacyData {
settings: Option<String>,
downloads: Vec<String>,
queues: Vec<String>,
ownership: Vec<(String, String)>,
pairing_token: Option<String>,
}
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable())
}
#[cfg(test)]
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
init_at_path_internal(app_data_dir, false)
}
fn init_at_path_internal(
app_data_dir: &Path,
portable: bool,
) -> Result<DbState, String> {
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<LegacyData, String> {
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<LegacyData, String> {
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<String>, 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<String>, 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<String>, 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<Vec<String>, 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<Vec<String>, 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::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to read legacy data: {error}"))
}
fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
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<PathBuf, String> {
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<bool, String> {
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<bool, String> {
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<bool, String> {
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<Option<String>, 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<bool, String> {
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, Value>, 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<String, 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 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<Vec<String>, 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<Value> = 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::<Result<Vec<_>, _>>()?;
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<String, Value>) {
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::<Result<Vec<_>, _>>().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<Vec<String>, 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<Value> =
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::<Result<Vec<_>, _>>()?;
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<Vec<(String, String)>, 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::<Result<Vec<_>, _>>()
.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<bool, String> {
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<bool, String> {
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<Option<String>, 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<String, String> {
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<String, String> {
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<Option<String>, 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<keyring_core::Entry, String> {
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<keyring_core::Entry, String> {
#[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<std::sync::MutexGuard<'static, ()>, 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<Vec<keyring_core::Entry>, 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<Option<keyring_core::Entry>, 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<Option<String>, 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<String, String> {
get_keychain_password_if_present_unlocked(id)?
.ok_or_else(|| keyring_core::Error::NoEntry.to_string())
}
pub fn get_keychain_password(id: &str) -> Result<String, String> {
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(&current).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(&current, 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(&current).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();
}
}