From d49c7bd292fc0b6a89cc8f28e4c65f96a1beb753 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sun, 23 Aug 2026 03:24:55 +0330 Subject: [PATCH] fix(downloads): harden recovery, replacement, and cleanup - make media credential recovery durable and explicit across lifecycle entry points - protect exact output replacement with platform-aware ownership, fingerprints, locks, and crash-safe quarantine - permanently remove unfinished assets while preserving safe completed and Torrent cleanup - add frontend, native, and localization regressions --- src-tauri/src/db.rs | 194 +- src-tauri/src/download_ownership.rs | 96 +- src-tauri/src/ipc.rs | 35 + src-tauri/src/lib.rs | 1838 ++++++++++++++++- src-tauri/src/platform.rs | 155 +- src-tauri/src/queue.rs | 199 +- src-tauri/tests/queue_manager.rs | 12 +- src/bindings/DownloadAssetRemovalPolicy.ts | 3 + src/bindings/DownloadItem.ts | 2 +- src/bindings/DownloadTargetInfo.ts | 4 + src/bindings/DownloadTargetKind.ts | 3 + src/bindings/EnqueueItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 51 +- src/components/DeleteConfirmationModal.tsx | 19 +- src/components/DownloadItem.tsx | 13 +- src/components/DownloadTable.tsx | 14 +- .../DuplicateResolutionModal.test.ts | 26 + src/components/DuplicateResolutionModal.tsx | 27 +- src/components/PropertiesWindowApp.tsx | 16 +- src/components/Sidebar.tsx | 16 +- src/i18n/catalogs/en.ts | 2 + src/i18n/catalogs/fa.ts | 2 + src/i18n/catalogs/he.ts | 2 + src/i18n/catalogs/ru.ts | 2 + src/i18n/catalogs/uk.ts | 2 + src/i18n/catalogs/zh-CN.ts | 2 + src/ipc.ts | 5 +- src/store/downloadStore.ts | 14 +- src/store/useDownloadStore.test.ts | 332 ++- src/store/useDownloadStore.ts | 327 ++- src/utils/addDownloadMetadata.ts | 2 + src/utils/downloads.test.ts | 7 + src/utils/downloads.ts | 10 + 33 files changed, 3242 insertions(+), 192 deletions(-) create mode 100644 src/bindings/DownloadAssetRemovalPolicy.ts create mode 100644 src/bindings/DownloadTargetInfo.ts create mode 100644 src/bindings/DownloadTargetKind.ts create mode 100644 src/components/DuplicateResolutionModal.test.ts diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ab5b285..d080753 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1678,17 +1678,33 @@ pub fn load_ownership(connection: &Connection) -> Result>(2)? - .and_then(|paths| serde_json::from_str::>(&paths).ok()) - .filter(|paths| !paths.is_empty()) - .unwrap_or_else(|| vec![primary_path.clone()]); - Ok((row.get(0)?, primary_path, owned_paths)) + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) }) .map_err(|error| format!("failed to query ownership data: {error}"))?; - rows.collect::, _>>() - .map_err(|error| format!("failed to read ownership data: {error}")) + let mut ownership = Vec::new(); + for row in rows { + let (id, primary_path, encoded_paths) = + row.map_err(|error| format!("failed to read ownership data: {error}"))?; + let owned_paths = match encoded_paths { + Some(encoded) => { + let paths = serde_json::from_str::>(&encoded).map_err(|error| { + format!("failed to decode owned paths for download '{id}': {error}") + })?; + if paths.is_empty() { + vec![primary_path.clone()] + } else { + paths + } + } + None => vec![primary_path.clone()], + }; + ownership.push((id, primary_path, owned_paths)); + } + Ok(ownership) } pub fn set_ownership_paths( @@ -1697,7 +1713,17 @@ pub fn set_ownership_paths( primary_path: &str, paths: &[String], ) -> Result<(), String> { - set_ownership_paths_checked(connection, id, primary_path, paths, &[]) + // The path collision check and both ownership writes must be one SQLite + // transaction. Otherwise two concurrent admissions can both observe an + // empty registry and claim the same output before either insert becomes + // visible to the other. + let transaction = connection + .unchecked_transaction() + .map_err(|error| format!("failed to begin download ownership transaction: {error}"))?; + set_ownership_paths_checked(&transaction, id, primary_path, paths, &[])?; + transaction + .commit() + .map_err(|error| format!("failed to commit download ownership transaction: {error}")) } fn set_ownership_paths_checked( @@ -1718,21 +1744,38 @@ fn set_ownership_paths_checked( .map_err(|error| format!("failed to prepare download ownership check: {error}"))?; let existing = statement .query_map(params![id], |row| { - let primary: String = row.get(1)?; - let owned = row - .get::<_, Option>(2)? - .and_then(|value| serde_json::from_str::>(&value).ok()) - .unwrap_or_else(|| vec![primary.clone()]); - let removal = row - .get::<_, Option>(3)? - .and_then(|value| serde_json::from_str::>(&value).ok()) - .unwrap_or_default(); - Ok((primary, owned, removal)) + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) }) .map_err(|error| format!("failed to check download ownership paths: {error}"))?; for row in existing { - let (existing_primary, owned, removal) = + let (existing_id, existing_primary, encoded_owned, encoded_removal) = row.map_err(|error| format!("failed to read download ownership paths: {error}"))?; + let owned = match encoded_owned { + Some(encoded) => { + let paths = serde_json::from_str::>(&encoded).map_err(|error| { + format!("failed to decode owned paths for download '{existing_id}': {error}") + })?; + if paths.is_empty() { + vec![existing_primary.clone()] + } else { + paths + } + } + None => vec![existing_primary.clone()], + }; + let removal = match encoded_removal { + Some(encoded) => serde_json::from_str::>(&encoded).map_err(|error| { + format!( + "failed to decode removal paths for download '{existing_id}': {error}" + ) + })?, + None => Vec::new(), + }; let new_paths = std::iter::once(primary_path) .chain(paths.iter().map(String::as_str)) .chain(removal_paths.iter().map(String::as_str)); @@ -1924,6 +1967,31 @@ pub fn load_torrent_removal_paths( .map(|paths| paths.unwrap_or_default()) } +pub fn load_all_torrent_removal_paths( + connection: &Connection, +) -> Result)>, String> { + let mut statement = connection + .prepare("SELECT id, paths FROM download_removal_paths") + .map_err(|error| format!("failed to prepare torrent removal ownership query: {error}"))?; + let rows = statement + .query_map([], |row| { + let id: String = row.get(0)?; + let encoded: String = row.get(1)?; + Ok((id, encoded)) + }) + .map_err(|error| format!("failed to query torrent removal ownership: {error}"))?; + + rows.map(|row| { + let (id, encoded) = row + .map_err(|error| format!("failed to read torrent removal ownership: {error}"))?; + let paths = serde_json::from_str::>(&encoded).map_err(|error| { + format!("failed to decode torrent removal paths for download '{id}': {error}") + })?; + Ok((id, paths)) + }) + .collect() +} + pub fn has_user_data(connection: &Connection) -> Result { connection .query_row( @@ -3471,6 +3539,90 @@ mod tests { .unwrap(); } + #[test] + fn malformed_owned_path_json_fails_closed_for_loading_and_new_claims() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + connection + .execute( + "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)", + params!["broken-owned", "/downloads/broken.bin"], + ) + .unwrap(); + connection + .execute( + "INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2)", + params!["broken-owned", "{not-json"], + ) + .unwrap(); + + let error = + load_ownership(&connection).expect_err("malformed owned paths must not be ignored"); + assert!(error.contains("broken-owned")); + + let error = set_ownership_paths( + &connection, + "later", + "/downloads/later.bin", + &["/downloads/later.bin".to_string()], + ) + .expect_err("new ownership claims must fail closed"); + assert!(error.contains("broken-owned")); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM download_ownership WHERE id = 'later'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + } + + #[test] + fn malformed_removal_path_json_fails_closed_for_loading_and_new_claims() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + connection + .execute( + "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)", + params!["broken-removal", "/downloads/broken.bin"], + ) + .unwrap(); + connection + .execute( + "INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2)", + params!["broken-removal", "{not-json"], + ) + .unwrap(); + + let error = load_all_torrent_removal_paths(&connection) + .expect_err("malformed removal paths must not be ignored"); + assert!(error.contains("broken-removal")); + + let error = set_ownership_paths( + &connection, + "later", + "/downloads/later.bin", + &["/downloads/later.bin".to_string()], + ) + .expect_err("new ownership claims must fail closed"); + assert!(error.contains("broken-removal")); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM download_ownership WHERE id = 'later'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + } + #[test] fn torrent_ownership_and_removal_reservation_commit_atomically() { let temp = TempDir::new().unwrap(); diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 72db05f..18f499f 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -346,10 +346,27 @@ pub fn known_primary_paths( }) .collect(); - // One-time compatibility for downloads created before the backend-owned - // registry existed. This imports the exact persisted queue path only. - for path in legacy_download_queue_paths(app_handle)? { - if !paths.iter().any(|existing| existing == &path) { + let database = app_handle.state::(); + let connection = database.lock()?; + for (_, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? { + for path in removal_paths.into_iter().map(PathBuf::from) { + if !paths + .iter() + .any(|existing| crate::platform::paths_equal(existing, &path)) + { + paths.push(path); + } + } + } + drop(connection); + + // Compatibility for downloads created before the backend-owned registry + // existed. Import only the exact persisted queue paths. + for (_, path) in legacy_download_queue_path_records(app_handle)? { + if !paths + .iter() + .any(|existing| crate::platform::paths_equal(existing, &path)) + { paths.push(path); } } @@ -357,6 +374,65 @@ pub fn known_primary_paths( Ok(paths) } +/// Return the Firelink download that owns an exact output path, if any. +/// +/// This is intentionally based on the persisted ownership registry rather +/// than on the visible download list. The renderer can be stale while a +/// queued/native lifecycle is being admitted, so duplicate replacement must +/// make this decision at the native boundary. +pub fn owner_for_path( + app_handle: &tauri::AppHandle, + path: &Path, +) -> Result, String> { + let canonical = crate::canonicalize_with_missing_components(path) + .ok_or_else(|| "Download target could not be canonicalized".to_string())?; + let mut owners = Vec::new(); + for record in load_records(app_handle)? { + let primary = PathBuf::from(&record.primary_path); + if crate::platform::paths_equal(&primary, &canonical) + || record + .owned_paths + .iter() + .map(PathBuf::from) + .any(|owned| crate::platform::paths_equal(&owned, &canonical)) + { + owners.push(record.id); + } + } + + let database = app_handle.state::(); + let connection = database.lock()?; + for (id, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? { + if removal_paths + .into_iter() + .map(PathBuf::from) + .any(|removal| crate::platform::paths_equal(&removal, &canonical)) + && !owners.contains(&id) + { + owners.push(id); + } + } + drop(connection); + + // Older rows may predate the ownership registry. They still represent + // Firelink-owned targets and must not be downgraded to unmanaged disk + // files merely because their migration record is absent. + for (id, legacy_path) in legacy_download_queue_path_records(app_handle)? { + if crate::platform::paths_equal(&legacy_path, &canonical) && !owners.contains(&id) { + owners.push(id); + } + } + + match owners.len() { + 0 => Ok(None), + 1 => Ok(owners.pop()), + _ => Err(format!( + "Download target is claimed by multiple Firelink downloads: {}", + owners.join(", ") + )), + } +} + fn load_records(app_handle: &tauri::AppHandle) -> Result, String> { let database = app_handle.state::(); let connection = database.lock()?; @@ -372,9 +448,9 @@ fn load_records(app_handle: &tauri::AppHandle) -> Result( +fn legacy_download_queue_path_records( app_handle: &tauri::AppHandle, -) -> Result, String> { +) -> Result, String> { let settings = crate::settings::load_settings(app_handle).ok(); let downloads = { @@ -383,7 +459,7 @@ fn legacy_download_queue_paths( parse_legacy_download_items(crate::db::load_downloads(&connection)?) }; - let mut paths = Vec::new(); + let mut paths: Vec<(String, PathBuf)> = Vec::new(); for download in downloads { let category = format!("{:?}", download.category); let mut destinations = Vec::new(); @@ -442,8 +518,10 @@ fn legacy_download_queue_paths( for destination in destinations { if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) { - if !paths.iter().any(|existing| existing == &path) { - paths.push(path); + if !paths.iter().any(|(id, existing)| { + id == &download.id && crate::platform::paths_equal(existing, &path) + }) { + paths.push((download.id.clone(), path)); } } } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 4a465a6..ff79b67 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -153,6 +153,38 @@ pub enum DownloadErrorKind { DestinationAccess, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum DownloadTargetKind { + Missing, + RegularFile, + Directory, + Symlink, + Special, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct DownloadTargetInfo { + pub kind: DownloadTargetKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub owned_by: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum DownloadAssetRemovalPolicy { + Trash, + PermanentIfUnfinished, +} + #[derive(Clone, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] @@ -222,6 +254,9 @@ pub struct DownloadItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub last_resolver_fallback: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub replace_existing_fingerprint: Option, #[ts(optional)] pub last_try: Option, #[serde(default)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b97270f..eef1568 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2589,6 +2589,22 @@ static MEDIA_METADATA_CACHE: OnceLock>>>, > = OnceLock::new(); +static DOWNLOAD_TARGET_LOCKS: OnceLock< + tokio::sync::Mutex>>>, +> = OnceLock::new(); + +async fn download_target_lock(path: &std::path::Path) -> std::sync::Arc> { + let key = crate::platform::path_identity(path); + let locks = DOWNLOAD_TARGET_LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); + let mut guard = locks.lock().await; + guard.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = guard.get(&key).and_then(std::sync::Weak::upgrade) { + return lock; + } + let lock = std::sync::Arc::new(tokio::sync::Mutex::new(())); + guard.insert(key, std::sync::Arc::downgrade(&lock)); + lock +} #[allow(clippy::too_many_arguments)] // Hash every user-controlled yt-dlp input explicitly. fn media_metadata_cache_key( @@ -3297,6 +3313,20 @@ fn destination_io_error_code(error: &std::io::Error) -> &'static str { } } +fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + return metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool { use std::path::Component; @@ -3309,7 +3339,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.file_type().is_symlink()) + .is_ok_and(|metadata| metadata_is_link_or_reparse(&metadata)) { return true; } @@ -3331,7 +3361,7 @@ pub(crate) fn canonicalize_with_missing_components( loop { match std::fs::symlink_metadata(existing) { Ok(metadata) => { - if metadata.file_type().is_symlink() { + if metadata_is_link_or_reparse(&metadata) { return None; } break; @@ -4675,6 +4705,31 @@ fn resolve_bundled_binary_path( crate::engines::resolve_bundled_binary_path(app_handle, binary_name) } +fn normalize_media_cookie_source(source: Option<&str>) -> Result, String> { + let Some(source) = source.map(str::trim).filter(|source| !source.is_empty()) else { + return Ok(None); + }; + let source = source.to_ascii_lowercase(); + if source == "none" { + return Ok(None); + } + if matches!( + source.as_str(), + "safari" + | "chrome" + | "chromium" + | "firefox" + | "edge" + | "brave" + | "opera" + | "vivaldi" + | "whale" + ) { + return Ok(Some(source)); + } + Err("Unsupported media browser-cookie source".to_string()) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn start_media_download_internal( app_handle: tauri::AppHandle, @@ -4695,6 +4750,7 @@ pub(crate) async fn start_media_download_internal( max_tries: Option, cancel_rx: &mut tokio::sync::watch::Receiver, ) -> Result { + let cookie_source = normalize_media_cookie_source(cookie_source.as_deref())?; let safe_filename = crate::download_ownership::canonical_download_filename(&filename); let resolved_dest = resolve_path(&destination, &app_handle); @@ -4769,7 +4825,7 @@ pub(crate) async fn start_media_download_internal( .max(0) as usize; let concurrent_fragments = normalize_media_connections(connections); let mut strike = 0_usize; - let mut effective_cookie_source = cookie_source.clone(); + let mut effective_cookie_source = cookie_source; let mut browser_cookie_fallback_used = false; while strike <= max_retries { @@ -5929,11 +5985,19 @@ async fn remove_download( id: String, delete_assets: bool, preserve_resumable: Option, + asset_removal_policy: Option, expected_lifecycle_generation: Option, ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; log::info!("remove_download called for id: {}", id); let preserve_resumable = preserve_resumable.unwrap_or(false); + // The permanent policy is deliberately opt-in and is resolved against the + // durable row while the lifecycle guard is held. A missing or malformed + // row cannot safely be classified as unfinished, so refuse asset removal + // before stopping anything when this policy is requested. + let permanent_if_unfinished_requested = delete_assets + && !preserve_resumable + && asset_removal_policy == Some(crate::ipc::DownloadAssetRemovalPolicy::PermanentIfUnfinished); let expected_lifecycle_generation = expected_lifecycle_generation .map(|generation| { generation @@ -5941,7 +6005,21 @@ async fn remove_download( .map_err(|_| "Invalid expected download lifecycle generation".to_string()) }) .transpose()?; - let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + let mut control_guard = Some(state.queue_manager.acquire_aria2_control(&id).await); + let mut cleanup_control_guard: Option = None; + // Classify the removal from the durable row only after taking the same + // lifecycle guard that protects stopping the native owner. This prevents + // a completed/unfinished decision from racing a terminal transition or a + // replacement lifecycle for the same download id. + let permanent_asset_removal = if permanent_if_unfinished_requested { + let persisted = load_persisted_download_item( + &app_handle.state::(), + &id, + )?; + !matches!(persisted.status, crate::ipc::DownloadStatus::Completed) + } else { + false + }; let active_kind = state.queue_manager.active_kind(&id).await; let registered_lifecycle_generation = state @@ -6049,9 +6127,9 @@ async fn remove_download( // Do not delete the guessed magnet path and ownership record until a // late GID has been removed; otherwise the daemon can finish creating // an output after cleanup and leave an untracked file behind. - drop(control_guard); + drop(control_guard.take()); state.queue_manager.wait_for_aria2_dispatch(&id).await; - let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; + let reacquired_control_guard = state.queue_manager.acquire_aria2_control(&id).await; let current_generation = state .queue_manager .registered_lifecycle_generation(&id) @@ -6120,40 +6198,115 @@ async fn remove_download( state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_torrent_telemetry(&id).await; state.queue_manager.forget_aria2_gid(&id).await; + cleanup_control_guard = Some(reacquired_control_guard); } + // Keep lifecycle ownership fenced while the exact owned paths and the + // durable ownership record are inspected and cleaned. The no-GID branch + // already reacquired this guard after waiting for a late dispatch; the + // mapped-GID branch can reuse a fresh guard after the daemon stopped. + let _cleanup_control_guard = match cleanup_control_guard { + Some(guard) => guard, + None => { + drop(control_guard.take()); + state.queue_manager.acquire_aria2_control(&id).await + } + }; + let owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?; let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?; - use tauri::Emitter; - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), - ); - let preserve_assets = preserve_resumable && (owned_paths.iter().any(|path| has_resumable_download_assets(path)) || primary_path .as_ref() .is_some_and(|path| has_resumable_download_assets(path))); + if delete_assets && !preserve_assets { + if owned_paths.is_empty() && primary_path.is_none() { + return Err( + "Cannot remove download files because Firelink ownership is unavailable".to_string(), + ); + } + + // A legacy row can still claim a path even when the newer ownership + // registry has a record for this id. Revalidate every exact target + // immediately before cleanup so a stale or colliding registry entry + // cannot delete another download's payload. + let mut ownership_targets = owned_paths.clone(); + if let Some(primary) = primary_path.as_ref() { + ownership_targets.push(primary.clone()); + } + ownership_targets.sort_by_key(|path| crate::platform::path_identity(path)); + ownership_targets.dedup_by(|left, right| crate::platform::paths_equal(left, right)); + for target in ownership_targets { + if let Some(owner) = crate::download_ownership::owner_for_path(&app_handle, &target)? { + if owner != id { + return Err(format!( + "Cannot remove download files because '{}' is owned by another Firelink download", + target.display() + )); + } + } + } + } + + use tauri::Emitter; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), + ); + + // Asset cleanup and exact replacement share the same target namespace. + // Hold every affected target lock in a stable order until the ownership + // record is removed, so another download cannot claim a path between + // deleting its payload/sidecars and releasing Firelink ownership. + let mut cleanup_target_guards = Vec::new(); + if delete_assets && !preserve_assets { + let mut cleanup_targets = owned_paths.clone(); + if let Some(primary) = primary_path + .as_ref() + .filter(|primary| primary_path_needs_container_cleanup(primary, &owned_paths)) + { + cleanup_targets.push(primary.clone()); + } + cleanup_targets.sort_by_key(|path| crate::platform::path_identity(path)); + cleanup_targets.dedup_by(|left, right| crate::platform::paths_equal(left, right)); + for target in cleanup_targets { + cleanup_target_guards.push(download_target_lock(&target).await.lock_owned().await); + } + } + let cleanup_result = async { if delete_assets && !preserve_assets { for path in &owned_paths { - remove_download_assets(path, &app_handle).await?; + if permanent_asset_removal { + remove_download_assets_permanently(path, &app_handle).await?; + } else { + remove_download_assets(path, &app_handle).await?; + } } if let Some(primary) = primary_path .as_ref() .filter(|primary| primary_path_needs_container_cleanup(primary, &owned_paths)) { - remove_download_container_assets(primary, &app_handle).await?; + if permanent_asset_removal { + remove_download_container_assets_permanently(primary, &app_handle).await?; + } else { + remove_download_container_assets(primary, &app_handle).await?; + } } } - crate::torrent::remove_managed_torrent(&app_handle, &id).await; + if permanent_asset_removal { + remove_managed_torrent_permanently(&app_handle, &id).await?; + } else { + crate::torrent::remove_managed_torrent(&app_handle, &id).await; + } crate::download_ownership::remove(&app_handle, &id)?; Ok::<(), String>(()) } .await; + drop(cleanup_target_guards); state.queue_manager.release_registered_id(&id).await; cleanup_result @@ -6262,6 +6415,339 @@ async fn remove_download_sidecars( Ok(()) } +async fn remove_exact_file_permanently( + path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + let Some(expected_fingerprint) = exact_file_fingerprint_for_permanent_removal(path, app_handle).await? else { + return Ok(()); + }; + + for attempt in 0..=5 { + match exact_file_fingerprint_for_permanent_removal(path, app_handle).await? { + Some(current) if current == expected_fingerprint => {} + Some(_) => { + return Err(format!( + "refusing to permanently remove '{}' because it changed during cleanup", + path.display() + )); + } + None => return Ok(()), + } + match tokio::fs::remove_file(path).await { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) if attempt == 5 => { + return Err(format!( + "could not permanently remove '{}' after retries: {error}", + path.display() + )); + } + Err(_) => { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + } + Ok(()) +} + +async fn exact_file_fingerprint_for_permanent_removal( + path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result, String> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("could not inspect '{}' before deletion: {error}", path.display())), + }; + if metadata_is_link_or_reparse(&metadata) || path_has_symlink_component(path) { + return Err(format!( + "refusing to permanently remove symbolic-link asset '{}'", + path.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "refusing to permanently remove non-file asset '{}'", + path.display() + )); + } + if !is_safe_path(path, app_handle) { + return Err(format!( + "download asset path '{}' is outside an allowed download location", + path.display() + )); + } + Ok(Some(target_fingerprint(&metadata))) +} + +async fn validate_exact_file_for_permanent_removal( + path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result { + Ok(exact_file_fingerprint_for_permanent_removal(path, app_handle) + .await? + .is_some()) +} + +async fn remove_download_sidecars_permanently( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + validate_download_sidecars_permanently(primary, app_handle).await?; + for suffix in [".aria2", ".part", ".ytdl"] { + let mut candidate_os = primary.as_os_str().to_os_string(); + candidate_os.push(suffix); + let candidate = std::path::PathBuf::from(candidate_os); + if validate_exact_file_for_permanent_removal(&candidate, app_handle).await? { + remove_exact_file_permanently(&candidate, app_handle).await?; + } + } + Ok(()) +} + +async fn validate_download_sidecars_permanently( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + for suffix in [".aria2", ".part", ".ytdl"] { + let mut candidate_os = primary.as_os_str().to_os_string(); + candidate_os.push(suffix); + let candidate = std::path::PathBuf::from(candidate_os); + let _ = validate_exact_file_for_permanent_removal(&candidate, app_handle).await?; + } + Ok(()) +} + +async fn collect_media_processing_artifacts_for_permanent_removal( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result, String> { + let Some(parent) = primary.parent() else { + return Ok(Vec::new()); + }; + if !is_safe_path(parent, app_handle) { + return Err("download media artifact directory is outside an allowed location".to_string()); + } + let Some(base_name) = primary.file_name().and_then(|name| name.to_str()) else { + return Ok(Vec::new()); + }; + let base_stem = primary + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or(base_name); + let mut entries = match tokio::fs::read_dir(parent).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("could not inspect media artifacts: {error}")), + }; + let mut artifacts = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| format!("could not inspect media artifacts: {error}"))? + { + let path = entry.path(); + if crate::platform::paths_equal(&path, primary) { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !is_media_artifact_name(name, base_name, base_stem) { + continue; + } + validate_exact_file_for_permanent_removal(&path, app_handle).await?; + artifacts.push(path); + } + Ok(artifacts) +} + +async fn remove_download_assets_permanently( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + let artifacts = collect_media_processing_artifacts_for_permanent_removal(primary, app_handle).await?; + // Validate every exact sidecar before deleting the primary payload. A + // symlink or special-file substitution must leave the row/ownership + // intact rather than producing a partially-cleaned download silently. + validate_download_sidecars_permanently(primary, app_handle).await?; + validate_exact_file_for_permanent_removal(primary, app_handle).await?; + remove_exact_file_permanently(primary, app_handle).await?; + remove_download_sidecars_permanently(primary, app_handle).await?; + for artifact in artifacts { + remove_exact_file_permanently(&artifact, app_handle).await?; + } + Ok(()) +} + +async fn remove_download_container_assets_permanently( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + let metadata = match tokio::fs::symlink_metadata(primary).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // The output directory may already be gone while Aria2's exact + // sidecars remain. They are still safe to remove by name. + remove_download_sidecars_permanently(primary, app_handle).await?; + return Ok(()); + } + Err(error) => return Err(format!("could not inspect Torrent output directory: {error}")), + }; + if metadata_is_link_or_reparse(&metadata) || path_has_symlink_component(primary) { + return Err(format!( + "refusing to permanently remove symbolic-link Torrent directory '{}'", + primary.display() + )); + } + if !metadata.is_dir() { + return Err(format!( + "Torrent output container '{}' is not a directory", + primary.display() + )); + } + if !is_safe_path(primary, app_handle) { + return Err("Torrent output directory is outside an allowed download location".to_string()); + } + + // Validate sidecars before any exact entry is removed. The removal helper + // revalidates again under the lifecycle/target lock immediately before + // unlinking, so a later substitution still fails closed. + validate_download_sidecars_permanently(primary, app_handle).await?; + + let mut entries = tokio::fs::read_dir(primary) + .await + .map_err(|error| format!("could not inspect Torrent output directory: {error}"))?; + let mut metadata_entries = Vec::new(); + let mut has_unrelated_entries = false; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| format!("could not inspect Torrent output directory: {error}"))? + { + let path = entry.path(); + let entry_metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|error| format!("could not inspect Torrent output entry: {error}"))?; + if metadata_is_link_or_reparse(&entry_metadata) { + return Err(format!( + "refusing to remove Torrent output directory containing symbolic link '{}'", + path.display() + )); + } + if !is_os_directory_metadata(&entry.file_name()) { + has_unrelated_entries = true; + continue; + } + if !entry_metadata.is_file() { + return Err(format!( + "refusing to remove non-file OS metadata entry '{}'", + path.display() + )); + } + validate_exact_file_for_permanent_removal(&path, app_handle).await?; + metadata_entries.push(path); + } + + remove_download_sidecars_permanently(primary, app_handle).await?; + for path in metadata_entries { + remove_exact_file_permanently(&path, app_handle).await?; + } + if has_unrelated_entries { + log::debug!( + "keeping Torrent output directory '{}': unrelated entries remain", + primary.display() + ); + return Ok(()); + } + + for attempt in 0..=5 { + match tokio::fs::remove_dir(primary).await { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => { + // A user or another process may have added an unrelated file + // after inspection. Preserve it and leave the directory in + // place; owned files were already removed exactly. + log::debug!( + "keeping Torrent output directory '{}': it became non-empty during cleanup", + primary.display() + ); + return Ok(()); + } + Err(error) if attempt == 5 => { + return Err(format!( + "could not permanently remove Torrent output directory '{}' after retries: {error}", + primary.display() + )); + } + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await, + } + } + Ok(()) +} + +async fn remove_managed_torrent_permanently( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result<(), String> { + let path = crate::torrent::managed_torrent_path(app_handle, id)?; + let expected_fingerprint = match managed_torrent_metadata_fingerprint(&path, app_handle).await? { + Some(fingerprint) => fingerprint, + None => return Ok(()), + }; + + for attempt in 0..=5 { + let current_fingerprint = match managed_torrent_metadata_fingerprint(&path, app_handle).await? { + Some(fingerprint) => fingerprint, + None => return Ok(()), + }; + if current_fingerprint != expected_fingerprint { + return Err("managed Torrent metadata changed during cleanup".to_string()); + } + match tokio::fs::remove_file(&path).await { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) if attempt == 5 => { + return Err(format!( + "could not permanently remove managed Torrent metadata after retries: {error}" + )); + } + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await, + } + } + Ok(()) +} + +async fn managed_torrent_metadata_fingerprint( + path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result, String> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("could not inspect managed Torrent metadata: {error}")), + }; + let root = crate::torrent::managed_torrent_storage_root(app_handle)?; + if path_has_symlink_component(&root) || path_has_symlink_component(&path) { + return Err("refusing to permanently remove Torrent metadata through a symbolic link".to_string()); + } + let canonical_root = std::fs::canonicalize(&root) + .map_err(|error| format!("could not validate managed Torrent metadata storage: {error}"))?; + let canonical_parent = path + .parent() + .and_then(|parent| std::fs::canonicalize(parent).ok()) + .ok_or_else(|| "managed Torrent metadata storage is unavailable".to_string())?; + if !crate::platform::paths_equal(&canonical_root, &canonical_parent) { + return Err("managed Torrent metadata path is outside its app-owned storage".to_string()); + } + if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { + return Err("refusing to permanently remove non-regular managed Torrent metadata".to_string()); + } + Ok(Some(target_fingerprint(&metadata))) +} + fn is_os_directory_metadata(name: &std::ffi::OsStr) -> bool { matches!( name.to_str(), @@ -7130,7 +7616,7 @@ struct ExpectedTorrentOutputPaths { unselected: Vec, } -#[derive(Clone)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct DownloadOwnershipSnapshot { primary: Option, owned: Vec, @@ -7170,6 +7656,19 @@ fn restore_download_ownership( ) } +fn restore_download_replacement_ownership( + app_handle: &tauri::AppHandle, + id: &str, + previous: Option, +) -> Result<(), String> { + match previous { + Some(snapshot) => restore_download_ownership(app_handle, id, snapshot), + // Journals created before the ownership snapshot was added can only + // be recovered safely by dropping the temporary ownership record. + None => crate::download_ownership::remove(app_handle, id), + } +} + fn expected_torrent_output_paths( app_handle: &tauri::AppHandle, id: &str, @@ -7279,6 +7778,8 @@ fn register_download_ownership( app_handle: &tauri::AppHandle, item: &queue::EnqueueItem, ) -> Result<(), String> { + let output_paths = enqueue_output_paths(app_handle, item)?; + ensure_enqueue_output_paths_unclaimed(app_handle, &item.id, &output_paths)?; if item.is_torrent.unwrap_or(false) { return register_torrent_output_ownership( app_handle, @@ -7304,6 +7805,675 @@ fn register_download_ownership( ) } +fn enqueue_output_paths( + app_handle: &tauri::AppHandle, + item: &queue::EnqueueItem, +) -> Result, String> { + if item.is_torrent.unwrap_or(false) { + if let Some(paths) = expected_torrent_output_paths( + app_handle, + &item.id, + &item.destination, + &item.filename, + item.torrent_path.as_deref(), + item.torrent_file_indices.as_deref(), + item.torrent_remove_unselected_file.unwrap_or(false), + )? { + let mut output_paths = Vec::with_capacity( + 1 + paths.selected.len() + paths.unselected.len(), + ); + output_paths.push(paths.primary); + output_paths.extend(paths.selected); + output_paths.extend(paths.unselected); + return Ok(output_paths); + } + } + + Ok(vec![crate::download_ownership::expected_primary_path( + app_handle, + &item.destination, + &item.filename, + )?]) +} + +fn enqueue_output_lock_path( + app_handle: &tauri::AppHandle, + item: &queue::EnqueueItem, +) -> Result { + enqueue_output_paths(app_handle, item)? + .into_iter() + .next() + .ok_or_else(|| "Download enqueue produced no output path".to_string()) +} + +fn ensure_enqueue_output_paths_unclaimed( + app_handle: &tauri::AppHandle, + id: &str, + paths: &[std::path::PathBuf], +) -> Result<(), String> { + let mut checked = Vec::new(); + for path in paths { + if checked + .iter() + .any(|existing: &std::path::PathBuf| crate::platform::paths_equal(existing, path)) + { + continue; + } + checked.push(path.clone()); + if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, path)? { + if owner != id { + return Err(format!( + "Download destination is already owned by Firelink download {owner}" + )); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct DownloadReplacementJournal { + id: String, + target: std::path::PathBuf, + quarantine: std::path::PathBuf, + fingerprint: String, + phase: String, + #[serde(default)] + previous_ownership: Option, +} + +#[derive(Debug, Clone)] +struct DownloadReplacementReservation { + journal_path: std::path::PathBuf, + target: std::path::PathBuf, + quarantine: std::path::PathBuf, + fingerprint: String, +} + +fn safe_replacement_component(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect() +} + +async fn write_download_replacement_journal( + path: &std::path::Path, + journal: &DownloadReplacementJournal, +) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(journal) + .map_err(|_| "Could not encode download replacement recovery".to_string())?; + crate::platform::atomic_write_replace(path, &bytes) + .await + .map_err(|_| "Could not commit download replacement recovery".to_string()) +} + +async fn mark_download_replacement_admitted( + reservation: &DownloadReplacementReservation, +) -> Result<(), String> { + let bytes = tokio::fs::read(&reservation.journal_path) + .await + .map_err(|error| format!("Could not read download replacement recovery: {error}"))?; + let mut journal: DownloadReplacementJournal = serde_json::from_slice(&bytes) + .map_err(|_| "Could not decode download replacement recovery".to_string())?; + if journal.target != reservation.target + || journal.quarantine != reservation.quarantine + || journal.fingerprint != reservation.fingerprint + { + return Err("Download replacement recovery no longer matches its reservation".to_string()); + } + journal.phase = "admitted".to_string(); + write_download_replacement_journal(&reservation.journal_path, &journal).await +} + +async fn restore_download_replacement( + reservation: &DownloadReplacementReservation, +) -> Result<(), String> { + let quarantine_metadata = match tokio::fs::symlink_metadata(&reservation.quarantine).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err( + "Cannot restore replacement because its quarantine is missing; recovery journal retained" + .to_string(), + ); + } + Err(error) => return Err(format!("Could not inspect replacement quarantine: {error}")), + }; + if metadata_is_link_or_reparse(&quarantine_metadata) || !quarantine_metadata.is_file() { + return Err("Cannot restore replacement quarantine because it is not a regular file".to_string()); + } + if target_fingerprint(&quarantine_metadata) != reservation.fingerprint { + return Err("Cannot restore replacement quarantine because its contents changed".to_string()); + } + if tokio::fs::symlink_metadata(&reservation.target).await.is_ok() { + return Err(format!( + "Cannot restore replaced target '{}': the destination changed while admission was in progress", + reservation.target.display() + )); + } + restore_quarantine_file_without_replacing(reservation, "replaced target").await +} + +async fn restore_staged_replacement_after_validation_failure( + reservation: &DownloadReplacementReservation, +) -> Result<(), String> { + let quarantine_metadata = tokio::fs::symlink_metadata(&reservation.quarantine) + .await + .map_err(|error| format!("Could not inspect staged replacement file: {error}"))?; + if metadata_is_link_or_reparse(&quarantine_metadata) || !quarantine_metadata.is_file() { + return Err("Cannot restore staged replacement because it is not a regular file".to_string()); + } + if tokio::fs::symlink_metadata(&reservation.target).await.is_ok() { + return Err(format!( + "Cannot restore staged replacement '{}': the destination changed while admission was in progress", + reservation.target.display() + )); + } + restore_quarantine_file_without_replacing(reservation, "staged replacement").await +} + +async fn restore_quarantine_file_without_replacing( + reservation: &DownloadReplacementReservation, + description: &str, +) -> Result<(), String> { + match tokio::fs::hard_link(&reservation.quarantine, &reservation.target).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(format!( + "Could not restore {description}: the destination changed while admission was in progress" + )); + } + Err(error) => { + return Err(format!("Could not restore {description}: {error}")); + } + } + tokio::fs::remove_file(&reservation.quarantine) + .await + .map_err(|error| format!("Could not clear {description} quarantine: {error}"))?; + tokio::fs::remove_file(&reservation.journal_path) + .await + .map_err(|error| format!("Could not clear replacement recovery: {error}")) +} + +fn restore_quarantine_file_without_replacing_sync( + quarantine: &std::path::Path, + target: &std::path::Path, +) -> Result<(), String> { + if std::fs::symlink_metadata(target).is_ok() { + return Err("the destination changed while admission was in progress".to_string()); + } + match std::fs::hard_link(quarantine, target) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err("the destination changed while admission was in progress".to_string()); + } + Err(error) => return Err(format!("could not restore quarantined file: {error}")), + } + std::fs::remove_file(quarantine) + .map_err(|error| format!("could not clear quarantined file: {error}")) +} + +async fn finalize_download_replacement( + reservation: &DownloadReplacementReservation, +) { + let quarantine_metadata = match tokio::fs::symlink_metadata(&reservation.quarantine).await { + Ok(metadata) if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() => { + log::warn!( + "download replacement cleanup [{}]: retained recovery journal because quarantine is not a regular file", + reservation.target.display() + ); + return; + } + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let _ = tokio::fs::remove_file(&reservation.journal_path).await; + return; + } + Err(error) => { + log::warn!( + "download replacement cleanup [{}]: retained recovery journal because quarantine could not be inspected: {}", + reservation.target.display(), + error + ); + return; + } + }; + if target_fingerprint(&quarantine_metadata) != reservation.fingerprint { + log::warn!( + "download replacement cleanup [{}]: retained recovery journal because quarantine changed", + reservation.target.display() + ); + return; + } + match tokio::fs::remove_file(&reservation.quarantine).await { + Ok(()) => { + let _ = tokio::fs::remove_file(&reservation.journal_path).await; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let _ = tokio::fs::remove_file(&reservation.journal_path).await; + } + Err(error) => { + log::warn!( + "download replacement cleanup [{}]: retained recovery journal after quarantine removal failed: {}", + reservation.target.display(), + error + ); + } + } +} + +async fn prepare_download_replacement( + app_handle: &tauri::AppHandle, + state: &AppState, + item: &queue::EnqueueItem, + target: &std::path::Path, + lifecycle_generation: u64, + previous_ownership: &DownloadOwnershipSnapshot, +) -> Result, String> { + let Some(expected_fingerprint) = item.replace_existing_fingerprint.as_deref() else { + return Ok(None); + }; + if item.is_torrent.unwrap_or(false) { + return Err("Exact replacement is not supported for Torrent output directories".to_string()); + } + + let metadata = match std::fs::symlink_metadata(target) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Could not inspect replacement target: {error}")), + }; + if metadata_is_link_or_reparse(&metadata) { + return Err("Cannot replace a symbolic-link download target".to_string()); + } + if !metadata.is_file() { + return Err("Exact replacement requires a regular file target".to_string()); + } + if !is_safe_path(target, app_handle) { + return Err("Download replacement target is outside an approved location".to_string()); + } + if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { + return Err(format!( + "Download target is already owned by Firelink download {owner}" + )); + } + let current_fingerprint = target_fingerprint(&metadata); + if current_fingerprint != expected_fingerprint { + return Err( + "The file changed after duplicate resolution. Reopen the conflict and choose Replace again." + .to_string(), + ); + } + + let parent = target + .parent() + .ok_or_else(|| "Download replacement target has no parent directory".to_string())?; + let transaction_id = uuid::Uuid::new_v4().simple().to_string(); + let quarantine = parent.join(format!( + ".firelink-replaced-{}-{}.tmp", + safe_replacement_component(&item.id), + transaction_id + )); + if crate::path_has_symlink_component(&quarantine) { + return Err("Download replacement quarantine path contains a symbolic link".to_string()); + } + let journal_dir = state.storage_layout.data_dir().join("download-replacements"); + tokio::fs::create_dir_all(&journal_dir) + .await + .map_err(|error| format!("Could not prepare download replacement recovery: {error}"))?; + let journal_path = journal_dir.join(format!( + "{}-{}-{}.json", + safe_replacement_component(&item.id), + lifecycle_generation, + transaction_id + )); + let reservation = DownloadReplacementReservation { + journal_path: journal_path.clone(), + target: target.to_path_buf(), + quarantine: quarantine.clone(), + fingerprint: expected_fingerprint.to_string(), + }; + let mut journal = DownloadReplacementJournal { + id: item.id.clone(), + target: target.to_path_buf(), + quarantine, + fingerprint: expected_fingerprint.to_string(), + phase: "prepared".to_string(), + previous_ownership: Some(previous_ownership.clone()), + }; + write_download_replacement_journal(&journal_path, &journal).await?; + + if let Err(error) = tokio::fs::rename(target, &reservation.quarantine).await { + let _ = tokio::fs::remove_file(&journal_path).await; + return Err(format!("Could not stage the existing file for replacement: {error}")); + } + let staged_metadata = match tokio::fs::symlink_metadata(&reservation.quarantine).await { + Ok(metadata) => metadata, + Err(error) => { + if let Err(restore_error) = restore_download_replacement(&reservation).await { + log::warn!( + "download replacement [{}]: could not restore the staged file after verification failed: {}", + item.id, + restore_error + ); + } + return Err(format!("Could not verify staged replacement file: {error}")); + } + }; + if metadata_is_link_or_reparse(&staged_metadata) + || !staged_metadata.is_file() + || target_fingerprint(&staged_metadata) != expected_fingerprint + { + let _ = restore_staged_replacement_after_validation_failure(&reservation).await; + return Err("The replacement target changed while it was being staged".to_string()); + } + journal.phase = "quarantined".to_string(); + if let Err(error) = write_download_replacement_journal(&journal_path, &journal).await { + let _ = restore_download_replacement(&reservation).await; + return Err(error); + } + Ok(Some(reservation)) +} + +fn validate_unmanaged_download_target( + app_handle: &tauri::AppHandle, + id: &str, + target: &std::path::Path, +) -> Result<(), String> { + let metadata = match std::fs::symlink_metadata(target) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A legacy Firelink row can own a path even while its payload is + // absent. Do not let a new row reserve that same output merely + // because there is no current directory entry to inspect. + if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { + if owner != id { + return Err(format!( + "Download destination is already owned by Firelink download {owner}" + )); + } + } + return Ok(()) + } + Err(error) => return Err(format!("Could not inspect download target: {error}")), + }; + if metadata_is_link_or_reparse(&metadata) { + return Err("Download target is a symbolic link and cannot be replaced automatically".to_string()); + } + if metadata.is_dir() { + return Err("Download target is a directory; choose a different filename".to_string()); + } + if !metadata.is_file() { + return Err("Download target is a special file and cannot be replaced".to_string()); + } + if !is_safe_path(target, app_handle) { + return Err("Download target is outside an approved location".to_string()); + } + if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { + if owner == id { + return Ok(()); + } + return Err(format!( + "Download target is already owned by Firelink download {owner}" + )); + } + Err("Download target already exists; choose Replace or Rename".to_string()) +} + +fn recover_download_replacement_journals( + app_handle: &tauri::AppHandle, + storage_layout: &crate::storage::StorageLayout, +) -> Result<(), String> { + let directory = storage_layout.data_dir().join("download-replacements"); + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("could not inspect download replacement recovery: {error}")), + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let journal: DownloadReplacementJournal = match std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(journal) => journal, + None => { + log::warn!("leaving malformed download replacement journal for manual recovery"); + continue; + } + }; + if journal.id.is_empty() + || !matches!(journal.phase.as_str(), "prepared" | "quarantined" | "admitted") + || !is_safe_path(&journal.target, app_handle) + || journal + .quarantine + .parent() + .is_none_or(|parent| !is_safe_path(parent, app_handle)) + || journal + .quarantine + .parent() + .zip(journal.target.parent()) + .is_none_or(|(quarantine_parent, target_parent)| { + !crate::platform::paths_equal(quarantine_parent, target_parent) + }) + || journal + .quarantine + .file_name() + .and_then(|name| name.to_str()) + .is_none_or(|name| { + !name.starts_with(".firelink-replaced-") || !name.ends_with(".tmp") + }) + || path_has_symlink_component(&journal.quarantine) + { + log::warn!("leaving unsafe download replacement journal for manual recovery"); + continue; + } + let owner = crate::download_ownership::owner_for_path(app_handle, &journal.target)?; + let target_exists = match std::fs::symlink_metadata(&journal.target) { + Ok(metadata) if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() => { + log::warn!( + "leaving download replacement journal [{}]: target is not a regular file", + journal.id + ); + continue; + } + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + log::warn!( + "leaving download replacement journal [{}]: target could not be inspected: {}", + journal.id, + error + ); + continue; + } + }; + let quarantine_exists = match std::fs::symlink_metadata(&journal.quarantine) { + Ok(metadata) + if metadata_is_link_or_reparse(&metadata) + || !metadata.is_file() + || target_fingerprint(&metadata) != journal.fingerprint => + { + log::warn!( + "leaving download replacement journal [{}]: quarantine is not the original regular file", + journal.id + ); + continue; + } + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + log::warn!( + "leaving download replacement journal [{}]: quarantine could not be inspected: {}", + journal.id, + error + ); + continue; + } + }; + if owner.as_deref() == Some(journal.id.as_str()) { + if journal.phase == "admitted" { + let quarantine_removed = if quarantine_exists { + match std::fs::remove_file(&journal.quarantine) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + log::warn!( + "download replacement recovery [{}] could not remove the admitted quarantine: {}", + journal.id, + error + ); + false + } + } + } else { + true + }; + if quarantine_removed { + if let Err(error) = std::fs::remove_file(&path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "download replacement recovery [{}] could not clear its journal: {}", + journal.id, + error + ); + } + } + } + } else if quarantine_exists { + if target_exists { + // Before the durable admitted marker, a target at this + // path is ambiguous: it may be a new Firelink output or + // an unrelated file that appeared during the crash + // window. Never discard the quarantined original or + // adopt the replacement silently in that state. + log::warn!( + "leaving download replacement journal [{}]: target appeared before admission was durably recorded", + journal.id + ); + continue; + } + // Ownership is written before the queue commit so duplicate + // admission is fenced. If the process stopped in that + // window, restore both the original file and the ownership + // record that existed before replacement. + if let Err(error) = restore_quarantine_file_without_replacing_sync( + &journal.quarantine, + &journal.target, + ) { + log::warn!( + "download replacement recovery [{}] could not restore the pre-admission target: {}", + journal.id, + error + ); + } else if let Err(error) = restore_download_replacement_ownership( + app_handle, + &journal.id, + journal.previous_ownership.clone(), + ) { + log::warn!( + "download replacement recovery [{}] restored the file but could not restore ownership: {}", + journal.id, + error + ); + } else if let Err(error) = std::fs::remove_file(&path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "download replacement recovery [{}] could not clear its journal: {}", + journal.id, + error + ); + } + } + } else { + log::warn!( + "leaving download replacement journal [{}]: ownership exists but quarantine is missing", + journal.id + ); + } + } else if owner.is_none() && !target_exists && quarantine_exists { + if let Err(error) = restore_quarantine_file_without_replacing_sync( + &journal.quarantine, + &journal.target, + ) { + log::warn!( + "download replacement recovery [{}] could not restore the old target: {}", + journal.id, + error + ); + } else if let Err(error) = restore_download_replacement_ownership( + app_handle, + &journal.id, + journal.previous_ownership.clone(), + ) { + log::warn!( + "download replacement recovery [{}] restored the file but could not restore ownership: {}", + journal.id, + error + ); + } else if let Err(error) = std::fs::remove_file(&path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "download replacement recovery [{}] could not clear its journal: {}", + journal.id, + error + ); + } + } + } else if owner.is_none() && journal.phase == "admitted" && target_exists { + if quarantine_exists { + match std::fs::remove_file(&journal.quarantine) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + log::warn!( + "download replacement recovery [{}] could not remove the admitted quarantine: {}", + journal.id, + error + ); + continue; + } + } + } + if let Err(error) = std::fs::remove_file(&path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "download replacement recovery [{}] could not clear its journal: {}", + journal.id, + error + ); + } + } + } else if owner.is_none() && !quarantine_exists && !target_exists { + // Both sides are already absent. Keep the recovery operation + // idempotent and remove only its own journal. + if let Err(error) = std::fs::remove_file(&path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "download replacement recovery [{}] could not clear its journal: {}", + journal.id, + error + ); + } + } + } else { + log::warn!( + "leaving download replacement journal [{}]: target/quarantine ownership state is ambiguous", + journal.id + ); + } + } + Ok(()) +} + fn register_torrent_output_ownership( app_handle: &tauri::AppHandle, id: &str, @@ -7720,6 +8890,21 @@ async fn enqueue_download_locked( let id = item.id.clone(); item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let accepted_filename = item.filename.clone(); + let target_path = if item.is_torrent.unwrap_or(false) { + None + } else { + Some( + crate::download_ownership::expected_primary_path( + app_handle, + &item.destination, + &item.filename, + ) + .map_err(AppError::Internal)?, + ) + }; + let target_lock_path = enqueue_output_lock_path(app_handle, &item) + .map_err(AppError::Internal)?; + let _target_guard = Some(download_target_lock(&target_lock_path).await.lock_owned().await); let lifecycle_generation = enqueue_lifecycle_generation(&item).map_err(AppError::Internal)?; let previous_generation = state .queue_manager @@ -7736,7 +8921,50 @@ async fn enqueue_download_locked( return Err(AppError::Internal(error)); } }; + let replacement = if let Some(target) = target_path.as_ref() { + let replacement = match prepare_download_replacement( + app_handle, + state, + &item, + target, + lifecycle_generation, + &previous_ownership, + ) + .await + { + Ok(replacement) => replacement, + Err(error) => { + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + }; + if replacement.is_none() { + item.replace_existing_fingerprint = None; + if let Err(error) = validate_unmanaged_download_target(app_handle, &item.id, target) { + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + } + replacement + } else { + None + }; if let Err(error) = register_download_ownership(app_handle, &item) { + if let Some(reservation) = replacement.as_ref() { + if let Err(restore_error) = restore_download_replacement(reservation).await { + log::error!( + "download replacement [{}]: failed to restore after ownership registration failed: {}", + id, + restore_error + ); + } + } if let Err(restore_error) = restore_download_ownership(app_handle, &id, previous_ownership) { log::error!( "download ownership [{}]: failed to restore the previous mapping after enqueue registration failed: {}", @@ -7750,11 +8978,31 @@ async fn enqueue_download_locked( .await; return Err(AppError::Internal(error)); } - if let Err(error) = state + let reservation_for_commit = replacement.clone(); + let commit_result = state .queue_manager - .commit_reserved_enqueue(item.into_task(), lifecycle_generation) - .await - { + .commit_reserved_enqueue_with_finalizer( + item.into_task(), + lifecycle_generation, + previous_generation, + move || async move { + match reservation_for_commit.as_ref() { + Some(reservation) => mark_download_replacement_admitted(reservation).await, + None => Ok(()), + } + }, + ) + .await; + if let Err(error) = commit_result { + if let Some(reservation) = replacement.as_ref() { + if let Err(restore_error) = restore_download_replacement(reservation).await { + log::error!( + "download replacement [{}]: failed to restore after enqueue commit failed: {}", + id, + restore_error + ); + } + } if let Err(restore_error) = restore_download_ownership(app_handle, &id, previous_ownership) { log::error!( "download ownership [{}]: failed to restore the previous mapping after enqueue commit failed: {}", @@ -7768,6 +9016,9 @@ async fn enqueue_download_locked( .await; return Err(AppError::Internal(error)); } + if let Some(reservation) = replacement.as_ref() { + finalize_download_replacement(reservation).await; + } Ok(crate::ipc::EnqueueAccepted { id, filename: accepted_filename, @@ -7846,6 +9097,39 @@ async fn enqueue_many( } item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let filename = item.filename.clone(); + let target_path = if item.is_torrent.unwrap_or(false) { + None + } else { + match crate::download_ownership::expected_primary_path( + &app_handle, + &item.destination, + &item.filename, + ) { + Ok(path) => Some(path), + Err(error) => { + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + } + }; + let target_lock_path = match enqueue_output_lock_path(&app_handle, &item) { + Ok(path) => path, + Err(error) => { + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + }; + let _target_guard = download_target_lock(&target_lock_path).await.lock_owned().await; let lifecycle_generation = match enqueue_lifecycle_generation(&item) { Ok(generation) => generation, Err(error) => { @@ -7890,7 +9174,62 @@ async fn enqueue_many( continue; } }; + let replacement = if let Some(target) = target_path.as_ref() { + match prepare_download_replacement( + &app_handle, + state.inner(), + &item, + target, + lifecycle_generation, + &previous_ownership, + ) + .await + { + Ok(Some(replacement)) => Some(replacement), + Ok(None) => { + item.replace_existing_fingerprint = None; + if let Err(error) = validate_unmanaged_download_target(&app_handle, &id, target) { + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + None + } + Err(error) => { + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + } + } else { + None + }; if let Err(error) = register_download_ownership(&app_handle, &item) { + if let Some(replacement) = replacement.as_ref() { + if let Err(restore_error) = restore_download_replacement(replacement).await { + log::error!( + "download replacement [{}]: failed to restore after batch ownership registration failed: {}", + id, + restore_error + ); + } + } if let Err(restore_error) = restore_download_ownership(&app_handle, &id, previous_ownership) { log::error!( "download ownership [{}]: failed to restore the previous mapping after batch enqueue registration failed: {}", @@ -7910,11 +9249,31 @@ async fn enqueue_many( }); continue; } - if let Err(error) = state + let reservation_for_commit = replacement.clone(); + let commit_result = state .queue_manager - .commit_reserved_enqueue(item.into_task(), lifecycle_generation) - .await - { + .commit_reserved_enqueue_with_finalizer( + item.into_task(), + lifecycle_generation, + previous_generation, + move || async move { + match reservation_for_commit.as_ref() { + Some(reservation) => mark_download_replacement_admitted(reservation).await, + None => Ok(()), + } + }, + ) + .await; + if let Err(error) = commit_result { + if let Some(replacement) = replacement.as_ref() { + if let Err(restore_error) = restore_download_replacement(replacement).await { + log::error!( + "download replacement [{}]: failed to restore after batch enqueue commit failed: {}", + id, + restore_error + ); + } + } if let Err(restore_error) = restore_download_ownership(&app_handle, &id, previous_ownership) { log::error!( "download ownership [{}]: failed to restore the previous mapping after batch enqueue commit failed: {}", @@ -7934,6 +9293,9 @@ async fn enqueue_many( }); continue; } + if let Some(replacement) = replacement.as_ref() { + finalize_download_replacement(replacement).await; + } results.push(crate::ipc::EnqueueResult { id, success: true, @@ -7990,6 +9352,7 @@ async fn remove_from_queue( id: String, ) -> Result { properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; + let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let removed = state.queue_manager.remove_from_pending(&id).await; if removed { let _ = crate::download_ownership::remove(&app_handle, &id); @@ -8139,7 +9502,7 @@ fn torrent_file_selection_snapshot( } } -fn load_persisted_torrent_item( +fn load_persisted_download_item( database: &crate::db::DbState, id: &str, ) -> Result { @@ -8154,6 +9517,13 @@ fn load_persisted_torrent_item( .ok_or_else(|| "download is not persisted".to_string()) } +fn load_persisted_torrent_item( + database: &crate::db::DbState, + id: &str, +) -> Result { + load_persisted_download_item(database, id) +} + fn persist_torrent_destination( database: &crate::db::DbState, id: &str, @@ -8547,7 +9917,7 @@ async fn export_torrent_metadata( } let metadata = std::fs::symlink_metadata(&destination); if let Ok(metadata) = metadata { - if metadata.file_type().is_symlink() { + if metadata_is_link_or_reparse(&metadata) { return Err("The export destination cannot be a symbolic link".to_string()); } return Err("The export destination already exists".to_string()); @@ -9781,6 +11151,7 @@ async fn verify_torrent_data( torrent_verify_only: Some(true), torrent_verify_restore_status: Some(restore_status.clone()), lifecycle_generation: Some(verification_generation.to_string()), + replace_existing_fingerprint: None, }; { @@ -11337,16 +12708,95 @@ fn db_replace_queues( crate::db::replace_queues(&mut connection, &data) } +fn target_fingerprint(metadata: &std::fs::Metadata) -> String { + #[cfg(unix)] + let identity = { + use std::os::unix::fs::MetadataExt; + format!("unix:{}:{}", metadata.dev(), metadata.ino()) + }; + #[cfg(windows)] + let identity = { + use std::os::windows::fs::MetadataExt; + format!( + "windows:{}:{}", + metadata.volume_serial_number().unwrap_or_default(), + metadata.file_index().unwrap_or_default() + ) + }; + #[cfg(not(any(unix, windows)))] + let identity = "portable".to_string(); + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|value| format!("{}:{}", value.as_secs(), value.subsec_nanos())) + .unwrap_or_else(|| "unknown".to_string()); + format!("{}:{}:{}", identity, metadata.len(), modified) +} + +fn classify_download_target(metadata: &std::fs::Metadata) -> crate::ipc::DownloadTargetKind { + if metadata_is_link_or_reparse(metadata) { + crate::ipc::DownloadTargetKind::Symlink + } else if metadata.is_file() { + crate::ipc::DownloadTargetKind::RegularFile + } else if metadata.is_dir() { + crate::ipc::DownloadTargetKind::Directory + } else { + crate::ipc::DownloadTargetKind::Special + } +} + #[tauri::command] -fn check_file_exists(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, path: String) -> bool { - if properties_window::ensure_main_window(&caller).is_err() { - return false; +fn inspect_download_target( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, + path: String, +) -> Result { + properties_window::ensure_main_window(&caller)?; + let resolved = resolve_path(&path, &app_handle); + let metadata = match std::fs::symlink_metadata(&resolved) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Some(parent) = resolved.parent() { + if !is_safe_path(parent, &app_handle) { + return Err("Download target is outside an approved location".to_string()); + } + } + let owned_by = crate::download_ownership::owner_for_path(&app_handle, &resolved)?; + return Ok(crate::ipc::DownloadTargetInfo { + kind: crate::ipc::DownloadTargetKind::Missing, + fingerprint: None, + owned_by, + }); + } + Err(error) => return Err(format!("Could not inspect download target: {error}")), + }; + + let kind = classify_download_target(&metadata); + if kind == crate::ipc::DownloadTargetKind::Symlink { + if resolved + .parent() + .is_none_or(|parent| !is_safe_path(parent, &app_handle)) + { + return Err("Download target is outside an approved location".to_string()); + } + return Ok(crate::ipc::DownloadTargetInfo { + kind: crate::ipc::DownloadTargetKind::Symlink, + fingerprint: None, + owned_by: None, + }); } - let resolved_dest = resolve_path(&path, &app_handle); - if !is_safe_path(&resolved_dest, &app_handle) { - return false; + if !is_safe_path(&resolved, &app_handle) { + return Err("Download target is outside an approved location".to_string()); } - resolved_dest.exists() + + let owned_by = crate::download_ownership::owner_for_path(&app_handle, &resolved)?; + Ok(crate::ipc::DownloadTargetInfo { + kind, + fingerprint: (kind == crate::ipc::DownloadTargetKind::RegularFile) + .then(|| target_fingerprint(&metadata)), + owned_by, + }) } fn collect_log_files(log_dir: &std::path::Path) -> Result, String> { @@ -11809,6 +13259,7 @@ mod tests { aria2_active_connection_count, aria2_nonnegative_count, parse_media_playlist_metadata, normalize_media_connections, + normalize_media_cookie_source, validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id, aria2_gid_not_found, aria2_download_state_progress, preflight_download_destination_access, @@ -11816,6 +13267,9 @@ mod tests { retained_torrent_info_hash_from_persisted_record, merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair, Aria2DaemonGuard, stale_lifecycle_cleanup_is_noop, + classify_download_target, download_target_lock, + remove_download_assets_permanently, remove_download_container_assets_permanently, + restore_download_replacement, target_fingerprint, DownloadReplacementReservation, }; #[cfg(target_os = "macos")] use super::should_apply_dock_badge_update; @@ -14031,6 +15485,293 @@ mod tests { assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video")); } + #[test] + fn replacement_fingerprints_change_when_the_target_changes() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("replace.bin"); + std::fs::write(&target, b"old").unwrap(); + let initial = target_fingerprint(&std::fs::symlink_metadata(&target).unwrap()); + assert_eq!( + initial, + target_fingerprint(&std::fs::symlink_metadata(&target).unwrap()) + ); + + std::fs::write(&target, b"new content").unwrap(); + let changed = target_fingerprint(&std::fs::symlink_metadata(&target).unwrap()); + assert_ne!(initial, changed); + } + + #[test] + fn target_inspection_classifies_regular_directories_and_links_without_following_them() { + let directory = tempfile::tempdir().unwrap(); + let regular = directory.path().join("regular.bin"); + let child_directory = directory.path().join("child"); + std::fs::write(®ular, b"file").unwrap(); + std::fs::create_dir(&child_directory).unwrap(); + + assert_eq!( + classify_download_target(&std::fs::symlink_metadata(®ular).unwrap()), + crate::ipc::DownloadTargetKind::RegularFile + ); + assert_eq!( + classify_download_target(&std::fs::symlink_metadata(&child_directory).unwrap()), + crate::ipc::DownloadTargetKind::Directory + ); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(®ular, directory.path().join("link")).unwrap(); + assert_eq!( + classify_download_target( + &std::fs::symlink_metadata(directory.path().join("link")).unwrap() + ), + crate::ipc::DownloadTargetKind::Symlink + ); + + let socket_path = directory.path().join("socket"); + let _socket = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + assert_eq!( + classify_download_target(&std::fs::symlink_metadata(socket_path).unwrap()), + crate::ipc::DownloadTargetKind::Special + ); + } + } + + #[tokio::test] + async fn replacement_target_lock_is_shared_by_concurrent_admissions() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("same-target.bin"); + let first = download_target_lock(&target).await; + let second = download_target_lock(&target).await; + + assert!(std::sync::Arc::ptr_eq(&first, &second)); + let guard = first.lock().await; + assert!(second.try_lock().is_err()); + drop(guard); + assert!(second.try_lock().is_ok()); + } + + #[tokio::test] + async fn replacement_rollback_restores_quarantine_and_clears_its_journal() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.bin"); + let quarantine = directory.path().join(".firelink-replaced-target.tmp"); + let journal = directory.path().join("replacement.json"); + std::fs::write(&quarantine, b"original").unwrap(); + let fingerprint = target_fingerprint(&std::fs::symlink_metadata(&quarantine).unwrap()); + std::fs::write(&journal, b"journal").unwrap(); + + restore_download_replacement(&DownloadReplacementReservation { + journal_path: journal.clone(), + target: target.clone(), + quarantine: quarantine.clone(), + fingerprint, + }) + .await + .expect("a staged target should be restorable"); + + assert_eq!(std::fs::read(&target).unwrap(), b"original"); + assert!(!quarantine.exists()); + assert!(!journal.exists()); + } + + #[tokio::test] + async fn replacement_rollback_never_overwrites_a_new_target() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.bin"); + let quarantine = directory.path().join(".firelink-replaced-target.tmp"); + let journal = directory.path().join("replacement.json"); + std::fs::write(&target, b"new-admission").unwrap(); + std::fs::write(&quarantine, b"old-file").unwrap(); + let fingerprint = target_fingerprint(&std::fs::symlink_metadata(&quarantine).unwrap()); + std::fs::write(&journal, b"journal").unwrap(); + + let error = restore_download_replacement(&DownloadReplacementReservation { + journal_path: journal.clone(), + target: target.clone(), + quarantine: quarantine.clone(), + fingerprint, + }) + .await + .expect_err("rollback must stop when a new target already exists"); + + assert!(error.contains("destination changed")); + assert_eq!(std::fs::read(&target).unwrap(), b"new-admission"); + assert_eq!(std::fs::read(&quarantine).unwrap(), b"old-file"); + assert!(journal.exists()); + } + + #[tokio::test] + async fn replacement_rollback_rejects_a_changed_quarantine() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.bin"); + let quarantine = directory.path().join(".firelink-replaced-target.tmp"); + let journal = directory.path().join("replacement.json"); + std::fs::write(&quarantine, b"old-file").unwrap(); + let fingerprint = target_fingerprint(&std::fs::symlink_metadata(&quarantine).unwrap()); + std::fs::write(&quarantine, b"tampered-file").unwrap(); + std::fs::write(&journal, b"journal").unwrap(); + + let error = restore_download_replacement(&DownloadReplacementReservation { + journal_path: journal.clone(), + target: target.clone(), + quarantine: quarantine.clone(), + fingerprint, + }) + .await + .expect_err("rollback must reject a changed quarantine"); + + assert!(error.contains("contents changed")); + assert!(!target.exists()); + assert!(quarantine.exists()); + assert!(journal.exists()); + } + + #[tokio::test] + async fn replacement_rollback_retains_journal_when_quarantine_is_missing() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.bin"); + let quarantine = directory.path().join(".firelink-replaced-target.tmp"); + let journal = directory.path().join("replacement.json"); + std::fs::write(&journal, b"journal").unwrap(); + + let error = restore_download_replacement(&DownloadReplacementReservation { + journal_path: journal.clone(), + target, + quarantine, + fingerprint: String::new(), + }) + .await + .expect_err("rollback must retain recovery evidence when quarantine is missing"); + + assert!(error.contains("quarantine is missing")); + assert!(journal.exists()); + } + + #[tokio::test] + async fn permanent_media_cleanup_removes_only_exact_payload_and_sidecars() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = app.path().download_dir().expect("download root"); + let root_was_present = download_root.exists(); + std::fs::create_dir_all(&download_root).unwrap(); + let directory = tempfile::tempdir_in(&download_root).unwrap(); + let primary = directory.path().join("video.mp4"); + std::fs::write(&primary, b"payload").unwrap(); + for suffix in [".aria2", ".part", ".ytdl"] { + let mut path = primary.as_os_str().to_os_string(); + path.push(suffix); + std::fs::write(path, b"sidecar").unwrap(); + } + let unrelated = directory.path().join("keep.me"); + std::fs::write(&unrelated, b"unrelated").unwrap(); + + remove_download_assets_permanently(&primary, app.handle()) + .await + .expect("unfinished media cleanup should remove exact assets"); + + assert!(!primary.exists()); + assert!(!directory.path().join("video.mp4.aria2").exists()); + assert!(!directory.path().join("video.mp4.part").exists()); + assert!(!directory.path().join("video.mp4.ytdl").exists()); + assert!(unrelated.exists()); + drop(directory); + if !root_was_present { + let _ = std::fs::remove_dir(&download_root); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn permanent_cleanup_refuses_symlink_payloads() { + use std::os::unix::fs::symlink; + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = app.path().download_dir().expect("download root"); + let root_was_present = download_root.exists(); + std::fs::create_dir_all(&download_root).unwrap(); + let directory = tempfile::tempdir_in(&download_root).unwrap(); + let outside = tempfile::tempdir().unwrap(); + let outside_payload = outside.path().join("payload"); + let link = directory.path().join("video.mp4"); + std::fs::write(&outside_payload, b"outside").unwrap(); + symlink(&outside_payload, &link).unwrap(); + + let error = remove_download_assets_permanently(&link, app.handle()) + .await + .expect_err("symbolic links must never be followed or removed"); + + assert!(error.contains("symbolic-link")); + assert!(link.exists()); + assert_eq!(std::fs::read(&outside_payload).unwrap(), b"outside"); + drop(directory); + if !root_was_present { + let _ = std::fs::remove_dir(&download_root); + } + } + + #[tokio::test] + async fn permanent_torrent_container_cleanup_preserves_unrelated_files() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = app.path().download_dir().expect("download root"); + let root_was_present = download_root.exists(); + std::fs::create_dir_all(&download_root).unwrap(); + let directory = tempfile::tempdir_in(&download_root).unwrap(); + let container = directory.path().join("torrent-output"); + std::fs::create_dir(&container).unwrap(); + std::fs::write(container.join(".DS_Store"), b"metadata").unwrap(); + let unrelated = container.join("unselected.bin"); + std::fs::write(&unrelated, b"preserve").unwrap(); + + remove_download_container_assets_permanently(&container, app.handle()) + .await + .expect("container cleanup should be conservative"); + + assert!(container.is_dir()); + assert!(unrelated.exists()); + drop(directory); + if !root_was_present { + let _ = std::fs::remove_dir(&download_root); + } + } + #[cfg(target_os = "macos")] #[test] fn dock_badge_updates_reject_stale_sessions_and_generations() { @@ -14556,6 +16297,18 @@ mod tests { )); } + #[test] + fn media_cookie_source_accepts_only_configured_browsers() { + assert_eq!(normalize_media_cookie_source(None).unwrap(), None); + assert_eq!(normalize_media_cookie_source(Some(" NONE ")).unwrap(), None); + assert_eq!( + normalize_media_cookie_source(Some(" Chrome ")).unwrap(), + Some("chrome".to_string()) + ); + assert!(normalize_media_cookie_source(Some("profile=secret")).is_err()); + assert!(normalize_media_cookie_source(Some("unknown-browser")).is_err()); + } + #[test] #[ignore = "requires network and a local yt-dlp executable"] fn filters_live_youtube_metadata_from_env() { @@ -15465,13 +17218,24 @@ pub fn run() { let database = crate::db::init(&storage_layout) .map_err(|error| format!("failed to initialize persistence: {error}"))?; + // Replacement recovery consults the persisted ownership registry. + // Register the database before running any recovery pass so a + // malformed or interrupted transaction cannot reach a missing + // Tauri state entry at startup. + app.manage(database); if let Err(error) = recover_torrent_move_journals( app.handle(), - &database, + &*app.state::(), &storage_layout, ) { log::warn!("Torrent move recovery did not complete: {error}"); } + if let Err(error) = recover_download_replacement_journals( + app.handle(), + &storage_layout, + ) { + log::warn!("Download replacement recovery did not complete: {error}"); + } // Establish Firelink-owned Aria2 routing-table paths after the // existing data-root initializer has created the selected storage // directory, but before the daemon launcher is scheduled. A @@ -15490,7 +17254,8 @@ pub fn run() { if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) { log::warn!("could not remove orphaned torrent probes: {error}"); } - let retained_torrent_metadata = database + let retained_torrent_metadata = app + .state::() .lock() .and_then(|connection| crate::db::load_downloads(&connection)) .map(|records| { @@ -15548,7 +17313,6 @@ pub fn run() { .map_err(|_| "extension pairing token lock is unavailable".to_string())?; *pairing_token = initial_pairing_token; } - app.manage(database); let persisted_settings = crate::settings::load_settings(app.handle()).ok(); let logs_enabled = persisted_settings .as_ref() @@ -16921,7 +18685,7 @@ pub fn run() { get_keychain_grant_status, accept_keychain_grant, abandon_keychain_grant, authorize_keychain_access, acknowledge_pairing_token_change, - check_file_exists, toggle_tray_icon, set_extension_pairing_token, + inspect_download_target, toggle_tray_icon, set_extension_pairing_token, get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index f3ada3e..165ebd7 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -305,39 +305,115 @@ fn trusted_system_path_entries() -> Vec { pub fn path_is_within(path: &Path, root: &Path) -> bool { #[cfg(target_os = "windows")] { - let path = path.to_string_lossy().to_lowercase(); - let root = root.to_string_lossy().to_lowercase(); + let path = path_identity(path); + let root = path_identity(root); path == root + || (root.len() == 3 + && root.ends_with('/') + && root.as_bytes()[1] == b':' + && path.starts_with(&root)) || path .strip_prefix(&root) - .is_some_and(|suffix| suffix.starts_with(['\\', '/'])) + .is_some_and(|suffix| suffix.starts_with('/')) } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + // Containment is a scope check, not an equality check. Do not fold + // case here: case-sensitive APFS/HFS+ volumes are valid macOS + // configurations, and lowercasing could admit `/Users/nima2` or a + // differently-cased sibling outside the approved root. Callers pass + // canonical paths (with only missing leaf components preserved), so + // NFC normalization is enough to compare macOS path spellings. + use unicode_normalization::UnicodeNormalization; + + let path = path.to_string_lossy().nfc().collect::(); + let root = root.to_string_lossy().nfc().collect::(); + let root = root.trim_end_matches('/'); + if path == root || (root.is_empty() && path == "/") { + return true; + } + + if root.is_empty() { + return path.starts_with('/'); + } + + path.strip_prefix(root) + .is_some_and(|suffix| suffix.starts_with('/')) + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + path.starts_with(root) + } + + #[cfg(not(any(unix, target_os = "windows", target_os = "macos")))] { path.starts_with(root) } } pub fn paths_equal(left: &Path, right: &Path) -> bool { + path_identity(left) == path_identity(right) +} + +/// Return the in-process lock identity for a path using the same platform +/// equivalence rules as `paths_equal`. Callers use this for serialization, +/// not for display or persistence. +pub fn path_identity(path: &Path) -> String { #[cfg(target_os = "windows")] { - left.to_string_lossy() - .to_lowercase() - == right.to_string_lossy().to_lowercase() + let mut normalized = path.to_string_lossy().replace('\\', "/"); + if normalized + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/")) + { + normalized.replace_range(..8, "//"); + } else if normalized + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/")) + { + normalized.replace_range(..4, ""); + } + + let is_unc = normalized.starts_with("//"); + let mut collapsed = String::with_capacity(normalized.len()); + for character in normalized.chars() { + if character == '/' && collapsed.ends_with('/') && !(is_unc && collapsed.len() == 1) { + continue; + } + collapsed.push(character); + } + while collapsed.len() > 1 + && collapsed.ends_with('/') + && !(collapsed.len() == 3 && collapsed.as_bytes()[1] == b':') + { + collapsed.pop(); + } + collapsed.to_lowercase() } #[cfg(target_os = "macos")] { use unicode_normalization::UnicodeNormalization; - let normalize = |path: &Path| { - path.to_string_lossy().to_lowercase().nfc().collect::() - }; - normalize(left) == normalize(right) + path.to_string_lossy() + .to_lowercase() + .nfc() + .collect::() } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(all(unix, not(target_os = "macos")))] { - left == right + use std::os::unix::ffi::OsStrExt; + + path.as_os_str() + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + #[cfg(not(any(unix, target_os = "windows", target_os = "macos")))] + { + path.to_string_lossy().to_string() } } @@ -362,6 +438,8 @@ fn numbered_windows_device(stem: &str, prefix: &str) -> bool { #[cfg(test)] mod tests { + #[cfg(any(target_os = "windows", target_os = "macos"))] + use super::path_is_within; use super::{engine_binary_name, is_windows_reserved_filename, paths_equal, target_triple}; use std::path::Path; @@ -406,6 +484,27 @@ mod tests { } } + #[cfg(target_os = "windows")] + #[test] + fn windows_path_identity_normalizes_separators_and_verbatim_prefixes() { + assert!(paths_equal( + Path::new(r"C:\downloads\file.bin"), + Path::new("c:/DOWNLOADS/file.bin") + )); + assert!(paths_equal( + Path::new(r"C:\downloads\file.bin"), + Path::new(r"\\?\C:\downloads\file.bin") + )); + assert!(paths_equal( + Path::new(r"\\server\share\file.bin"), + Path::new(r"\\?\UNC\server\share\file.bin") + )); + assert!(path_is_within( + Path::new("c:/downloads/file.bin"), + Path::new(r"C:\downloads") + )); + } + #[test] fn path_identity_handles_non_ascii_case_differences() { let left = Path::new("/downloads/Ärt/File.bin"); @@ -427,4 +526,34 @@ mod tests { assert!(!paths_equal(composed, decomposed)); } } + + #[cfg(target_os = "macos")] + #[test] + fn macos_path_is_within_preserves_scope_and_unicode_identity() { + assert!(path_is_within( + Path::new("/Downloads/cafe\u{301}/movie.bin"), + Path::new("/Downloads/café") + )); + assert!(path_is_within( + Path::new("/Downloads/movie.bin"), + Path::new("/Downloads") + )); + assert!(path_is_within( + Path::new("/Downloads"), + Path::new("/Downloads/") + )); + assert!(path_is_within(Path::new("/"), Path::new("////"))); + assert!(path_is_within( + Path::new("/Downloads/movie.bin"), + Path::new("/") + )); + assert!(!path_is_within( + Path::new("/downloads/cafeteria/movie.bin"), + Path::new("/Downloads/café") + )); + assert!(!path_is_within( + Path::new("/downloads/movie.bin"), + Path::new("/Downloads") + )); + } } diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index fabdc81..00794b5 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -2121,24 +2121,93 @@ impl QueueManager { } pub async fn commit_reserved_enqueue( + &self, + task: QueuedTask, + generation: u64, + previous_generation: Option, + ) -> Result<(), String> { + self.commit_reserved_enqueue_with_finalizer(task, generation, previous_generation, || async { + Ok(()) + }) + .await + } + + /// Commit an enqueue and its final durable admission marker as one + /// dispatcher-visible boundary. The task is placed in the pending list + /// before the finalizer runs, but the admission gate stays held so the + /// dispatcher cannot pop it until the finalizer succeeds. If the + /// finalizer fails, the task is removed before any worker can observe it. + pub async fn commit_reserved_enqueue_with_finalizer( &self, mut task: QueuedTask, generation: u64, - ) -> Result<(), String> { + previous_generation: Option, + finalizer: F, + ) -> Result<(), String> + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + let id = task.id.clone(); let _admission_gate = self.admission_gate.lock().await; if self.system_action_pending.load(Ordering::Acquire) { + self.rollback_enqueue_reservation(&id, generation, previous_generation) + .await; return Err("System action is already being performed".to_string()); } - let id = task.id.clone(); - let cancellations = self.enqueue_cancellations.lock().await; - if cancellations - .get(&id) - .is_some_and(|cancelled| *cancelled >= generation) + if self + .registered_lifecycle_generation(&id) + .await + .is_none_or(|registered| registered != generation) { - return Err("Download enqueue was superseded by a newer user action".to_string()); + self.rollback_enqueue_reservation(&id, generation, previous_generation) + .await; + return Err("Download enqueue reservation is no longer current".to_string()); + } + { + let cancellations = self.enqueue_cancellations.lock().await; + if cancellations + .get(&id) + .is_some_and(|cancelled| *cancelled >= generation) + { + self.rollback_enqueue_reservation(&id, generation, previous_generation) + .await; + return Err("Download enqueue was superseded by a newer user action".to_string()); + } } task.lifecycle_generation = generation; self.pending.lock().await.push_back(task); + + if let Err(error) = finalizer().await { + let mut pending = self.pending.lock().await; + pending.retain(|candidate| { + !(candidate.id == id && candidate.lifecycle_generation == generation) + }); + self.rollback_enqueue_reservation(&id, generation, previous_generation) + .await; + return Err(error); + } + + // Cancellation can arrive while the durable admission marker is being + // written. Recheck it before making the task visible to the rest of + // the lifecycle; the admission gate prevents a dispatcher or queue + // mutation from observing a half-committed replacement. + { + let cancellations = self.enqueue_cancellations.lock().await; + if cancellations + .get(&id) + .is_some_and(|cancelled| *cancelled >= generation) + { + let mut pending = self.pending.lock().await; + pending.retain(|candidate| { + !(candidate.id == id && candidate.lifecycle_generation == generation) + }); + self.rollback_enqueue_reservation(&id, generation, previous_generation) + .await; + return Err("Download enqueue was superseded by a newer user action".to_string()); + } + } + self.emit_state(id, DownloadStatus::Queued); self.notify.notify_one(); Ok(()) @@ -2152,7 +2221,10 @@ impl QueueManager { ) -> Result<(), String> { let id = task.id.clone(); let previous_generation = self.reserve_enqueue_generation(&id, generation).await?; - if let Err(error) = self.commit_reserved_enqueue(task, generation).await { + if let Err(error) = self + .commit_reserved_enqueue(task, generation, previous_generation) + .await + { self.rollback_enqueue_reservation(&id, generation, previous_generation) .await; return Err(error); @@ -3395,6 +3467,7 @@ impl QueueManager { /// Pop the next task, or None if empty. pub async fn pop_front(&self) -> Option { + let _admission_gate = self.admission_gate.lock().await; self.pending.lock().await.pop_front() } @@ -5789,6 +5862,7 @@ impl QueueManager { queue_id: &str, direction: QueueDirection, ) -> Vec { + let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let queue_positions = pending .iter() @@ -5852,6 +5926,7 @@ impl QueueManager { queue_id: &str, target_index: usize, ) -> Vec { + let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let queue_positions = pending .iter() @@ -5880,6 +5955,7 @@ impl QueueManager { /// Does NOT release a permit (the caller handles active permits via /// release_permit if the task was already dispatched). pub async fn remove_from_pending(&self, id: &str) -> bool { + let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let before = pending.len(); pending.retain(|t| t.id != id); @@ -5891,6 +5967,7 @@ impl QueueManager { } pub async fn remove_from_pending_for_generation(&self, id: &str, generation: u64) -> bool { + let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let before = pending.len(); pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation)); @@ -8556,6 +8633,9 @@ pub struct EnqueueItem { #[serde(default)] #[ts(optional)] pub lifecycle_generation: Option, + #[serde(default)] + #[ts(optional)] + pub replace_existing_fingerprint: Option, } impl EnqueueItem { @@ -8756,7 +8836,7 @@ mod tests { release: Arc::clone(&release), }), )); - manager + let previous_generation = manager .reserve_enqueue_generation("allocation", 7) .await .expect("lifecycle reservation"); @@ -8770,6 +8850,7 @@ mod tests { lifecycle_generation: 7, }, 7, + previous_generation, ) .await .expect("queued task"); @@ -8796,6 +8877,106 @@ mod tests { dispatcher.abort(); } + #[tokio::test] + async fn enqueue_finalizer_failure_removes_pending_task_before_dispatch() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); + let id = "finalizer-failure"; + let generation = 3; + let previous_generation = manager + .reserve_enqueue_generation(id, generation) + .await + .expect("lifecycle reservation"); + + let error = manager + .commit_reserved_enqueue_with_finalizer( + QueuedTask { + id: id.to_string(), + queue_id: "main".to_string(), + kind: TaskKind::Aria2, + payload: SpawnPayload::default(), + lifecycle_generation: generation, + }, + generation, + previous_generation, + || async { Err("journal commit failed".to_string()) }, + ) + .await + .expect_err("a failed finalizer must reject admission"); + + assert_eq!(error, "journal commit failed"); + assert!(manager.pending_order(None).await.is_empty()); + assert_eq!(manager.registered_lifecycle_generation(id).await, None); + assert!(!manager.is_registered(id).await); + } + + #[tokio::test] + async fn enqueue_cancellation_during_finalizer_rejects_admission() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let manager = Arc::new(QueueManager::test_new( + app.handle().clone(), + 1, + Arc::new(TestSpawner), + )); + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let finalizer_started = Arc::clone(&started); + let finalizer_release = Arc::clone(&release); + let id = "finalizer-cancelled".to_string(); + let generation = 4; + let previous_generation = manager + .reserve_enqueue_generation(&id, generation) + .await + .expect("lifecycle reservation"); + + let commit_manager = Arc::clone(&manager); + let commit_id = id.clone(); + let commit = tokio::spawn(async move { + commit_manager + .commit_reserved_enqueue_with_finalizer( + QueuedTask { + id: commit_id, + queue_id: "main".to_string(), + kind: TaskKind::Aria2, + payload: SpawnPayload::default(), + lifecycle_generation: generation, + }, + generation, + previous_generation, + { + move || async move { + finalizer_started.notify_one(); + finalizer_release.notified().await; + Ok(()) + } + }, + ) + .await + }); + + tokio::time::timeout(Duration::from_secs(1), started.notified()) + .await + .expect("finalizer should begin"); + manager.cancel_enqueue_generation(&id, generation).await; + release.notify_one(); + + let error = tokio::time::timeout(Duration::from_secs(1), commit) + .await + .expect("enqueue should finish") + .expect("enqueue task should not panic") + .expect_err("cancellation must reject the in-flight admission"); + assert_eq!( + error, + "Download enqueue was superseded by a newer user action" + ); + assert!(manager.pending_order(None).await.is_empty()); + assert!(!manager.is_registered(&id).await); + } + #[tokio::test] async fn download_start_before_gid_registration_is_buffered_and_consumed() { let app = tauri::test::mock_builder() diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index b91f71d..35c5d79 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -433,7 +433,9 @@ async fn cancellation_between_reservation_and_commit_cannot_start_the_task() { .expect("reservation should succeed"); mgr.cancel_enqueue_generation("a", 7).await; - let committed = mgr.commit_reserved_enqueue(sample_task("a"), 7).await; + let committed = mgr + .commit_reserved_enqueue(sample_task("a"), 7, previous) + .await; assert!(committed.is_err(), "cancelled reservation must not commit"); mgr.rollback_enqueue_reservation("a", 7, previous).await; @@ -1799,12 +1801,16 @@ async fn duplicate_pending_id_cannot_replace_an_existing_queue_ownership() { assert!(manager .ensure_aria2_permit_for_queue("duplicate", "queue-b") .await); - manager + let previous = manager .reserve_enqueue_generation("duplicate", 1) .await .unwrap(); manager - .commit_reserved_enqueue(aria2_task_in_queue("duplicate", "queue-a"), 1) + .commit_reserved_enqueue( + aria2_task_in_queue("duplicate", "queue-a"), + 1, + previous, + ) .await .unwrap(); diff --git a/src/bindings/DownloadAssetRemovalPolicy.ts b/src/bindings/DownloadAssetRemovalPolicy.ts new file mode 100644 index 0000000..814f1c2 --- /dev/null +++ b/src/bindings/DownloadAssetRemovalPolicy.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DownloadAssetRemovalPolicy = "trash" | "permanentIfUnfinished"; diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index d82a5a3..5b90675 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -4,4 +4,4 @@ import type { DownloadErrorKind } from "./DownloadErrorKind"; import type { DownloadStatus } from "./DownloadStatus"; import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, replaceExistingFingerprint?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; diff --git a/src/bindings/DownloadTargetInfo.ts b/src/bindings/DownloadTargetInfo.ts new file mode 100644 index 0000000..7da08b9 --- /dev/null +++ b/src/bindings/DownloadTargetInfo.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DownloadTargetKind } from "./DownloadTargetKind"; + +export type DownloadTargetInfo = { kind: DownloadTargetKind, fingerprint?: string, ownedBy?: string, }; diff --git a/src/bindings/DownloadTargetKind.ts b/src/bindings/DownloadTargetKind.ts new file mode 100644 index 0000000..e158d86 --- /dev/null +++ b/src/bindings/DownloadTargetKind.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DownloadTargetKind = "missing" | "regularFile" | "directory" | "symlink" | "special"; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 9841a0a..0f56f6a 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, replace_existing_fingerprint?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index bd8bad2..289864e 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -1281,27 +1281,44 @@ export const AddDownloadsModal = () => { } } - let fileExistsOnDisk = false; + let diskTargetKind: string | null = null; + let diskTargetFingerprint: string | undefined; + let diskTargetOwner: string | undefined; try { - fileExistsOnDisk = await invoke('check_file_exists', { + const targetInfo = await invoke('inspect_download_target', { path: await resolveDownloadFilePath(itemLocation, finalFile) }); + diskTargetKind = targetInfo.kind; + diskTargetFingerprint = targetInfo.fingerprint; + diskTargetOwner = targetInfo.ownedBy; } catch (e) { console.error("Failed to check if file exists on disk:", e); } - if (existingDownload || fileExistsOnDisk) { + const fileExistsOnDisk = diskTargetKind !== null && diskTargetKind !== 'missing'; + const hasFirelinkOwnedTarget = Boolean(diskTargetOwner); + const diskReplaceAllowed = diskTargetKind === 'regularFile' + && !diskTargetOwner + && Boolean(diskTargetFingerprint); + const canReplaceExistingDownload = existingDownload + ? !isTransferLocked(existingDownload.status) + && (!diskTargetOwner || diskTargetOwner === existingDownload.id) + : false; + if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', - msg: existingDownload + msg: existingDownload || hasFirelinkOwnedTarget ? t($ => $.addDownloads.existingDownloadDestination) : t($ => $.addDownloads.fileExistsOnDisk) }, resolution: 'rename', - replaceAllowed: Boolean(existingDownload), + replaceAllowed: existingDownload ? canReplaceExistingDownload : diskReplaceAllowed, + ...(existingDownload ? {} : diskReplaceAllowed + ? { replaceFingerprint: diskTargetFingerprint } + : {}), existingDownloadId: existingDownload?.id }); } @@ -1332,7 +1349,11 @@ export const AddDownloadsModal = () => { action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, - resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[], + resolutions?: { + id: string; + resolution: 'rename' | 'replace' | 'skip'; + replaceFingerprint?: string; + }[], destinationOverrides: Record = {} ) => { let itemsToAdd: Array = parsedItems.map(item => @@ -1351,6 +1372,7 @@ export const AddDownloadsModal = () => { if (res.resolution === 'skip') { itemsToAdd[idx] = null; } else if (res.resolution === 'rename') { + itemsToAdd[idx] = { ...item, replaceExistingFingerprint: undefined }; let finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); @@ -1404,9 +1426,10 @@ export const AddDownloadsModal = () => { } let diskHas = false; try { - diskHas = await invoke('check_file_exists', { + const targetInfo = await invoke('inspect_download_target', { path: await resolveDownloadFilePath(itemLocation, newName) }); + diskHas = targetInfo.kind !== 'missing'; } catch(e) {} const batchHas = batchTargets.some(target => downloadLocationEquals( target.location, @@ -1422,7 +1445,7 @@ export const AddDownloadsModal = () => { throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile })); } - itemsToAdd[idx] = { ...item, file: newName }; + itemsToAdd[idx] = { ...item, file: newName, replaceExistingFingerprint: undefined }; } else if (res.resolution === 'replace') { if (!conflict?.replaceAllowed) { const finalFile = item.isMedia @@ -1469,7 +1492,14 @@ export const AddDownloadsModal = () => { } if (!existingItem) { - throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); + if (!res.replaceFingerprint || conflict?.existingDownloadId) { + throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); + } + itemsToAdd[idx] = { + ...item, + replaceExistingFingerprint: res.replaceFingerprint + }; + continue; } const incomingMediaFormat = mediaFormatSelectorForRow(item); const mediaFormatChanged = item.isMedia @@ -1623,7 +1653,8 @@ export const AddDownloadsModal = () => { ? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), - sizeBytes: item.sizeBytes + sizeBytes: item.sizeBytes, + replaceExistingFingerprint: item.replaceExistingFingerprint }, action); if (!added) { const rejected = useDownloadStore.getState().downloads.find(download => download.id === id); diff --git a/src/components/DeleteConfirmationModal.tsx b/src/components/DeleteConfirmationModal.tsx index a5c50e9..a93d894 100644 --- a/src/components/DeleteConfirmationModal.tsx +++ b/src/components/DeleteConfirmationModal.tsx @@ -6,7 +6,7 @@ import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; export const DeleteConfirmationModal: React.FC = () => { const { t } = useTranslation(); - const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore(); + const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore(); const [errorMessage, setErrorMessage] = useState(''); const [isRemoving, setIsRemoving] = useState(false); const modalRef = useModalFocus(deleteModalState.isOpen); @@ -49,7 +49,12 @@ export const DeleteConfirmationModal: React.FC = () => { const failures: string[] = []; for (const id of ids) { try { - await removeDownload(id, deleteFile); + await removeDownload( + id, + deleteFile, + false, + deleteFile ? 'permanentIfUnfinished' : undefined + ); succeeded += 1; } catch (error) { failures.push(String(error)); @@ -72,6 +77,11 @@ export const DeleteConfirmationModal: React.FC = () => { const handleRemoveFromList = () => removeMany(false); const handleDeleteFile = () => removeMany(true); const itemCount = deleteModalState.downloadIds?.length ?? 0; + const selectedItems = (deleteModalState.downloadIds ?? []) + .map(id => downloads.find(download => download.id === id)) + .filter(Boolean); + const hasCompletedSelection = selectedItems.some(item => item?.status === 'completed'); + const hasUnfinishedSelection = selectedItems.some(item => item?.status !== 'completed'); return (
{ {itemCount > 1 ? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount }) : t($ => $.dialogs.removeDownload.confirmationSingle)} + {hasCompletedSelection && hasUnfinishedSelection && ( +
+ {t($ => $.dialogs.removeDownload.mixedRemovalPolicy)} +
+ )} {errorMessage &&
{errorMessage}
}
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 6529514..814b2c9 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -239,7 +239,9 @@ export const DownloadItem = React.memo(({ const downloadStatusLabel = allocationVisible ? t($ => $.downloads.status.allocatingFiles) : t($ => $.downloads.status[download.status]); - const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution' + const visibleErrorStatusLabel = download.credentialsRequired === true + ? t($ => $.properties.credentialsRequired) + : download.lastErrorKind === 'nameResolution' ? download.status === 'retrying' && download.lastResolverFallback === true ? t($ => $.downloads.errors.nameResolutionRetrying) : download.status === 'failed' @@ -357,6 +359,7 @@ export const DownloadItem = React.memo(({ download.status === 'failed' || download.status === 'retrying' || download.lastErrorKind === 'destinationAccess' + || download.credentialsRequired === true ) ? download.lastError : (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 @@ -468,10 +471,14 @@ export const DownloadItem = React.memo(({ onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)} className="app-icon-button main-control-button" title={resumeSelectionCount === null - ? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) + ? download.credentialsRequired === true + ? t($ => $.properties.retryWithoutCredentials) + : download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) : `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`} aria-label={resumeSelectionCount === null - ? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) + ? download.credentialsRequired === true + ? t($ => $.properties.retryWithoutCredentials) + : download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) : `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`} > diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index f013833..bb2ad8e 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1931,7 +1931,7 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC if (ids.length === 0) return; const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id)); const credentialMarkedIds = selected - .filter(download => download.credentialsRequired === true) + .filter(download => download.credentialsRequired === true && canStartDownload(download.status)) .map(download => download.id); if (credentialMarkedIds.length > 0 && !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) { @@ -1954,7 +1954,17 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }, [showInteractionError, startSelected, t]); const handleStartAll = useCallback(() => { - void startAll().catch(error => { + const credentialMarkedIds = useDownloadStore.getState().downloads + .filter(download => + download.credentialsRequired === true + && (download.status === 'queued' || canStartDownload(download.status)) + ) + .map(download => download.id); + const resumeWithoutCredentials = credentialMarkedIds.length > 0 + && window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm)); + void startAll({ + resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : [] + }).catch(error => { showInteractionError(t($ => $.downloadTable.resumeFailed), error); }); }, [showInteractionError, startAll, t]); diff --git a/src/components/DuplicateResolutionModal.test.ts b/src/components/DuplicateResolutionModal.test.ts new file mode 100644 index 0000000..cab560a --- /dev/null +++ b/src/components/DuplicateResolutionModal.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + canReplaceAllDuplicateConflicts, + duplicateConflictCanReplace, +} from './DuplicateResolutionModal'; + +describe('duplicate replacement eligibility', () => { + it('exposes Replace for an eligible unmanaged regular-file conflict', () => { + expect(duplicateConflictCanReplace({ replaceAllowed: true })).toBe(true); + expect(duplicateConflictCanReplace({ replaceAllowed: false })).toBe(false); + expect(duplicateConflictCanReplace({})).toBe(false); + }); + + it('enables Replace all only when every conflict is eligible', () => { + expect(canReplaceAllDuplicateConflicts([{ replaceAllowed: true }])).toBe(true); + expect(canReplaceAllDuplicateConflicts([ + { replaceAllowed: true }, + { replaceAllowed: true }, + ])).toBe(true); + expect(canReplaceAllDuplicateConflicts([ + { replaceAllowed: true }, + { replaceAllowed: false }, + ])).toBe(false); + expect(canReplaceAllDuplicateConflicts([])).toBe(false); + }); +}); diff --git a/src/components/DuplicateResolutionModal.tsx b/src/components/DuplicateResolutionModal.tsx index 69522b4..ffea0f6 100644 --- a/src/components/DuplicateResolutionModal.tsx +++ b/src/components/DuplicateResolutionModal.tsx @@ -11,15 +11,28 @@ export interface DuplicateConflict { reason: DuplicateReason; resolution: DuplicateResolution; replaceAllowed?: boolean; + replaceFingerprint?: string; existingDownloadId?: string; } interface Props { conflicts: DuplicateConflict[]; - onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void; + onConfirm: (resolutions: { + id: string; + resolution: DuplicateResolution; + replaceFingerprint?: string; + }[]) => void; onCancel: () => void; } +export const duplicateConflictCanReplace = ( + conflict: Pick +): boolean => conflict.replaceAllowed === true; + +export const canReplaceAllDuplicateConflicts = ( + conflicts: readonly Pick[] +): boolean => conflicts.length > 0 && conflicts.every(duplicateConflictCanReplace); + export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => { const { t } = useTranslation(); const [conflicts, setConflicts] = useState(initialConflicts); @@ -40,9 +53,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir setConflicts(current => current.map(c => c.id === id ? { ...c, resolution } : c)); }; - const canReplaceAll = conflicts.length > 0 && conflicts.every(conflict => - conflict.replaceAllowed === true - ); + const canReplaceAll = canReplaceAllDuplicateConflicts(conflicts); const applyResolutionToAll = (resolution: DuplicateResolution) => { if (resolution === 'replace' && !canReplaceAll) return; @@ -113,7 +124,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir className="app-control w-24 shrink-0 px-2 py-1 text-xs" > - {conflict.replaceAllowed && } + {duplicateConflictCanReplace(conflict) && } @@ -125,7 +136,11 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir {t($ => $.actions.cancel)}