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.
This commit is contained in:
NimBold
2026-08-15 14:31:38 +03:30
parent 92cfaa26ce
commit f77fd0be3f
14 changed files with 846 additions and 90 deletions
+1
View File
@@ -150,6 +150,7 @@ pub struct QueueConcurrencyConfig {
#[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadErrorKind {
NameResolution,
DestinationAccess,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+197 -21
View File
@@ -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(&current_url)
.ok()
.and_then(|value| value.host_str().map(str::to_string))
.unwrap_or_else(|| "<unknown host>".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(&current_url)
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
let metadata_host = parsed_current_url
.host_str()
.unwrap_or("<unknown host>")
.to_string();
let resolved_addr = match parsed_current_url.scheme() {
"http" | "https" => validate_url_ssrf(&current_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<R: tauri::Runtime>(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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
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<i32>,
#[ts(optional)]
effective_connections: Option<i32>,
#[ts(optional)]
uploaded_bytes: Option<f64>,
#[ts(optional)]
upload_speed: Option<String>,
@@ -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<Aria2RecoveryReason> {
@@ -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)
+545 -58
View File
@@ -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<R: tauri::Runtime = tauri::Wry> {
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
/// 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<HashMap<String, Aria2ConnectionOptions>>,
/// 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<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
.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<i32> {
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<R: tauri::Runtime> QueueManager<R> {
// 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<R: tauri::Runtime> QueueManager<R> {
/// 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<R: tauri::Runtime> QueueManager<R> {
}
PendingOutcome::Error(error) => {
self.forget_torrent_telemetry(id).await;
let terminal_gid = self
.aria2_gid_for_download(id)
.unwrap_or_else(|| "<none>".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<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
.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<String>,
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<HttpTransferProbe, String> {
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<HttpTransferProbe, String> {
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<HttpTransferProbe, String> {
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, &current);
for redirect_count in 0..=5 {
let (host, address) = crate::resolve_and_validate_url_host(&current).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::<std::net::IpAddr>()
.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(&current).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<String>,
options: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Value {
serde_json::json!([transfer_uris, options])
}
fn apply_aria2_normal_reliability_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
@@ -6605,7 +6886,9 @@ impl ProductionSpawner {
impl SidecarSpawner for ProductionSpawner {
async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result<String, String> {
let state = self.app_handle.state::<crate::AppState>();
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(|| "<torrent>".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, &params).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
);
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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, };
+4 -2
View File
@@ -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) {
+5 -1
View File
@@ -334,7 +334,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div>
<span
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
download.lastError && (
download.status === 'failed'
|| download.status === 'retrying'
|| download.lastErrorKind === 'destinationAccess'
)
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${downloadStatusLabel} #${queueIndex + 1}`
+6 -1
View File
@@ -227,9 +227,14 @@ describe('Properties window bridge', () => {
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');
});
+6
View File
@@ -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 }
: {}),
+29
View File
@@ -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: [
+27 -3
View File
@@ -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;
+7
View File
@@ -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')
|| (
+15
View File
@@ -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,
+2 -2
View File
@@ -25,7 +25,7 @@ export const getPropertiesProgress = (
: resolveDownloadFraction(snapshot);
export const getPropertiesConnectionPresentation = (
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'torrentConnectedPeers' | 'torrentConnectedSeeders'>,
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'effectiveConnections' | 'torrentConnectedPeers' | 'torrentConnectedSeeders'>,
): 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)}`,
};
};