Files
Firelink/src-tauri/src/db.rs
T
NimBold 8eb1a55e72 fix: eliminate OS keychain prompt on startup by persisting pairing token in DB
The root cause was hydrate_extension_pairing_token accessing the
keychain when keychainAccessGranted was true in the DB.  Any keychain
access on macOS triggers the system prompt when the binary signature
changes after an update.

Architecture change: two-store model.
- The SQLite settings DB is now the primary store for the token.
  hydrate_extension_pairing_token reads from it exclusively -- it
  never touches the OS keychain.  No system prompt on startup.
- The OS keychain remains defence-in-depth: grant_keychain_access
  still writes the token there, but it is only reached from the
  explicit Grant Access button, so any system prompt is user-initiated.

DB helpers: load_pairing_token_from_settings / save_pairing_token_to_settings
hydrate_extension_pairing_token: reads from DB, skips keychain entirely
grant_keychain_access: syncs token to DB after keychain access
Frontend: extensionPairingToken included in partialize for auto-persist
2026-06-25 03:09:50 +03:30

1068 lines
38 KiB
Rust

use rusqlite::{params, Connection, OptionalExtension, Transaction};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Manager;
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;
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";
pub struct DbState {
conn: Mutex<Connection>,
}
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(Debug, Clone, PartialEq, Eq)]
enum PairingTokenSource {
Keychain,
LegacySettings,
Generated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PairingTokenDecision {
token: String,
source: PairingTokenSource,
changed: bool,
}
#[derive(Default)]
struct LegacyData {
settings: Option<String>,
downloads: Vec<String>,
queues: Vec<String>,
ownership: Vec<(String, String)>,
pairing_token: Option<String>,
}
pub fn init(app_handle: &tauri::AppHandle) -> Result<DbState, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|error| format!("failed to resolve app data directory: {error}"))?;
init_at_path_internal(&app_data_dir)
}
#[cfg(test)]
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
init_at_path_internal(app_data_dir)
}
fn init_at_path_internal(app_data_dir: &Path) -> 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}"))?;
if existed && version < CURRENT_SCHEMA_VERSION {
backup_database(&connection, &database_path, &format!("schema-v{version}"))?;
}
migrate_schema(&mut connection, version)?;
// We no longer touch the keychain on backend startup.
// Legacy imports will safely preserve any pairing token in the JSON payload.
// The frontend will manually trigger migration to the keychain via IPC if access is granted.
import_legacy_data(&mut connection, app_data_dir, false)?;
Ok(DbState {
conn: Mutex::new(connection),
})
}
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,
migrate_keychain: 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)? {
continue;
}
backup_file(&candidate, "legacy-import")?;
let mut legacy = if candidate
.file_name()
.is_some_and(|name| name == DATABASE_NAME)
{
read_legacy_database(&candidate, migrate_keychain)?
} else {
read_legacy_store(&candidate, migrate_keychain)?
};
let mut migration_complete = true;
if migrate_keychain
&& get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err()
&& legacy.pairing_token.is_some()
{
if let Some(token) = legacy.pairing_token.as_deref() {
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, token) {
log::warn!(
"Legacy pairing token could not be migrated yet; settings import will retry: {}",
error
);
legacy.settings = None;
migration_complete = false;
}
}
}
merge_legacy_data(connection, legacy)?;
if migration_complete {
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 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)
}
pub fn sanitize_current_settings_and_restore_token(
connection: &Connection,
force_migrate: bool,
) -> Result<(bool, bool), String> {
let Some(settings) = load_settings(connection)? else {
return Ok((false, false));
};
let (sanitized, legacy_token, keychain_granted) =
sanitize_settings_text(&settings, force_migrate)?;
if sanitized == settings {
return Ok((false, keychain_granted));
}
let should_migrate = force_migrate || keychain_granted;
if should_migrate && get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() {
if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) {
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token) {
log::warn!(
"Persisted pairing token could not be migrated yet; original settings retained: {}",
error
);
return Ok((true, keychain_granted));
}
}
}
save_settings(connection, &sanitized)?;
Ok((false, keychain_granted))
}
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 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) -> Result<(), String> {
let values: Vec<Value> = serde_json::from_str(data)
.map_err(|error| format!("failed to decode downloads: {error}"))?;
let strings = values
.iter()
.map(|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 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> {
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 hydrate_pairing_token(
connection: &mut Connection,
skip_keychain: bool,
) -> Result<(String, bool), String> {
if skip_keychain {
return Ok((generate_pairing_token(), false));
}
let existing = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).ok();
let generated = generate_pairing_token();
let decision = decide_pairing_token(
existing.as_deref(),
None,
has_user_data(connection)?,
&generated,
);
if decision.source != PairingTokenSource::Keychain {
set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &decision.token)?;
}
if decision.changed {
record_notice(connection, TOKEN_CHANGED_NOTICE)?;
}
let changed = has_pending_notice(connection, TOKEN_CHANGED_NOTICE)?;
Ok((decision.token, changed))
}
pub fn acknowledge_pairing_token_notice(connection: &Connection) -> Result<(), String> {
consume_notice(connection, TOKEN_CHANGED_NOTICE)
}
fn decide_pairing_token(
keychain_token: Option<&str>,
legacy_token: Option<&str>,
has_existing_user_data: bool,
generated_token: &str,
) -> PairingTokenDecision {
if let Some(token) = keychain_token.filter(|token| !token.trim().is_empty()) {
return PairingTokenDecision {
token: token.to_string(),
source: PairingTokenSource::Keychain,
changed: false,
};
}
if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) {
return PairingTokenDecision {
token: token.to_string(),
source: PairingTokenSource::LegacySettings,
changed: false,
};
}
PairingTokenDecision {
token: generated_token.to_string(),
source: PairingTokenSource::Generated,
changed: has_existing_user_data,
}
}
pub(crate) fn generate_pairing_token() -> String {
format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
/// Read the extension pairing token from the persisted settings JSON.
/// Returns `None` when the field is missing, empty, or the settings haven't
/// been saved yet. This is the **primary read path** — it does not touch the
/// OS keychain and therefore never triggers a system credential prompt.
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 the persisted settings
/// JSON document. Keeps all other settings fields intact.
pub fn save_pairing_token_to_settings(
connection: &Connection,
token: &str,
) -> Result<(), String> {
let Some(settings_json) = load_settings(connection)? else {
// Settings haven't been persisted yet — nothing to update.
return Ok(());
};
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")
.expect("state is an object")
} else {
&mut value
};
state["extensionPairingToken"] =
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)
}
pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
entry
.set_password(password)
.map_err(|error| error.to_string())
}
pub fn get_keychain_password(id: &str) -> Result<String, String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
entry.get_password().map_err(|error| error.to_string())
}
pub fn delete_keychain_password(id: &str) -> Result<(), String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
let _ = entry.delete_credential();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
#[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 imports_legacy_bundle_store_and_preserves_token() {
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"));
assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("store.bin.backup-legacy-import-")
}));
}
#[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\"},\"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);
assert!(load_settings(&connection)
.unwrap()
.unwrap()
.contains("\"nord\""));
assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("firelink.sqlite.backup-legacy-import-")
}));
}
#[test]
fn token_decision_preserves_keychain_and_legacy_values() {
let keychain = decide_pairing_token(Some("keychain"), Some("legacy"), true, "generated");
assert_eq!(keychain.token, "keychain");
assert_eq!(keychain.source, PairingTokenSource::Keychain);
assert!(!keychain.changed);
let legacy = decide_pairing_token(None, Some("legacy"), true, "generated");
assert_eq!(legacy.token, "legacy");
assert_eq!(legacy.source, PairingTokenSource::LegacySettings);
assert!(!legacy.changed);
let recovery = decide_pairing_token(None, None, true, "generated");
assert_eq!(recovery.token, "generated");
assert_eq!(recovery.source, PairingTokenSource::Generated);
assert!(recovery.changed);
let fresh = decide_pairing_token(None, None, false, "generated");
assert!(!fresh.changed);
}
#[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());
}
}