From 201bb1e07ca2789fc7a50dc785edc989c45f227a Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 27 Aug 2026 13:21:46 +0330 Subject: [PATCH] fix(startup): support redirected Windows storage paths (#37) - Issue #37: allow Windows AppData junctions to resolve before the main window starts. - Keep reparse-point rejection for user-selected download and recovery paths. - Add Windows junction coverage and v1.3.1 schema migration data-preservation coverage. --- src-tauri/src/db.rs | 51 ++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 17 +++++++++++++- src-tauri/src/storage.rs | 30 ++++++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index d080753..642ee83 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2523,6 +2523,57 @@ mod tests { })); } + #[test] + fn migrates_v1_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, + 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); + CREATE TABLE download_ownership ( + id TEXT PRIMARY KEY, + primary_path TEXT NOT NULL + ); + INSERT INTO download_ownership VALUES ('download-1', '/downloads/file.bin'); + PRAGMA user_version = 1; + ", + ) + .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!(table_exists(&connection, "download_owned_paths").unwrap()); + assert!(table_exists(&connection, "download_removal_paths").unwrap()); + assert_eq!( + load_ownership(&connection).unwrap(), + vec![( + "download-1".to_string(), + "/downloads/file.bin".to_string(), + vec!["/downloads/file.bin".to_string()] + )] + ); + assert!(fs::read_dir(temp.path()).unwrap().flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("firelink.sqlite.backup-schema-v1-") + })); + } + #[cfg(unix)] #[test] fn refuses_to_open_a_database_symlink() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7cc6385..75cfba8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3328,6 +3328,21 @@ fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { } pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool { + path_has_component_matching(path, metadata_is_link_or_reparse) +} + +/// Detect only symbolic-link components, without treating every Windows +/// reparse point as a link. Trusted application-data directories may use +/// junctions for Windows folder redirection; user-selected download and +/// recovery paths continue to use the stricter helper above. +pub(crate) fn path_has_symbolic_link_component(path: &std::path::Path) -> bool { + path_has_component_matching(path, |metadata| metadata.file_type().is_symlink()) +} + +fn path_has_component_matching( + path: &std::path::Path, + matches: impl Fn(&std::fs::Metadata) -> bool, +) -> bool { use std::path::Component; let mut current = std::path::PathBuf::new(); @@ -3339,7 +3354,7 @@ pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool { Component::Normal(name) => { current.push(name); if std::fs::symlink_metadata(¤t) - .is_ok_and(|metadata| metadata_is_link_or_reparse(&metadata)) + .is_ok_and(|metadata| matches(&metadata)) { return true; } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 4c82bde..5e24c45 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -251,7 +251,7 @@ fn aria2_server_stat_is_valid(contents: &str) -> bool { } fn canonicalize_storage_path(path: &Path) -> Result { - if crate::path_has_symlink_component(path) { + if crate::path_has_symbolic_link_component(path) { return Err(format!( "storage path contains a symlinked component: '{}'", path.display() @@ -455,4 +455,32 @@ mod tests { assert!(canonicalize_storage_path(Path::new(&redirected)).is_err()); } + + #[cfg(windows)] + #[test] + fn accepts_windows_junctions_for_redirected_storage_paths() { + use std::process::Command; + + let parent = TempDir::new().unwrap(); + let spaced_parent = parent.path().join("firelink test data"); + fs::create_dir(&spaced_parent).unwrap(); + let root = TempDir::new_in(&spaced_parent).unwrap(); + let target = TempDir::new_in(&spaced_parent).unwrap(); + let redirected = root.path().join("redirected"); + let target_storage = target.path().join("firelink"); + fs::create_dir(&target_storage).unwrap(); + + let status = Command::new("cmd") + .args(["/D", "/C", "mklink", "/J"]) + .arg(&redirected) + .arg(target.path()) + .status() + .expect("Windows junction creation command should start"); + assert!(status.success(), "mklink /J failed with status {status}"); + + assert_eq!( + canonicalize_storage_path(&redirected.join("firelink")).unwrap(), + fs::canonicalize(target_storage).unwrap() + ); + } }