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:
NimBold
2026-07-12 23:08:48 +03:30
parent 56b4c9f511
commit a0f44b79ad
20 changed files with 905 additions and 82 deletions
+378 -18
View File
@@ -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(&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();
@@ -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");