mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-12 04:26:57 +00:00
feat(portable): add secure Windows portable release (#15)
Implement marker-based portable storage, portable WebView and log paths, secure queue and migration sanitization, and Windows portable ZIP validation while preserving the NSIS installer path. Refs #15
This commit is contained in:
+378
-18
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -16,6 +15,7 @@ static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct DbState {
|
||||
conn: Mutex<Connection>,
|
||||
portable: bool,
|
||||
}
|
||||
|
||||
impl DbState {
|
||||
@@ -49,20 +49,16 @@ struct LegacyData {
|
||||
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)
|
||||
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)
|
||||
init_at_path_internal(app_data_dir, false)
|
||||
}
|
||||
|
||||
fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
|
||||
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);
|
||||
@@ -73,7 +69,12 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
|
||||
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 {
|
||||
// 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)?;
|
||||
@@ -81,13 +82,23 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
|
||||
// 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)?;
|
||||
import_legacy_data(&mut connection, app_data_dir, false, 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!(
|
||||
@@ -176,6 +187,7 @@ fn import_legacy_data(
|
||||
connection: &mut Connection,
|
||||
app_data_dir: &Path,
|
||||
migrate_keychain: bool,
|
||||
portable: bool,
|
||||
) -> Result<(), String> {
|
||||
let legacy_app_dir = app_data_dir
|
||||
.parent()
|
||||
@@ -194,9 +206,14 @@ fn import_legacy_data(
|
||||
}
|
||||
let marker = format!("legacy-import:{}", candidate.to_string_lossy());
|
||||
if metadata_exists(connection, &marker)? {
|
||||
if portable {
|
||||
sanitize_legacy_source(&candidate)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
backup_file(&candidate, "legacy-import")?;
|
||||
if !portable {
|
||||
backup_file(&candidate, "legacy-import")?;
|
||||
}
|
||||
let mut legacy = if candidate
|
||||
.file_name()
|
||||
.is_some_and(|name| name == DATABASE_NAME)
|
||||
@@ -230,11 +247,85 @@ fn import_legacy_data(
|
||||
params![marker],
|
||||
)
|
||||
.map_err(|error| format!("failed to record legacy import: {error}"))?;
|
||||
if portable {
|
||||
sanitize_legacy_source(&candidate)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_legacy_source(path: &Path) -> Result<(), String> {
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|name| name == DATABASE_NAME)
|
||||
{
|
||||
let mut connection = Connection::open(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to open legacy database '{}' for portable sanitization: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if table_exists(&connection, "downloads")? {
|
||||
return sanitize_persisted_downloads(&mut connection);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let text = fs::read_to_string(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to read legacy store '{}' for portable sanitization: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let mut document: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
format!(
|
||||
"failed to decode legacy store '{}' for portable sanitization: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let Some(downloads) = document
|
||||
.get_mut("download_queue")
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
for download in downloads {
|
||||
remove_persisted_transfer_secrets(download);
|
||||
}
|
||||
let sanitized = serde_json::to_string(&document).map_err(|error| {
|
||||
format!(
|
||||
"failed to encode legacy store '{}' for portable sanitization: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if sanitized == text {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| format!("invalid legacy store path '{}'", path.display()))?;
|
||||
let temporary = path.with_file_name(format!(".{file_name}.portable-sanitized.tmp"));
|
||||
fs::write(&temporary, sanitized).map_err(|error| {
|
||||
format!(
|
||||
"failed to write temporary sanitized legacy store '{}': {error}",
|
||||
temporary.display()
|
||||
)
|
||||
})?;
|
||||
if let Err(rename_error) = fs::rename(&temporary, path) {
|
||||
let _ = fs::remove_file(path);
|
||||
fs::rename(&temporary, path).map_err(|error| {
|
||||
format!(
|
||||
"failed to replace legacy store '{}' after rename error ({rename_error}): {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_legacy_data(connection: &mut Connection, legacy: LegacyData) -> Result<(), String> {
|
||||
let transaction = connection
|
||||
.transaction()
|
||||
@@ -595,13 +686,20 @@ 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> {
|
||||
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
|
||||
.iter()
|
||||
.map(|value| {
|
||||
serde_json::to_string(value)
|
||||
.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<_>, _>>()?;
|
||||
@@ -614,6 +712,106 @@ pub fn replace_downloads(connection: &mut Connection, data: &str) -> Result<(),
|
||||
.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.
|
||||
for key in ["password", "cookies", "headers", "mirrors", "proxy"] {
|
||||
object.remove(key);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 from this persisted download; add it again to resume."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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", [])
|
||||
@@ -844,10 +1042,22 @@ pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Optio
|
||||
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 {
|
||||
// Settings haven't been persisted yet — nothing to update.
|
||||
return Ok(());
|
||||
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}"))?;
|
||||
@@ -1077,6 +1287,43 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[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_and_preserves_token() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -1126,6 +1373,33 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_import_sanitizes_legacy_source_after_success() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let current = root.path().join("com.nimbold.firelink");
|
||||
let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER);
|
||||
fs::create_dir_all(&legacy).unwrap();
|
||||
let store_path = legacy.join(LEGACY_STORE_NAME);
|
||||
let store = json!({
|
||||
"settings": json!({"state": {"theme": "dark"}}).to_string(),
|
||||
"download_queue": [{
|
||||
"id": "download-1",
|
||||
"status": "queued",
|
||||
"url": "https://example.com/file",
|
||||
"password": "legacy-secret"
|
||||
}],
|
||||
"queues": []
|
||||
});
|
||||
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
|
||||
|
||||
let state = init_at_path_internal(¤t, true).unwrap();
|
||||
let connection = state.lock().unwrap();
|
||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("password").is_none());
|
||||
let sanitized_store = fs::read_to_string(&store_path).unwrap();
|
||||
assert!(!sanitized_store.contains("legacy-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_legacy_bundle_sqlite_database() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -1178,6 +1452,92 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[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_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"
|
||||
}])
|
||||
.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());
|
||||
}
|
||||
|
||||
#[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 token_decision_preserves_keychain_and_legacy_values() {
|
||||
let keychain = decide_pairing_token(Some("keychain"), Some("legacy"), true, "generated");
|
||||
|
||||
Reference in New Issue
Block a user