From f77fd0be3f1e89133f68824dce35feace965f5b9 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 15 Aug 2026 14:31:38 +0330 Subject: [PATCH] fix(downloads): harden automatic capture and aria2 transfers - Pass prepared redirect URIs to Aria2 while preserving stable source identities. - Fence effective-connection telemetry and retry lifecycle transitions by control epoch. - Keep credentialed routes conservative across redirects, mirrors, and inline URL credentials. - Preflight destination access and preserve actionable retryable permission errors in the UI. - Add adversarial regression coverage for ranges, redirects, retries, telemetry, and enqueue failures. --- src-tauri/src/ipc.rs | 1 + src-tauri/src/lib.rs | 218 +++++++- src-tauri/src/queue.rs | 603 ++++++++++++++++++++--- src/bindings/DownloadErrorKind.ts | 2 +- src/bindings/DownloadProgressEvent.ts | 2 +- src/components/AddDownloadsModal.tsx | 6 +- src/components/DownloadItem.tsx | 6 +- src/propertiesBridge.test.ts | 7 +- src/propertiesBridge.ts | 6 + src/store/useDownloadStore.test.ts | 29 ++ src/store/useDownloadStore.ts | 30 +- src/utils/downloadErrors.ts | 7 + src/utils/propertiesPresentation.test.ts | 15 + src/utils/propertiesPresentation.ts | 4 +- 14 files changed, 846 insertions(+), 90 deletions(-) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c55df92..a1f6d4c 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -150,6 +150,7 @@ pub struct QueueConcurrencyConfig { #[ts(export, export_to = "../../src/bindings/")] pub enum DownloadErrorKind { NameResolution, + DestinationAccess, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9272198..f8d473b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1470,6 +1470,7 @@ fn emit_media_progress( total_is_estimate, active_connections: None, requested_connections: None, + effective_connections: None, uploaded_bytes: None, upload_speed: None, num_seeders: None, @@ -2010,6 +2011,14 @@ async fn fetch_metadata( loop { if redirects >= 5 { + log::warn!( + "metadata [stage=metadata host={} redirects={} error_code=redirect_limit]", + reqwest::Url::parse(¤t_url) + .ok() + .and_then(|value| value.host_str().map(str::to_string)) + .unwrap_or_else(|| "".to_string()), + redirects + ); return Err("Too many redirects".to_string()); } @@ -2037,6 +2046,10 @@ async fn fetch_metadata( let parsed_current_url = reqwest::Url::parse(¤t_url) .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; + let metadata_host = parsed_current_url + .host_str() + .unwrap_or("") + .to_string(); let resolved_addr = match parsed_current_url.scheme() { "http" | "https" => validate_url_ssrf(¤t_url).await?, "ftp" | "sftp" => resolve_and_validate_url_host(&parsed_current_url) @@ -2104,10 +2117,16 @@ async fn fetch_metadata( let mut current_res = match head_req.send().await { Ok(response) => response, Err(head_error) => build_get_range().send().await.map_err(|get_error| { - let head_error = crate::redact_sensitive_text(&head_error.to_string()); - let get_error = crate::redact_sensitive_text(&get_error.to_string()); + log::warn!( + "metadata [stage=metadata host={} error_code=head_{}+get_{}]", + metadata_host, + reqwest_error_code(&head_error), + reqwest_error_code(&get_error) + ); format!( - "HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})" + "HEAD metadata request failed ({}); ranged GET fallback failed ({})", + reqwest_error_code(&head_error), + reqwest_error_code(&get_error) ) })?, }; @@ -2137,7 +2156,14 @@ async fn fetch_metadata( } if needs_fallback { - current_res = build_get_range().send().await.map_err(|e| e.to_string())?; + current_res = build_get_range().send().await.map_err(|error| { + log::warn!( + "metadata [stage=metadata host={} error_code=get_{}]", + metadata_host, + reqwest_error_code(&error) + ); + error.to_string() + })?; if retry_metadata_with_cookies( current_res.status(), @@ -2161,6 +2187,11 @@ async fn fetch_metadata( if let Ok(new_url) = parsed_base.join(loc_str) { current_url = new_url.to_string(); redirects += 1; + log::debug!( + "metadata [stage=redirect host={} redirect_count={}]", + metadata_host, + redirects + ); continue; } } @@ -2179,6 +2210,11 @@ async fn fetch_metadata( } if let Some(error) = metadata_response_error(current_res.status()) { + log::warn!( + "metadata [stage=metadata host={} status={} error_code=response]", + metadata_host, + current_res.status().as_u16() + ); return Err(error); } @@ -2246,6 +2282,20 @@ async fn fetch_metadata( }) } +fn reqwest_error_code(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_request() { + "request" + } else if error.is_body() { + "body" + } else { + "transport" + } +} + const MEDIA_METADATA_CACHE_TTL: Duration = Duration::from_secs(60); const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55); const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128; @@ -2940,6 +2990,118 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle .any(|root| crate::platform::path_is_within(&canonical_path, &root)) } +const RETRYABLE_DESTINATION_ACCESS_PREFIX: &str = "destination access retryable:"; + +/// Verify the destination before queue ownership is committed. This is an +/// app-owned, uniquely named probe so macOS privacy prompts and ordinary +/// filesystem permission failures happen at admission time rather than +/// during cleanup or after Aria2 has already started. +fn preflight_download_destination_access( + app_handle: &tauri::AppHandle, + id: &str, + destination: &str, +) -> Result<(), String> { + let resolved = resolve_path(destination.trim(), app_handle); + if !is_safe_path(&resolved, app_handle) { + log::warn!( + "download destination [{}]: access preflight rejected an unsafe destination", + id + ); + return Err(format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected folder is not approved" + )); + } + + if !resolved.exists() { + std::fs::create_dir_all(&resolved).map_err(|error| { + log::warn!( + "download destination [{}]: could not create destination for access preflight: {}", + id, + crate::redact_sensitive_text(&error.to_string()) + ); + format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} grant Firelink access to the selected folder and retry" + ) + })?; + } + + let canonical = canonicalize_with_missing_components(&resolved).ok_or_else(|| { + log::warn!( + "download destination [{}]: destination could not be canonicalized during access preflight", + id + ); + format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected folder could not be verified" + ) + })?; + if !is_safe_path(&canonical, app_handle) { + log::warn!( + "download destination [{}]: canonical destination is outside the approved roots", + id + ); + return Err(format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected folder is not approved" + )); + } + if !canonical.is_dir() { + log::warn!( + "download destination [{}]: selected destination is not a directory", + id + ); + return Err(format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected destination is not a folder" + )); + } + + let probe_path = canonical.join(format!( + ".firelink-access-check-{}.probe", + uuid::Uuid::new_v4().simple() + )); + let probe_result = (|| -> Result<(), std::io::Error> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe_path)?; + file.write_all(b"firelink")?; + file.flush()?; + file.sync_all()?; + Ok(()) + })(); + let cleanup_result = std::fs::remove_file(&probe_path); + + if let Err(error) = probe_result { + log::warn!( + "download destination [{}]: access probe failed: {}", + id, + crate::redact_sensitive_text(&error.to_string()) + ); + if let Err(cleanup_error) = cleanup_result { + log::debug!( + "download destination [{}]: failed to clean access probe: {}", + id, + crate::redact_sensitive_text(&cleanup_error.to_string()) + ); + } + return Err(format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} Firelink could not write to the selected folder; grant access and retry" + )); + } + if let Err(error) = cleanup_result { + log::warn!( + "download destination [{}]: access probe cleanup failed: {}", + id, + crate::redact_sensitive_text(&error.to_string()) + ); + return Err(format!( + "{RETRYABLE_DESTINATION_ACCESS_PREFIX} the selected folder could not be safely verified; retry" + )); + } + + log::debug!("download destination [{}]: access preflight succeeded", id); + Ok(()) +} + pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool { use std::path::Component; @@ -3337,6 +3499,8 @@ pub struct DownloadProgressEvent { #[ts(optional)] requested_connections: Option, #[ts(optional)] + effective_connections: Option, + #[ts(optional)] uploaded_bytes: Option, #[ts(optional)] upload_speed: Option, @@ -4612,6 +4776,7 @@ pub(crate) async fn start_media_download_internal( total_is_estimate: Some(false), active_connections: None, requested_connections: None, + effective_connections: None, uploaded_bytes: None, upload_speed: None, num_seeders: None, @@ -4689,6 +4854,7 @@ pub(crate) async fn start_media_download_internal( total_is_estimate: Some(false), active_connections: None, requested_connections: None, + effective_connections: None, uploaded_bytes: None, upload_speed: None, num_seeders: None, @@ -7123,6 +7289,8 @@ async fn enqueue_download_locked( .await .map_err(AppError::Internal)?; } + preflight_download_destination_access(app_handle, &item.id, &item.destination) + .map_err(AppError::Internal)?; let id = item.id.clone(); item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let accepted_filename = item.filename.clone(); @@ -11984,7 +12152,7 @@ mod tests { completed, speed_bytes: speed, active_connections: 16, - requested_connections: 16, + effective_connections: 16, speed_limited: false, now: start + Duration::from_secs(offset), }, @@ -12000,7 +12168,7 @@ mod tests { completed: 13 * 1024 * 1024, speed_bytes: 1024.0 * 1024.0, active_connections: 16, - requested_connections: 16, + effective_connections: 16, speed_limited: false, now: start + Duration::from_secs(31), }, @@ -12018,7 +12186,7 @@ mod tests { completed: 13 * 1024 * 1024, speed_bytes: 1024.0 * 1024.0, active_connections: 1, - requested_connections: 16, + effective_connections: 16, speed_limited: false, now: start + Duration::from_secs(62), }, @@ -12051,7 +12219,7 @@ mod tests { completed: 10, speed_bytes: 1024.0, active_connections: 16, - requested_connections: 16, + effective_connections: 16, speed_limited: false, now, }, @@ -12073,7 +12241,7 @@ mod tests { completed: 10, speed_bytes: 1024.0, active_connections: 16, - requested_connections: 16, + effective_connections: 16, speed_limited: false, now: now + Duration::from_secs(1), }, @@ -13928,7 +14096,7 @@ struct Aria2ConnectionSample<'a> { completed: u64, speed_bytes: f64, active_connections: i32, - requested_connections: i32, + effective_connections: i32, speed_limited: bool, now: Instant, } @@ -13972,7 +14140,7 @@ fn observe_aria2_connections( completed: u64, speed_bytes: f64, active_connections: i32, - requested_connections: i32, + effective_connections: i32, speed_limited: bool, now: Instant, ) -> Option { @@ -13986,7 +14154,7 @@ fn observe_aria2_connections( completed, speed_bytes, active_connections, - requested_connections, + effective_connections, speed_limited, now, }, @@ -14005,7 +14173,7 @@ fn observe_aria2_connections_with_epoch( completed, speed_bytes, active_connections, - requested_connections, + effective_connections, speed_limited, now, } = sample; @@ -14043,7 +14211,7 @@ fn observe_aria2_connections_with_epoch( observation.no_progress_since = None; } - let multi_connection_candidate = requested_connections > 1 + let multi_connection_candidate = effective_connections > 1 && remaining >= ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY; if multi_connection_candidate { let healthy_connection_sample = active_connections > 1 @@ -14064,9 +14232,9 @@ fn observe_aria2_connections_with_epoch( let partial_connection_pool_collapse = !speed_limited && observation.saw_multiple_connections && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES - && requested_connections >= 4 + && effective_connections >= 4 && (active_connections as f64) - <= (requested_connections as f64) * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION + <= (effective_connections as f64) * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION && observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY && speed_bytes > 0.0 && speed_bytes < observation.peak_speed_bytes * 0.5; @@ -14978,10 +15146,15 @@ pub fn run() { .await .unwrap_or(1) .max(1); + let effective_connections = poll_mgr + .aria2_effective_connections(&id, mapping.epoch) + .await + .unwrap_or(requested_connections) + .max(1); let speed_limited = poll_mgr.aria2_speed_limited(&id).await; let control_epoch = mapping.epoch; - // The status snapshot and the requested - // connection lookup both await. A pause, + // The status snapshot and both connection + // lookups await. A pause, // retry, or same-GID resume may replace the // mapping while those awaits are in // flight. Only emit telemetry if this @@ -15007,7 +15180,7 @@ pub fn run() { completed, speed_bytes, active_connections, - requested_connections, + effective_connections, speed_limited, now, }, @@ -15212,6 +15385,8 @@ pub fn run() { total_is_estimate: Some(false), active_connections: Some(active_connections), requested_connections: (!is_torrent).then_some(requested_connections), + effective_connections: (!is_torrent) + .then_some(effective_connections), uploaded_bytes: torrent_telemetry .map(|value| value.uploaded_bytes as f64) .or_else(|| uploaded_bytes.map(|value| value as f64)), @@ -15234,13 +15409,14 @@ pub fn run() { if let Some(reason) = recovery_reason { log::warn!( - "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={}", + "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={} effective_connections={}", id, gid, reason.as_str(), speed_bytes, active_connections, - requested_connections + requested_connections, + effective_connections ); if let Err(error) = poll_mgr .refresh_aria2_connections(&id, gid, control_epoch) diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index d2b8892..07b3c85 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -534,6 +534,12 @@ pub struct Aria2GidMapping { pub epoch: u64, } +#[derive(Clone, Copy, Debug)] +struct Aria2ConnectionOptions { + epoch: u64, + effective: i32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TorrentTelemetrySnapshot { pub uploaded_bytes: u64, @@ -974,6 +980,10 @@ pub struct QueueManager { /// download id -> spawn payload for aria2 transient-error re-addUri retries. aria2_payloads: Mutex>, + /// Attempt-scoped Aria2 connection options. The persisted payload keeps + /// the user's requested count; this map records the effective count after + /// range probing for the currently admitted attempt. + aria2_connection_options: Mutex>, /// Initial aria2 addUri handoffs that have not yet either published a GID /// or removed a stale late GID. Removal waits for these handoffs before /// deleting owned assets so a magnet cannot leave an orphaned output. @@ -1072,6 +1082,7 @@ impl QueueManager { aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())), pending_completion: Arc::new(Mutex::new(HashMap::new())), aria2_payloads: Mutex::new(HashMap::new()), + aria2_connection_options: Mutex::new(HashMap::new()), aria2_dispatch_inflight: Mutex::new(HashMap::new()), aria2_dispatch_notify: Notify::new(), aria2_global_speed_limit: Arc::new(StdMutex::new(None)), @@ -1927,6 +1938,37 @@ impl QueueManager { .map(clamp_download_connections) } + pub async fn set_aria2_connection_options( + &self, + id: &str, + epoch: u64, + effective: i32, + ) { + let mut options = self.aria2_connection_options.lock().await; + if options + .get(id) + .is_some_and(|current| current.epoch > epoch) + { + return; + } + options.insert( + id.to_string(), + Aria2ConnectionOptions { + epoch, + effective: clamp_download_connections(effective), + }, + ); + } + + pub async fn aria2_effective_connections(&self, id: &str, epoch: u64) -> Option { + self.aria2_connection_options + .lock() + .await + .get(id) + .filter(|options| options.epoch == epoch) + .map(|options| options.effective) + } + pub async fn aria2_torrent_seeding_requested(&self, id: &str) -> bool { self.aria2_payloads .lock() @@ -3709,7 +3751,9 @@ impl QueueManager { // lets the UI (and a concurrent Properties pause) // act on a lifecycle that does not yet exist. let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await; - self.emit_state(&id, DownloadStatus::Downloading); + if buffered_outcome.is_none() { + self.emit_state(&id, DownloadStatus::Downloading); + } let install_web_seeds = buffered_outcome.is_none() && task.payload.is_torrent && !task.payload.torrent_verify_only @@ -3917,14 +3961,19 @@ impl QueueManager { /// epoch must not be reused after a pause invalidated that lifecycle. pub async fn rebind_aria2_gid_epoch(&self, id: &str, gid: &str, epoch: u64) -> bool { let _gid_state = self.aria2_gid_state.lock().await; - let mut gids = self.aria2_gids.write().unwrap(); - let Some(mapping) = gids.get_mut(gid) else { - return false; - }; - if mapping.id != id { - return false; + { + let mut gids = self.aria2_gids.write().unwrap(); + let Some(mapping) = gids.get_mut(gid) else { + return false; + }; + if mapping.id != id { + return false; + } + mapping.epoch = epoch; + } + if let Some(options) = self.aria2_connection_options.lock().await.get_mut(id) { + options.epoch = epoch; } - mapping.epoch = epoch; true } @@ -4068,6 +4117,28 @@ impl QueueManager { } PendingOutcome::Error(error) => { self.forget_torrent_telemetry(id).await; + let terminal_gid = self + .aria2_gid_for_download(id) + .unwrap_or_else(|| "".to_string()); + let terminal_epoch = self + .aria2_gid_for_download(id) + .and_then(|gid| self.aria2_gid_mapping(&gid).map(|mapping| mapping.epoch)) + .unwrap_or(self.current_aria2_control_epoch(id).await); + let retry_strike = self + .aria2_retry_strikes + .lock() + .await + .get(id) + .copied() + .unwrap_or_default(); + let requested_connections = self + .aria2_requested_connections(id) + .await + .unwrap_or(DOWNLOAD_CONNECTIONS_MIN); + let effective_connections = self + .aria2_effective_connections(id, terminal_epoch) + .await + .unwrap_or(requested_connections); if !verification_only && error.to_ascii_lowercase().contains("checksum") { log::warn!("Checksum error detected for {}, cleaning up assets", id); if let Ok(paths) = @@ -4112,7 +4183,16 @@ impl QueueManager { self.emit_paused_with_error(id, error); } else { let safe_error = crate::redact_sensitive_text(&error); - log::error!("aria2 download {} failed: {}", id, safe_error); + log::error!( + "aria2 terminal [stage=terminal id={} gid={} epoch={} retry_strike={} requested_connections={} effective_connections={} error={}]", + id, + terminal_gid, + terminal_epoch, + retry_strike, + requested_connections, + effective_connections, + safe_error + ); self.emit_failed(id, error); } } @@ -4123,6 +4203,7 @@ impl QueueManager { pub async fn clear_aria2_retry_state(&self, id: &str) { self.aria2_payloads.lock().await.remove(id); self.aria2_retry_strikes.lock().await.remove(id); + self.aria2_connection_options.lock().await.remove(id); } pub async fn cancel_aria2_retries(&self, id: &str) { @@ -4842,16 +4923,21 @@ impl QueueManager { .await .insert(id_for_task.clone(), strike + 1); } - this.emit_state(&id_for_task, DownloadStatus::Downloading); - // Stop suppressing events for the id before exposing the - // new gid. The old gid remains marked as retrying until - // remember_gid atomically replaces its mapping, so a - // duplicate old event is still ignored while a genuine - // new-gid error is allowed through. - this.release_aria2_retry_inflight(&id_for_task, retry_epoch) - .await; let new_gid_for_event = new_gid.clone(); let buffered_outcome = this.remember_gid(id_for_task.clone(), new_gid).await; + // Install the replacement GID before exposing the + // Downloading state. If Aria2 completed or failed the + // replacement before the mapping was installed, apply + // that buffered terminal outcome directly and never + // publish a stale transient state. + if buffered_outcome.is_none() { + this.emit_state(&id_for_task, DownloadStatus::Downloading); + } + // The old gid remains marked as retrying until the new + // mapping is installed, so a duplicate old event is + // ignored while a genuine new-gid event is accepted. + this.release_aria2_retry_inflight(&id_for_task, retry_epoch) + .await; this.aria2_retrying_gids.lock().await.remove(&retry_gid); drop(control_guard); if let Some(outcome) = buffered_outcome { @@ -5174,20 +5260,76 @@ struct HttpTransferProbe { struct PreparedNormalTransfer { uris: Vec, - connections: i32, + requested_connections: i32, + effective_connections: i32, credentials_allowed: bool, } fn payload_has_credential_material(payload: &SpawnPayload) -> bool { - [ - payload.username.as_deref(), - payload.password.as_deref(), - payload.cookies.as_deref(), - payload.headers.as_deref(), + let inline_url_credentials = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) + .into_iter() + .any(|uri| { + reqwest::Url::parse(&uri) + .ok() + .is_some_and(|parsed| !parsed.username().is_empty() || parsed.password().is_some()) + }); + if inline_url_credentials { + return true; + } + + if payload + .username + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || payload + .password + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || payload + .cookies + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + return true; + } + + payload + .headers + .as_deref() + .into_iter() + .flat_map(str::lines) + .filter_map(|line| { + line.split_once(':') + .map(|(name, _)| name.trim().to_ascii_lowercase()) + }) + .any(|name| header_name_has_credential_material(&name)) +} + +fn header_name_has_credential_material(name: &str) -> bool { + let name = name.trim().to_ascii_lowercase(); + matches!( + name.as_str(), + "authorization" + | "cookie" + | "cookie2" + | "proxy-authorization" + | "set-cookie" + | "x-api-key" + | "x-auth-token" + | "x-access-token" + ) || [ + "auth", + "credential", + "key", + "password", + "passwd", + "secret", + "session", + "signature", + "token", ] - .into_iter() - .flatten() - .any(|value| !value.trim().is_empty()) + .iter() + .any(|marker| name.contains(marker)) } async fn prepare_normal_transfer( @@ -5206,10 +5348,20 @@ async fn prepare_normal_transfer( .enumerate() { if !is_http_uri(&uri) { - crate::resolve_and_validate_url_host( - &reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?, - ) - .await?; + let parsed = + reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?; + crate::resolve_and_validate_url_host(&parsed).await?; + let uri_credentials_allowed = can_forward_payload_credentials(&credential_origin, &parsed); + if index > 0 + && payload_has_credential_material(payload) + && !uri_credentials_allowed + { + return Err( + "credentialed mirrors must use the same origin as the primary URL" + .to_string(), + ); + } + credentials_allowed &= uri_credentials_allowed; uris.push(uri); continue; } @@ -5247,13 +5399,41 @@ async fn prepare_normal_transfer( _ => {} } } - Err(error) => { - return Err(format!( - "normal transfer preflight failed for {}: {}", + Err(error) if is_fatal_range_probe_error(&error) => { + log::error!( + "aria2 redirect [{}]: fatal transfer validation failed host={} error_code={}", + id, uri_host_for_log(&uri), - crate::redact_sensitive_text(&error) + range_probe_error_code(&error) + ); + return Err(format!( + "normal transfer preflight rejected for {}: {}", + uri_host_for_log(&uri), + range_probe_error_code(&error) )); } + Err(error) if payload_has_credential_material(payload) => { + log::warn!( + "aria2 range probe [{}]: credentialed route could not be verified host={} error_code={}; retryable", + id, + uri_host_for_log(&uri), + range_probe_error_code(&error) + ); + return Err(format!( + "normal transfer preflight is retryable for {}: {}", + uri_host_for_log(&uri), + range_probe_error_code(&error) + )); + } + Err(error) => { + log::warn!( + "aria2 range probe [{}]: public route unavailable host={} error_code={}; trying the validated source URI", + id, + uri_host_for_log(&uri), + range_probe_error_code(&error) + ); + uris.push(uri); + } } } uris.dedup(); @@ -5268,11 +5448,42 @@ async fn prepare_normal_transfer( } Ok(PreparedNormalTransfer { uris, - connections, + requested_connections: requested, + effective_connections: connections, credentials_allowed, }) } +fn is_fatal_range_probe_error(error: &str) -> bool { + let lower = error.to_ascii_lowercase(); + lower.contains("private/local ip not allowed") + || lower.contains("invalid url") + || lower.contains("no host") + || lower.contains("unsupported scheme") + || lower.contains("invalid range probe redirect") + || lower.contains("range probe redirect uses an unsupported scheme") + || lower.contains("range probe redirect has no valid location") + || lower.contains("range probe redirect limit exceeded") + || lower.contains("range probe redirect loop exhausted") +} + +fn range_probe_error_code(error: &str) -> &'static str { + let lower = error.to_ascii_lowercase(); + if lower.contains("timed out") || lower.contains("timeout") { + "timeout" + } else if lower.contains("dns") || lower.contains("name resolution") { + "dns" + } else if lower.contains("private/local") { + "ssrf_private_address" + } else if lower.contains("redirect") { + "redirect" + } else if lower.contains("invalid") { + "invalid_route" + } else { + "transport" + } +} + fn is_http_uri(uri: &str) -> bool { reqwest::Url::parse(uri) .ok() @@ -5312,18 +5523,56 @@ async fn probe_bounded_range_support( uri: &str, payload: &SpawnPayload, credential_origin: &reqwest::Url, +) -> Result { + probe_bounded_range_support_with_local_override(uri, payload, credential_origin, false).await +} + +#[cfg(test)] +async fn probe_bounded_range_support_local_test( + uri: &str, + payload: &SpawnPayload, + credential_origin: &reqwest::Url, +) -> Result { + probe_bounded_range_support_with_local_override(uri, payload, credential_origin, true).await +} + +async fn probe_bounded_range_support_with_local_override( + uri: &str, + payload: &SpawnPayload, + credential_origin: &reqwest::Url, + allow_localhost: bool, ) -> Result { crate::ensure_reqwest_crypto_provider(); let mut current = reqwest::Url::parse(uri).map_err(|error| error.to_string())?; let mut credentials_allowed = can_forward_payload_credentials(credential_origin, ¤t); for redirect_count in 0..=5 { - let (host, address) = crate::resolve_and_validate_url_host(¤t).await?; + let (host, address) = if allow_localhost { + let host = current + .host_str() + .ok_or_else(|| "range probe test URL has no host".to_string())?; + let ip = host + .parse::() + .map_err(|_| "range probe test URL must use an IP host".to_string())?; + ( + host.to_string(), + std::net::SocketAddr::new( + ip, + current.port_or_known_default().unwrap_or(80), + ), + ) + } else { + crate::resolve_and_validate_url_host(¤t).await? + }; let mut builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(10)) .resolve(&host, address); + if allow_localhost { + builder = builder.no_proxy(); + } + if let Some(proxy) = payload .proxy .as_deref() @@ -5363,8 +5612,12 @@ async fn probe_bounded_range_support( if !matches!(next.scheme(), "http" | "https") { return Err("range probe redirect uses an unsupported scheme".to_string()); } - credentials_allowed = - credentials_allowed && can_forward_payload_credentials(credential_origin, &next); + let (next, next_credentials_allowed) = apply_redirect_credentials_policy( + credential_origin, + credentials_allowed, + next, + ); + credentials_allowed = next_credentials_allowed; current = next; continue; } @@ -5394,6 +5647,20 @@ fn can_forward_payload_credentials(original: &reqwest::Url, current: &reqwest::U || (original.scheme() == "http" && current.scheme() == "https")) } +fn apply_redirect_credentials_policy( + credential_origin: &reqwest::Url, + credentials_already_allowed: bool, + mut next: reqwest::Url, +) -> (reqwest::Url, bool) { + let credentials_allowed = credentials_already_allowed + && can_forward_payload_credentials(credential_origin, &next); + if !credentials_allowed { + let _ = next.set_username(""); + let _ = next.set_password(None); + } + (next, credentials_allowed) +} + fn apply_payload_headers( mut request: reqwest::RequestBuilder, payload: &SpawnPayload, @@ -5515,17 +5782,24 @@ fn classify_bounded_range_response( status: reqwest::StatusCode, content_range: Option<&str>, ) -> BoundedRangeSupport { + // A 416 for the deliberately tiny `bytes=0-0` request is the one + // response we can classify as an explicit range rejection. Successful + // 200 responses and larger 206 responses remain ambiguous because + // servers and proxies commonly normalize or expand bounded requests. + if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE { + return BoundedRangeSupport::Unsupported; + } if status == reqwest::StatusCode::PARTIAL_CONTENT { return match content_range.and_then(parse_content_range_bounds) { Some((0, 0)) => BoundedRangeSupport::Supported, - Some((0, _)) => BoundedRangeSupport::Unsupported, + Some((0, _)) => BoundedRangeSupport::Unknown, Some(_) => BoundedRangeSupport::Unknown, None => BoundedRangeSupport::Unknown, }; } if status.is_success() { - BoundedRangeSupport::Unsupported + BoundedRangeSupport::Unknown } else { BoundedRangeSupport::Unknown } @@ -5588,6 +5862,13 @@ fn apply_aria2_connection_options( ); } +fn aria2_add_uri_params( + transfer_uris: Vec, + options: serde_json::Map, +) -> serde_json::Value { + serde_json::json!([transfer_uris, options]) +} + fn apply_aria2_normal_reliability_options( options: &mut serde_json::Map, payload: &SpawnPayload, @@ -6605,7 +6886,9 @@ impl ProductionSpawner { impl SidecarSpawner for ProductionSpawner { async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result { let state = self.app_handle.state::(); + let attempt_epoch = state.queue_manager.current_aria2_control_epoch(id).await; let mut options = serde_json::Map::new(); + let mut connection_options = None; let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle); if !crate::is_safe_path(&resolved_dest, &self.app_handle) { return Err("Path traversal blocked".to_string()); @@ -6628,18 +6911,22 @@ impl SidecarSpawner for ProductionSpawner { if !payload.is_torrent { options.insert("out".to_string(), serde_json::json!(safe_filename)); } - let (transfer_uris, transfer_connections, credentials_allowed) = if payload.is_torrent { - (Vec::new(), DOWNLOAD_CONNECTIONS_MIN, true) - } else { - let requested = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); - validate_aria2_transfer_network_policy(&requested).await?; - let prepared = prepare_normal_transfer(id, payload).await?; - ( - prepared.uris, - prepared.connections, - prepared.credentials_allowed, - ) - }; + let (transfer_uris, requested_connections, transfer_connections, credentials_allowed) = + if payload.is_torrent { + (Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, true) + } else { + let requested = + crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); + validate_aria2_transfer_network_policy(&requested).await?; + let prepared = prepare_normal_transfer(id, payload).await?; + connection_options = Some(prepared.effective_connections); + ( + prepared.uris, + prepared.requested_connections, + prepared.effective_connections, + prepared.credentials_allowed, + ) + }; if should_apply_aria2_connection_options(payload) { apply_aria2_connection_options(&mut options, transfer_connections); } @@ -6691,6 +6978,19 @@ impl SidecarSpawner for ProductionSpawner { } apply_aria2_resolver_options(&mut options, payload.aria2_resolver_mode); + log::info!( + "aria2 admission [stage=admission id={} epoch={} host={} requested_connections={} effective_connections={} uri_count={}]", + id, + attempt_epoch, + transfer_uris + .first() + .map(|uri| uri_host_for_log(uri)) + .unwrap_or_else(|| "".to_string()), + requested_connections, + transfer_connections, + transfer_uris.len() + ); + let (method, params) = if payload.is_torrent { if let Some(path) = payload.torrent_path.as_deref() { let path = crate::torrent::validate_managed_torrent_path( @@ -6751,8 +7051,10 @@ impl SidecarSpawner for ProductionSpawner { ("aria2.addUri", serde_json::json!([[magnet], options])) } } else { - let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); - ("aria2.addUri", serde_json::json!([uris, options])) + ( + "aria2.addUri", + aria2_add_uri_params(transfer_uris, options), + ) }; match self.add_transfer_rpc(&state, method, ¶ms).await { @@ -6761,13 +7063,29 @@ impl SidecarSpawner for ProductionSpawner { if gid.is_empty() { Err(format!("{method} returned an empty gid")) } else { + if let Some(effective) = connection_options { + state + .queue_manager + .set_aria2_connection_options( + id, + attempt_epoch, + effective, + ) + .await; + } log::info!("aria2 {} [{}]: created gid {}", method, id, gid); Ok(gid) } } Err(e) => { let safe_error = crate::redact_sensitive_text(&e); - log::error!("aria2 {} [{}] failed: {}", method, id, safe_error); + log::error!( + "aria2 admission [stage=admission id={} epoch={} method={} error={}]", + id, + attempt_epoch, + method, + safe_error + ); Err(format!("aria2 {method} failed: {safe_error}")) } } @@ -8618,6 +8936,21 @@ mod tests { assert!(!options.contains_key("http-user")); } + #[test] + fn inline_url_credentials_are_treated_as_credential_material() { + assert!(payload_has_credential_material(&SpawnPayload { + url: "https://alice:secret@example.test/file".to_string(), + ..SpawnPayload::default() + })); + assert!(payload_has_credential_material(&SpawnPayload { + url: "https://example.test/file".to_string(), + mirrors: Some("https://alice:secret@mirror.example/file".to_string()), + ..SpawnPayload::default() + })); + assert!(header_name_has_credential_material("X-Request-Signature")); + assert!(!header_name_has_credential_material("Referer")); + } + #[test] fn normal_checksum_options_enable_aria2_integrity_checks() { let mut options = serde_json::Map::new(); @@ -8693,6 +9026,103 @@ mod tests { &reqwest::Url::parse("https://example.test/file").unwrap(), &reqwest::Url::parse("http://example.test/file").unwrap() )); + + let ftp_origin = reqwest::Url::parse("ftp://example.test/file").unwrap(); + assert!(can_forward_payload_credentials( + &ftp_origin, + &reqwest::Url::parse("ftp://example.test/other").unwrap(), + )); + assert!(!can_forward_payload_credentials( + &ftp_origin, + &reqwest::Url::parse("ftp://mirror.example.test/file").unwrap(), + )); + + let redirected_with_credentials = reqwest::Url::parse( + "https://alice:secret@cdn.example.test/file", + ) + .unwrap(); + let (sanitized, credentials_allowed) = apply_redirect_credentials_policy( + &original, + true, + redirected_with_credentials, + ); + assert!(!credentials_allowed); + assert!(sanitized.username().is_empty()); + assert!(sanitized.password().is_none()); + } + + #[tokio::test] + async fn range_probe_fixture_follows_redirect_and_forwards_same_origin_credentials() { + use axum::{ + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Redirect}, + routing::get, + Router, + }; + + async fn source() -> Redirect { + Redirect::temporary("/final") + } + + async fn final_resource(headers: HeaderMap) -> impl IntoResponse { + let range = headers + .get("range") + .and_then(|value| value.to_str().ok()); + let cookie = headers + .get("cookie") + .and_then(|value| value.to_str().ok()); + if range == Some("bytes=0-0") && cookie == Some("session=secret") { + ( + StatusCode::PARTIAL_CONTENT, + [("content-range", "bytes 0-0/8")], + ) + .into_response() + } else { + StatusCode::UNAUTHORIZED.into_response() + } + } + + let app = Router::new() + .route("/source", get(source)) + .route("/final", get(final_resource)); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("fixture listener"); + let port = listener.local_addr().expect("fixture address").port(); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let source_url = format!("http://127.0.0.1:{port}/source"); + let payload = SpawnPayload { + url: source_url.clone(), + cookies: Some("session=secret".to_string()), + connections: Some(16), + ..SpawnPayload::default() + }; + let origin = reqwest::Url::parse(&source_url).expect("fixture URL"); + let probe = probe_bounded_range_support_local_test(&source_url, &payload, &origin) + .await + .expect("range probe"); + + assert_eq!( + probe.final_uri, + format!("http://127.0.0.1:{port}/final") + ); + assert_eq!(probe.range_support, BoundedRangeSupport::Supported); + assert!(probe.credentials_allowed); + server.abort(); + } + + #[test] + fn public_probe_transport_failures_are_advisory_but_security_failures_are_fatal() { + assert!(!is_fatal_range_probe_error("error sending request")); + assert_eq!(range_probe_error_code("error sending request"), "transport"); + assert!(is_fatal_range_probe_error("SSRF blocked: Private/local IP not allowed")); + assert_eq!( + range_probe_error_code("SSRF blocked: DNS resolution timed out"), + "timeout" + ); } #[test] @@ -9087,6 +9517,55 @@ mod tests { ); } + #[tokio::test] + async fn stale_aria2_connection_options_cannot_overwrite_a_newer_epoch() { + 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 first_epoch = manager.next_aria2_control_epoch("download").await; + + manager + .set_aria2_connection_options("download", first_epoch, 1) + .await; + assert_eq!( + manager + .aria2_effective_connections("download", first_epoch) + .await, + Some(1) + ); + + let second_epoch = manager.next_aria2_control_epoch("download").await; + assert_eq!( + manager + .aria2_effective_connections("download", second_epoch) + .await, + None + ); + manager + .set_aria2_connection_options("download", second_epoch, 4) + .await; + manager + .set_aria2_connection_options("download", first_epoch, 1) + .await; + assert_eq!( + manager + .aria2_effective_connections("download", second_epoch) + .await, + Some(4) + ); + } + + #[test] + fn aria2_add_uri_uses_the_prepared_attempt_uris() { + let prepared = vec!["https://cdn.example.test/signed-attempt".to_string()]; + let source = "https://downloads.example.test/stable-source"; + let params = aria2_add_uri_params(prepared.clone(), serde_json::Map::new()); + + assert_eq!(params[0], serde_json::json!(prepared)); + assert_ne!(params[0], serde_json::json!([source])); + } + #[test] fn bounded_range_probe_accepts_exact_requested_byte() { assert_eq!( @@ -9124,20 +9603,28 @@ mod tests { } #[test] - fn bounded_range_probe_rejects_server_that_expands_to_end() { + fn bounded_range_probe_treats_expanded_response_as_unknown() { assert_eq!( classify_bounded_range_response( reqwest::StatusCode::PARTIAL_CONTENT, Some("bytes 0-383882117/383882118"), ), - BoundedRangeSupport::Unsupported + BoundedRangeSupport::Unknown ); } #[test] - fn bounded_range_probe_rejects_ignored_range_request() { + fn bounded_range_probe_treats_ignored_range_request_as_unknown() { assert_eq!( classify_bounded_range_response(reqwest::StatusCode::OK, None), + BoundedRangeSupport::Unknown + ); + } + + #[test] + fn bounded_range_probe_classifies_explicit_range_rejection() { + assert_eq!( + classify_bounded_range_response(reqwest::StatusCode::RANGE_NOT_SATISFIABLE, None), BoundedRangeSupport::Unsupported ); } diff --git a/src/bindings/DownloadErrorKind.ts b/src/bindings/DownloadErrorKind.ts index 2ce70e3..c8a028d 100644 --- a/src/bindings/DownloadErrorKind.ts +++ b/src/bindings/DownloadErrorKind.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadErrorKind = "nameResolution"; +export type DownloadErrorKind = "nameResolution" | "destinationAccess"; diff --git a/src/bindings/DownloadProgressEvent.ts b/src/bindings/DownloadProgressEvent.ts index bb5e0ea..80fbecb 100644 --- a/src/bindings/DownloadProgressEvent.ts +++ b/src/bindings/DownloadProgressEvent.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, }; +export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, effective_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index cf5c2d9..576ae8f 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -1496,7 +1496,8 @@ export const AddDownloadsModal = () => { lastError: undefined }, pendingAction); if (!replaced) { - throw new Error(t($ => $.addDownloads.backendRejectedStart)); + const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id); + throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart)); } // The existing row was updated in place; do not create a @@ -1625,7 +1626,8 @@ export const AddDownloadsModal = () => { sizeBytes: item.sizeBytes }, action); if (!added) { - throw new Error(t($ => $.addDownloads.backendRejectedStart)); + const rejected = useDownloadStore.getState().downloads.find(download => download.id === id); + throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart)); } addedCount += 1; } catch (e) { diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index ae852f1..20fc05b 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -334,7 +334,11 @@ export const DownloadItem = React.memo(({ { size_is_final: true, active_connections: 3, requested_connections: 8, + effective_connections: 1, }, }); - expect(normalSnapshot).toMatchObject({ activeConnections: 3, requestedConnections: 8 }); + expect(normalSnapshot).toMatchObject({ + activeConnections: 3, + requestedConnections: 8, + effectiveConnections: 1, + }); expect(normalSnapshot).not.toHaveProperty('connectedPeers'); }); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 43235b1..252fdcf 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -179,6 +179,7 @@ export type PropertiesSnapshot = SafePropertiesFields & { lastResolverFallback?: boolean; activeConnections?: number; requestedConnections?: number; + effectiveConnections?: number; uploadSpeed?: string; torrentConnectedPeers?: number; torrentConnectedSeeders?: number; @@ -437,6 +438,11 @@ const copyWithoutSecrets = ( && live.progress.requested_connections !== undefined ? { requestedConnections: live.progress.requested_connections } : {}), + ...(item.isTorrent !== true + && item.isMedia !== true + && live.progress.effective_connections !== undefined + ? { effectiveConnections: live.progress.effective_connections } + : {}), ...(live.progress.uploaded_bytes !== undefined ? { torrentUploadedBytes: live.progress.uploaded_bytes } : {}), diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 6c9a91d..063cb8a 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1082,6 +1082,35 @@ describe('useDownloadStore', () => { expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); }); + it('keeps destination permission failures retryable before backend admission', async () => { + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'enqueue_download') { + throw new Error('Internal error: destination access retryable: Firelink could not write to the selected folder; grant access and retry'); + } + return undefined; + }); + useDownloadStore.setState({ + downloads: [{ + id: 'destination-permission', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + destination: '/tmp', + status: 'ready', + category: 'Other', + dateAdded: '' + }] as any[], + backendRegisteredIds: new Set() + }); + + await expect(dispatchItem('destination-permission')).resolves.toBe(false); + + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + status: 'ready', + lastError: 'Firelink could not write to the selected folder; grant access and retry', + lastErrorKind: 'destinationAccess' + }); + }); + it('matches site logins by host, wildcard host, path, and full URL patterns', () => { const settings = { siteLogins: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index bdf1b7f..f24f3c8 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -3,6 +3,7 @@ import { info } from '../utils/logger'; import { invokeCommand as invoke } from '../ipc'; import type { DownloadItem } from '../bindings/DownloadItem'; +import type { DownloadErrorKind } from '../bindings/DownloadErrorKind'; import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { ExtensionDownload } from '../bindings/ExtensionDownload'; import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; @@ -258,6 +259,18 @@ export class SystemProxyResolutionError extends Error { const isSystemProxyConfigurationError = (error: unknown): boolean => error instanceof SystemProxyResolutionError; +const DESTINATION_ACCESS_ERROR_MARKER = 'destination access retryable:'; + +const isRetryableDestinationAccessError = (error: unknown): boolean => + errorMessage(error).toLowerCase().includes(DESTINATION_ACCESS_ERROR_MARKER); + +const destinationAccessErrorMessage = (message: string): string => { + const markerIndex = message.toLowerCase().indexOf(DESTINATION_ACCESS_ERROR_MARKER); + if (markerIndex === -1) return message; + const detail = message.slice(markerIndex + DESTINATION_ACCESS_ERROR_MARKER.length).trim(); + return detail || message; +}; + const stripSensitiveMediaHeaders = (value: string | null | undefined): string => (value || '') .split(/\r?\n/) @@ -442,7 +455,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): useDownloadStore.getState().setPendingOrder(order); useDownloadStore.getState().registerBackendIds([id]); - useDownloadStore.getState().updateDownload(id, { lastError: undefined }); + useDownloadStore.getState().updateDownload(id, { + lastError: undefined, + lastErrorKind: undefined + }); return true; } catch (e) { console.error(`Failed to dispatch ${id}:`, e); @@ -451,9 +467,17 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): } if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) { const proxyBlocked = isSystemProxyConfigurationError(e); + const destinationAccessBlocked = isRetryableDestinationAccessError(e); + const message = errorMessage(e); useDownloadStore.getState().updateDownload(id, { - status: proxyBlocked ? 'queued' : 'failed', - lastError: errorMessage(e) + status: proxyBlocked ? 'queued' : destinationAccessBlocked ? 'ready' : 'failed', + hasBeenDispatched: false, + lastErrorKind: destinationAccessBlocked + ? ('destinationAccess' as DownloadErrorKind) + : undefined, + lastError: destinationAccessBlocked + ? destinationAccessErrorMessage(message) + : message }); } return false; diff --git a/src/utils/downloadErrors.ts b/src/utils/downloadErrors.ts index a7a66fd..0082657 100644 --- a/src/utils/downloadErrors.ts +++ b/src/utils/downloadErrors.ts @@ -8,6 +8,13 @@ import type { DownloadErrorKind } from '../bindings/DownloadErrorKind'; export const classifyDownloadError = (message: unknown): DownloadErrorKind | undefined => { if (typeof message !== 'string') return undefined; const lower = message.toLowerCase(); + if ( + lower.includes('destination access retryable') + || lower.includes('could not write to the selected folder') + || lower.includes('selected folder could not be verified') + ) { + return 'destinationAccess'; + } if ( lower.includes('aria2 error code 19') || ( diff --git a/src/utils/propertiesPresentation.test.ts b/src/utils/propertiesPresentation.test.ts index 568d308..9c5ed7c 100644 --- a/src/utils/propertiesPresentation.test.ts +++ b/src/utils/propertiesPresentation.test.ts @@ -58,6 +58,21 @@ describe('Properties connection presentation', () => { }); }); + it('shows effective Aria2 connections when the transfer is degraded', () => { + expect(getPropertiesConnectionPresentation({ + isMedia: false, + isTorrent: false, + connections: 16, + activeConnections: 1, + requestedConnections: 16, + effectiveConnections: 1, + })).toMatchObject({ + kind: 'aria2', + labelKey: 'connections', + value: '1 / 1', + }); + }); + it('does not use tellActive connections for the Torrent header', () => { expect(getPropertiesConnectionPresentation({ isMedia: false, diff --git a/src/utils/propertiesPresentation.ts b/src/utils/propertiesPresentation.ts index 346e74a..9cbc981 100644 --- a/src/utils/propertiesPresentation.ts +++ b/src/utils/propertiesPresentation.ts @@ -25,7 +25,7 @@ export const getPropertiesProgress = ( : resolveDownloadFraction(snapshot); export const getPropertiesConnectionPresentation = ( - snapshot: Pick, + snapshot: Pick, ): PropertiesConnectionPresentation => { if (snapshot.isMedia === true) { return { @@ -53,6 +53,6 @@ export const getPropertiesConnectionPresentation = ( kind: 'aria2', showHeaderMetric: true, labelKey: 'connections', - value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.requestedConnections ?? snapshot.connections)}`, + value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.effectiveConnections ?? snapshot.requestedConnections ?? snapshot.connections)}`, }; };