mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 01:44:01 +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");
|
||||
|
||||
@@ -30,6 +30,7 @@ const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||
const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
|
||||
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||
const PROTOCOL_VERSION: &str = "4";
|
||||
|
||||
@@ -140,6 +141,13 @@ async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
|
||||
PROTOCOL_VERSION_HEADER,
|
||||
HeaderValue::from_static(PROTOCOL_VERSION),
|
||||
);
|
||||
if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() {
|
||||
if let Ok(process_id) = HeaderValue::from_str(&std::process::id().to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SMOKE_PROCESS_ID_HEADER, process_id);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ pub struct PlatformInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub target_triple: String,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+126
-21
@@ -1959,6 +1959,7 @@ pub mod queue;
|
||||
pub mod process;
|
||||
pub mod retry;
|
||||
mod settings;
|
||||
mod storage;
|
||||
pub use error::AppError;
|
||||
|
||||
// Retained only for compatibility with the optional aria2 diagnostic monitor.
|
||||
@@ -1970,6 +1971,7 @@ pub enum TaskHandle {
|
||||
|
||||
pub struct AppState {
|
||||
pub download_coordinator: download::DownloadCoordinator,
|
||||
pub storage_layout: crate::storage::StorageLayout,
|
||||
pub extension_pairing_token: extension_server::SharedExtensionToken,
|
||||
pub extension_frontend_ready: extension_server::SharedFrontendReady,
|
||||
pub extension_server_port: extension_server::SharedServerPort,
|
||||
@@ -3818,11 +3820,12 @@ fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_platform_info() -> crate::ipc::PlatformInfo {
|
||||
fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformInfo {
|
||||
crate::ipc::PlatformInfo {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
target_triple: crate::platform::target_triple(),
|
||||
portable: state.storage_layout.is_portable(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4346,17 +4349,42 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_keychain_password(id: String, password: String) -> Result<(), String> {
|
||||
fn set_keychain_password(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
if state.storage_layout.is_portable()
|
||||
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
crate::db::set_keychain_password(&id, &password)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_keychain_password(id: String) -> Result<String, String> {
|
||||
fn get_keychain_password(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<String, String> {
|
||||
if state.storage_layout.is_portable()
|
||||
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
|
||||
{
|
||||
return Err("portable pairing token is stored in portable settings".to_string());
|
||||
}
|
||||
crate::db::get_keychain_password(&id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_keychain_password(id: String) -> Result<(), String> {
|
||||
fn delete_keychain_password(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
if state.storage_layout.is_portable()
|
||||
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
crate::db::delete_keychain_password(&id)
|
||||
}
|
||||
|
||||
@@ -4399,17 +4427,23 @@ fn hydrate_extension_pairing_token(
|
||||
});
|
||||
}
|
||||
|
||||
// No token in the DB yet — generate one and save it so future launches
|
||||
// find it without prompting.
|
||||
// No token in the DB yet — generate one. Portable mode initializes the
|
||||
// settings row immediately so the pairing secret travels with the folder;
|
||||
// standard mode remains session-only until the user grants credential-store
|
||||
// access when settings have not been persisted yet.
|
||||
let generated = crate::db::generate_pairing_token();
|
||||
crate::db::save_pairing_token_to_settings(&connection, &generated)?;
|
||||
crate::db::save_pairing_token_to_settings(
|
||||
&connection,
|
||||
&generated,
|
||||
app_state.storage_layout.is_portable(),
|
||||
)?;
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = generated.clone();
|
||||
}
|
||||
Ok(PairingTokenHydration {
|
||||
token: generated,
|
||||
token_changed: false,
|
||||
persistent: false,
|
||||
persistent: app_state.storage_layout.is_portable(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
@@ -4421,6 +4455,26 @@ fn grant_keychain_access(
|
||||
) -> Result<PairingTokenHydration, String> {
|
||||
let mut connection = database.lock()?;
|
||||
|
||||
if app_state.storage_layout.is_portable() {
|
||||
let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)?
|
||||
{
|
||||
existing
|
||||
} else {
|
||||
let generated = crate::db::generate_pairing_token();
|
||||
crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
|
||||
generated
|
||||
};
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = token.clone();
|
||||
}
|
||||
return Ok(PairingTokenHydration {
|
||||
token,
|
||||
token_changed: false,
|
||||
persistent: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Explicitly force migration of any legacy token to the keychain.
|
||||
// This is the ONLY code path that touches the OS keychain and it is
|
||||
// reached exclusively through the frontend's "Grant Access" button,
|
||||
@@ -4431,7 +4485,7 @@ fn grant_keychain_access(
|
||||
Ok((token, token_changed)) => {
|
||||
// Persist the token to the settings DB so future startups
|
||||
// can read it without touching the keychain at all.
|
||||
let _ = crate::db::save_pairing_token_to_settings(&connection, &token);
|
||||
let _ = crate::db::save_pairing_token_to_settings(&connection, &token, false);
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = token.clone();
|
||||
}
|
||||
@@ -4475,6 +4529,11 @@ fn db_save_settings(
|
||||
let connection = state.lock()?;
|
||||
let existing = crate::db::load_settings(&connection)?;
|
||||
let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?;
|
||||
let merged = if state.is_portable() {
|
||||
crate::settings::preserve_portable_pairing_token(existing.as_deref(), &merged)?
|
||||
} else {
|
||||
merged
|
||||
};
|
||||
crate::db::save_settings(&connection, &merged)?;
|
||||
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?;
|
||||
if let Ok(mut cached) = app_state.scheduler_settings.write() {
|
||||
@@ -4502,8 +4561,9 @@ fn db_replace_downloads(
|
||||
state: tauri::State<'_, crate::db::DbState>,
|
||||
data: String,
|
||||
) -> Result<(), String> {
|
||||
let portable = state.is_portable();
|
||||
let mut connection = state.lock()?;
|
||||
crate::db::replace_downloads(&mut connection, &data)
|
||||
crate::db::replace_downloads(&mut connection, &data, portable)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -4531,8 +4591,11 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool {
|
||||
}
|
||||
|
||||
async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
use tauri::Manager;
|
||||
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
|
||||
let log_dir = app_handle
|
||||
.state::<AppState>()
|
||||
.storage_layout
|
||||
.log_dir()
|
||||
.to_path_buf();
|
||||
let mut files = Vec::new();
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
@@ -5563,6 +5626,20 @@ fn set_log_stream_active(active: bool) {
|
||||
pub fn run() {
|
||||
ensure_reqwest_crypto_provider();
|
||||
|
||||
let storage_mode = crate::storage::StorageMode::detect();
|
||||
let setup_storage_mode = storage_mode.clone();
|
||||
let log_target = match &storage_mode {
|
||||
crate::storage::StorageMode::Standard => tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::LogDir { file_name: None },
|
||||
),
|
||||
crate::storage::StorageMode::Portable { root } => tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::Folder {
|
||||
path: root.join("data").join("logs"),
|
||||
file_name: None,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
let extension_pairing_token = Arc::new(RwLock::new(String::new()));
|
||||
let server_pairing_token = extension_pairing_token.clone();
|
||||
let extension_frontend_ready = Arc::new(AtomicBool::new(false));
|
||||
@@ -5587,11 +5664,26 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.manage(Aria2DaemonGuard::new())
|
||||
.setup(move |app| {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.set_decorations(false)
|
||||
.map_err(|error| format!("failed to disable Windows native frame: {error}"))?;
|
||||
let storage_layout = crate::storage::StorageLayout::resolve(
|
||||
app.handle(),
|
||||
setup_storage_mode.clone(),
|
||||
)?;
|
||||
let main_window_config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "main")
|
||||
.cloned()
|
||||
.ok_or_else(|| "main window configuration is missing".to_string())?;
|
||||
let mut main_window_builder = tauri::WebviewWindowBuilder::from_config(
|
||||
app.handle(),
|
||||
&main_window_config,
|
||||
)
|
||||
.map_err(|error| format!("failed to prepare main window: {error}"))?;
|
||||
if storage_layout.is_portable() {
|
||||
main_window_builder =
|
||||
main_window_builder.data_directory(storage_layout.webview_dir().to_path_buf());
|
||||
}
|
||||
|
||||
let mut sys = sysinfo::System::new_all();
|
||||
@@ -5607,7 +5699,7 @@ pub fn run() {
|
||||
build_main_tray(app.handle())
|
||||
.map_err(|error| format!("failed to create tray menu: {error}"))?;
|
||||
|
||||
let database = crate::db::init(app.handle())
|
||||
let database = crate::db::init(&storage_layout)
|
||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||
let initial_pairing_token = {
|
||||
// Generate a temporary session token for the extension server on startup.
|
||||
@@ -5671,6 +5763,7 @@ pub fn run() {
|
||||
|
||||
app.manage(AppState {
|
||||
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
|
||||
storage_layout,
|
||||
extension_pairing_token,
|
||||
extension_frontend_ready,
|
||||
extension_server_port,
|
||||
@@ -5683,6 +5776,20 @@ pub fn run() {
|
||||
queue_manager,
|
||||
});
|
||||
|
||||
// Build the window only after all command state is registered. This
|
||||
// prevents the frontend from racing startup and invoking IPC before
|
||||
// the database and portable storage layout are available.
|
||||
main_window_builder
|
||||
.build()
|
||||
.map_err(|error| format!("failed to create main window: {error}"))?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.set_decorations(false)
|
||||
.map_err(|error| format!("failed to disable Windows native frame: {error}"))?;
|
||||
}
|
||||
|
||||
let deep_link_app = app.handle().clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(error) = app.deep_link().register_all() {
|
||||
@@ -5970,9 +6077,7 @@ pub fn run() {
|
||||
tauri_plugin_log::Builder::new()
|
||||
.targets([
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
|
||||
file_name: None,
|
||||
}),
|
||||
log_target,
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview)
|
||||
.filter(|_| {
|
||||
LOG_STREAM_ACTIVE.load(std::sync::atomic::Ordering::Acquire)
|
||||
|
||||
@@ -77,6 +77,41 @@ pub fn preserve_scheduler_runtime_keys(
|
||||
.map_err(|error| format!("failed to encode persisted settings: {error}"))
|
||||
}
|
||||
|
||||
pub fn preserve_portable_pairing_token(
|
||||
existing: Option<&str>,
|
||||
incoming: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(existing) = existing else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
|
||||
let existing_document = decode_document(&Value::String(existing.to_string()))?;
|
||||
let existing_state = settings_state(&existing_document)?;
|
||||
let Some(token) = existing_state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
|
||||
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
||||
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
||||
let incoming_token_present = incoming_state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !incoming_token_present {
|
||||
incoming_state.insert(
|
||||
"extensionPairingToken".to_string(),
|
||||
Value::String(token.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::to_string(&incoming_document)
|
||||
.map_err(|error| format!("failed to encode portable settings: {error}"))
|
||||
}
|
||||
|
||||
fn decode_document(stored: &Value) -> Result<Value, String> {
|
||||
match stored {
|
||||
Value::String(text) => serde_json::from_str(text)
|
||||
@@ -320,7 +355,9 @@ fn default_settings() -> PersistedSettings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_stored_settings, preserve_scheduler_runtime_keys};
|
||||
use super::{
|
||||
decode_stored_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
@@ -510,11 +547,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ignores_legacy_extension_pairing_token_field() {
|
||||
// Older versions persisted `extensionPairingToken` as plaintext inside
|
||||
// the settings document. It now lives in the OS keychain and is no
|
||||
// longer part of PersistedSettings. serde ignores the unknown field so
|
||||
// existing installs decode without error; the plaintext value is
|
||||
// simply dropped and a fresh token is minted by the frontend.
|
||||
// Older standard installs persisted `extensionPairingToken` as
|
||||
// plaintext inside the settings document. It now lives in the OS
|
||||
// keychain and is no longer part of PersistedSettings; portable mode
|
||||
// deliberately keeps its token in the portable settings document.
|
||||
// serde ignores the unknown field so existing installs decode without
|
||||
// error; standard-mode migration drops the plaintext value.
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "plaintext-leaked-secret",
|
||||
@@ -527,4 +565,32 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_portable_pairing_token_when_startup_save_races() {
|
||||
let existing = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "portable-token",
|
||||
"maxConcurrentDownloads": 3
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
let incoming = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "",
|
||||
"maxConcurrentDownloads": 5
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap();
|
||||
let document: Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(
|
||||
document["state"]["extensionPairingToken"],
|
||||
"portable-token"
|
||||
);
|
||||
assert_eq!(document["state"]["maxConcurrentDownloads"], 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
Standard,
|
||||
Portable { root: PathBuf },
|
||||
}
|
||||
|
||||
impl StorageMode {
|
||||
pub fn detect() -> Self {
|
||||
let Some(executable) = std::env::current_exe().ok() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
let Some(root) = executable.parent() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
|
||||
if root.join(PORTABLE_MARKER).is_file() {
|
||||
Self::Portable {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn detect_from_root(root: &Path) -> Self {
|
||||
if root.join(PORTABLE_MARKER).is_file() {
|
||||
Self::Portable {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StorageLayout {
|
||||
mode: StorageMode,
|
||||
data_dir: PathBuf,
|
||||
log_dir: PathBuf,
|
||||
webview_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl StorageLayout {
|
||||
pub fn resolve<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
mode: StorageMode,
|
||||
) -> Result<Self, String> {
|
||||
match mode {
|
||||
StorageMode::Standard => Ok(Self {
|
||||
mode: StorageMode::Standard,
|
||||
data_dir: app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("failed to resolve app data directory: {error}"))?,
|
||||
log_dir: app_handle
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.map_err(|error| format!("failed to resolve app log directory: {error}"))?,
|
||||
webview_dir: app_handle.path().app_local_data_dir().map_err(|error| {
|
||||
format!("failed to resolve app local data directory: {error}")
|
||||
})?,
|
||||
}),
|
||||
StorageMode::Portable { root } => {
|
||||
let data_dir = root.join(PORTABLE_DATA_DIR);
|
||||
Ok(Self {
|
||||
mode: StorageMode::Portable { root },
|
||||
log_dir: data_dir.join(PORTABLE_LOG_DIR),
|
||||
webview_dir: data_dir.join(PORTABLE_WEBVIEW_DIR),
|
||||
data_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_portable(&self) -> bool {
|
||||
matches!(self.mode, StorageMode::Portable { .. })
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
pub fn log_dir(&self) -> &Path {
|
||||
&self.log_dir
|
||||
}
|
||||
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn marker_selects_portable_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
StorageMode::detect_from_root(root.path()),
|
||||
StorageMode::Portable {
|
||||
root: root.path().to_path_buf()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_marker_keeps_standard_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
StorageMode::detect_from_root(root.path()),
|
||||
StorageMode::Standard
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user