mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-31 04:58:11 +00:00
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:
@@ -150,6 +150,7 @@ pub struct QueueConcurrencyConfig {
|
|||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub enum DownloadErrorKind {
|
pub enum DownloadErrorKind {
|
||||||
NameResolution,
|
NameResolution,
|
||||||
|
DestinationAccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
+197
-21
@@ -1470,6 +1470,7 @@ fn emit_media_progress(
|
|||||||
total_is_estimate,
|
total_is_estimate,
|
||||||
active_connections: None,
|
active_connections: None,
|
||||||
requested_connections: None,
|
requested_connections: None,
|
||||||
|
effective_connections: None,
|
||||||
uploaded_bytes: None,
|
uploaded_bytes: None,
|
||||||
upload_speed: None,
|
upload_speed: None,
|
||||||
num_seeders: None,
|
num_seeders: None,
|
||||||
@@ -2010,6 +2011,14 @@ async fn fetch_metadata(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
if redirects >= 5 {
|
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(|| "<unknown host>".to_string()),
|
||||||
|
redirects
|
||||||
|
);
|
||||||
return Err("Too many redirects".to_string());
|
return Err("Too many redirects".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2037,6 +2046,10 @@ async fn fetch_metadata(
|
|||||||
|
|
||||||
let parsed_current_url = reqwest::Url::parse(¤t_url)
|
let parsed_current_url = reqwest::Url::parse(¤t_url)
|
||||||
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
.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() {
|
let resolved_addr = match parsed_current_url.scheme() {
|
||||||
"http" | "https" => validate_url_ssrf(¤t_url).await?,
|
"http" | "https" => validate_url_ssrf(¤t_url).await?,
|
||||||
"ftp" | "sftp" => resolve_and_validate_url_host(&parsed_current_url)
|
"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 {
|
let mut current_res = match head_req.send().await {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
|
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
|
||||||
let head_error = crate::redact_sensitive_text(&head_error.to_string());
|
log::warn!(
|
||||||
let get_error = crate::redact_sensitive_text(&get_error.to_string());
|
"metadata [stage=metadata host={} error_code=head_{}+get_{}]",
|
||||||
|
metadata_host,
|
||||||
|
reqwest_error_code(&head_error),
|
||||||
|
reqwest_error_code(&get_error)
|
||||||
|
);
|
||||||
format!(
|
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 {
|
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(
|
if retry_metadata_with_cookies(
|
||||||
current_res.status(),
|
current_res.status(),
|
||||||
@@ -2161,6 +2187,11 @@ async fn fetch_metadata(
|
|||||||
if let Ok(new_url) = parsed_base.join(loc_str) {
|
if let Ok(new_url) = parsed_base.join(loc_str) {
|
||||||
current_url = new_url.to_string();
|
current_url = new_url.to_string();
|
||||||
redirects += 1;
|
redirects += 1;
|
||||||
|
log::debug!(
|
||||||
|
"metadata [stage=redirect host={} redirect_count={}]",
|
||||||
|
metadata_host,
|
||||||
|
redirects
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2179,6 +2210,11 @@ async fn fetch_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(error) = metadata_response_error(current_res.status()) {
|
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);
|
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_CACHE_TTL: Duration = Duration::from_secs(60);
|
||||||
const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55);
|
const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55);
|
||||||
const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128;
|
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))
|
.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 {
|
pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool {
|
||||||
use std::path::Component;
|
use std::path::Component;
|
||||||
|
|
||||||
@@ -3337,6 +3499,8 @@ pub struct DownloadProgressEvent {
|
|||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
requested_connections: Option<i32>,
|
requested_connections: Option<i32>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
effective_connections: Option<i32>,
|
||||||
|
#[ts(optional)]
|
||||||
uploaded_bytes: Option<f64>,
|
uploaded_bytes: Option<f64>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
upload_speed: Option<String>,
|
upload_speed: Option<String>,
|
||||||
@@ -4612,6 +4776,7 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
total_is_estimate: Some(false),
|
total_is_estimate: Some(false),
|
||||||
active_connections: None,
|
active_connections: None,
|
||||||
requested_connections: None,
|
requested_connections: None,
|
||||||
|
effective_connections: None,
|
||||||
uploaded_bytes: None,
|
uploaded_bytes: None,
|
||||||
upload_speed: None,
|
upload_speed: None,
|
||||||
num_seeders: None,
|
num_seeders: None,
|
||||||
@@ -4689,6 +4854,7 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
total_is_estimate: Some(false),
|
total_is_estimate: Some(false),
|
||||||
active_connections: None,
|
active_connections: None,
|
||||||
requested_connections: None,
|
requested_connections: None,
|
||||||
|
effective_connections: None,
|
||||||
uploaded_bytes: None,
|
uploaded_bytes: None,
|
||||||
upload_speed: None,
|
upload_speed: None,
|
||||||
num_seeders: None,
|
num_seeders: None,
|
||||||
@@ -7123,6 +7289,8 @@ async fn enqueue_download_locked(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
}
|
}
|
||||||
|
preflight_download_destination_access(app_handle, &item.id, &item.destination)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
let id = item.id.clone();
|
let id = item.id.clone();
|
||||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||||
let accepted_filename = item.filename.clone();
|
let accepted_filename = item.filename.clone();
|
||||||
@@ -11984,7 +12152,7 @@ mod tests {
|
|||||||
completed,
|
completed,
|
||||||
speed_bytes: speed,
|
speed_bytes: speed,
|
||||||
active_connections: 16,
|
active_connections: 16,
|
||||||
requested_connections: 16,
|
effective_connections: 16,
|
||||||
speed_limited: false,
|
speed_limited: false,
|
||||||
now: start + Duration::from_secs(offset),
|
now: start + Duration::from_secs(offset),
|
||||||
},
|
},
|
||||||
@@ -12000,7 +12168,7 @@ mod tests {
|
|||||||
completed: 13 * 1024 * 1024,
|
completed: 13 * 1024 * 1024,
|
||||||
speed_bytes: 1024.0 * 1024.0,
|
speed_bytes: 1024.0 * 1024.0,
|
||||||
active_connections: 16,
|
active_connections: 16,
|
||||||
requested_connections: 16,
|
effective_connections: 16,
|
||||||
speed_limited: false,
|
speed_limited: false,
|
||||||
now: start + Duration::from_secs(31),
|
now: start + Duration::from_secs(31),
|
||||||
},
|
},
|
||||||
@@ -12018,7 +12186,7 @@ mod tests {
|
|||||||
completed: 13 * 1024 * 1024,
|
completed: 13 * 1024 * 1024,
|
||||||
speed_bytes: 1024.0 * 1024.0,
|
speed_bytes: 1024.0 * 1024.0,
|
||||||
active_connections: 1,
|
active_connections: 1,
|
||||||
requested_connections: 16,
|
effective_connections: 16,
|
||||||
speed_limited: false,
|
speed_limited: false,
|
||||||
now: start + Duration::from_secs(62),
|
now: start + Duration::from_secs(62),
|
||||||
},
|
},
|
||||||
@@ -12051,7 +12219,7 @@ mod tests {
|
|||||||
completed: 10,
|
completed: 10,
|
||||||
speed_bytes: 1024.0,
|
speed_bytes: 1024.0,
|
||||||
active_connections: 16,
|
active_connections: 16,
|
||||||
requested_connections: 16,
|
effective_connections: 16,
|
||||||
speed_limited: false,
|
speed_limited: false,
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
@@ -12073,7 +12241,7 @@ mod tests {
|
|||||||
completed: 10,
|
completed: 10,
|
||||||
speed_bytes: 1024.0,
|
speed_bytes: 1024.0,
|
||||||
active_connections: 16,
|
active_connections: 16,
|
||||||
requested_connections: 16,
|
effective_connections: 16,
|
||||||
speed_limited: false,
|
speed_limited: false,
|
||||||
now: now + Duration::from_secs(1),
|
now: now + Duration::from_secs(1),
|
||||||
},
|
},
|
||||||
@@ -13928,7 +14096,7 @@ struct Aria2ConnectionSample<'a> {
|
|||||||
completed: u64,
|
completed: u64,
|
||||||
speed_bytes: f64,
|
speed_bytes: f64,
|
||||||
active_connections: i32,
|
active_connections: i32,
|
||||||
requested_connections: i32,
|
effective_connections: i32,
|
||||||
speed_limited: bool,
|
speed_limited: bool,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
}
|
}
|
||||||
@@ -13972,7 +14140,7 @@ fn observe_aria2_connections(
|
|||||||
completed: u64,
|
completed: u64,
|
||||||
speed_bytes: f64,
|
speed_bytes: f64,
|
||||||
active_connections: i32,
|
active_connections: i32,
|
||||||
requested_connections: i32,
|
effective_connections: i32,
|
||||||
speed_limited: bool,
|
speed_limited: bool,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
) -> Option<Aria2RecoveryReason> {
|
) -> Option<Aria2RecoveryReason> {
|
||||||
@@ -13986,7 +14154,7 @@ fn observe_aria2_connections(
|
|||||||
completed,
|
completed,
|
||||||
speed_bytes,
|
speed_bytes,
|
||||||
active_connections,
|
active_connections,
|
||||||
requested_connections,
|
effective_connections,
|
||||||
speed_limited,
|
speed_limited,
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
@@ -14005,7 +14173,7 @@ fn observe_aria2_connections_with_epoch(
|
|||||||
completed,
|
completed,
|
||||||
speed_bytes,
|
speed_bytes,
|
||||||
active_connections,
|
active_connections,
|
||||||
requested_connections,
|
effective_connections,
|
||||||
speed_limited,
|
speed_limited,
|
||||||
now,
|
now,
|
||||||
} = sample;
|
} = sample;
|
||||||
@@ -14043,7 +14211,7 @@ fn observe_aria2_connections_with_epoch(
|
|||||||
observation.no_progress_since = None;
|
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;
|
&& remaining >= ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY;
|
||||||
if multi_connection_candidate {
|
if multi_connection_candidate {
|
||||||
let healthy_connection_sample = active_connections > 1
|
let healthy_connection_sample = active_connections > 1
|
||||||
@@ -14064,9 +14232,9 @@ fn observe_aria2_connections_with_epoch(
|
|||||||
let partial_connection_pool_collapse = !speed_limited
|
let partial_connection_pool_collapse = !speed_limited
|
||||||
&& observation.saw_multiple_connections
|
&& observation.saw_multiple_connections
|
||||||
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
||||||
&& requested_connections >= 4
|
&& effective_connections >= 4
|
||||||
&& (active_connections as f64)
|
&& (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
|
&& observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY
|
||||||
&& speed_bytes > 0.0
|
&& speed_bytes > 0.0
|
||||||
&& speed_bytes < observation.peak_speed_bytes * 0.5;
|
&& speed_bytes < observation.peak_speed_bytes * 0.5;
|
||||||
@@ -14978,10 +15146,15 @@ pub fn run() {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
.max(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 speed_limited = poll_mgr.aria2_speed_limited(&id).await;
|
||||||
let control_epoch = mapping.epoch;
|
let control_epoch = mapping.epoch;
|
||||||
// The status snapshot and the requested
|
// The status snapshot and both connection
|
||||||
// connection lookup both await. A pause,
|
// lookups await. A pause,
|
||||||
// retry, or same-GID resume may replace the
|
// retry, or same-GID resume may replace the
|
||||||
// mapping while those awaits are in
|
// mapping while those awaits are in
|
||||||
// flight. Only emit telemetry if this
|
// flight. Only emit telemetry if this
|
||||||
@@ -15007,7 +15180,7 @@ pub fn run() {
|
|||||||
completed,
|
completed,
|
||||||
speed_bytes,
|
speed_bytes,
|
||||||
active_connections,
|
active_connections,
|
||||||
requested_connections,
|
effective_connections,
|
||||||
speed_limited,
|
speed_limited,
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
@@ -15212,6 +15385,8 @@ pub fn run() {
|
|||||||
total_is_estimate: Some(false),
|
total_is_estimate: Some(false),
|
||||||
active_connections: Some(active_connections),
|
active_connections: Some(active_connections),
|
||||||
requested_connections: (!is_torrent).then_some(requested_connections),
|
requested_connections: (!is_torrent).then_some(requested_connections),
|
||||||
|
effective_connections: (!is_torrent)
|
||||||
|
.then_some(effective_connections),
|
||||||
uploaded_bytes: torrent_telemetry
|
uploaded_bytes: torrent_telemetry
|
||||||
.map(|value| value.uploaded_bytes as f64)
|
.map(|value| value.uploaded_bytes as f64)
|
||||||
.or_else(|| uploaded_bytes.map(|value| value as f64)),
|
.or_else(|| uploaded_bytes.map(|value| value as f64)),
|
||||||
@@ -15234,13 +15409,14 @@ pub fn run() {
|
|||||||
|
|
||||||
if let Some(reason) = recovery_reason {
|
if let Some(reason) = recovery_reason {
|
||||||
log::warn!(
|
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,
|
id,
|
||||||
gid,
|
gid,
|
||||||
reason.as_str(),
|
reason.as_str(),
|
||||||
speed_bytes,
|
speed_bytes,
|
||||||
active_connections,
|
active_connections,
|
||||||
requested_connections
|
requested_connections,
|
||||||
|
effective_connections
|
||||||
);
|
);
|
||||||
if let Err(error) = poll_mgr
|
if let Err(error) = poll_mgr
|
||||||
.refresh_aria2_connections(&id, gid, control_epoch)
|
.refresh_aria2_connections(&id, gid, control_epoch)
|
||||||
|
|||||||
+545
-58
@@ -534,6 +534,12 @@ pub struct Aria2GidMapping {
|
|||||||
pub epoch: u64,
|
pub epoch: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct Aria2ConnectionOptions {
|
||||||
|
epoch: u64,
|
||||||
|
effective: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct TorrentTelemetrySnapshot {
|
pub struct TorrentTelemetrySnapshot {
|
||||||
pub uploaded_bytes: u64,
|
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.
|
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
||||||
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
|
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
|
/// Initial aria2 addUri handoffs that have not yet either published a GID
|
||||||
/// or removed a stale late GID. Removal waits for these handoffs before
|
/// or removed a stale late GID. Removal waits for these handoffs before
|
||||||
/// deleting owned assets so a magnet cannot leave an orphaned output.
|
/// 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())),
|
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||||
aria2_payloads: 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_inflight: Mutex::new(HashMap::new()),
|
||||||
aria2_dispatch_notify: Notify::new(),
|
aria2_dispatch_notify: Notify::new(),
|
||||||
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
||||||
@@ -1927,6 +1938,37 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.map(clamp_download_connections)
|
.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 {
|
pub async fn aria2_torrent_seeding_requested(&self, id: &str) -> bool {
|
||||||
self.aria2_payloads
|
self.aria2_payloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -3709,7 +3751,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
// lets the UI (and a concurrent Properties pause)
|
// lets the UI (and a concurrent Properties pause)
|
||||||
// act on a lifecycle that does not yet exist.
|
// act on a lifecycle that does not yet exist.
|
||||||
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
|
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()
|
let install_web_seeds = buffered_outcome.is_none()
|
||||||
&& task.payload.is_torrent
|
&& task.payload.is_torrent
|
||||||
&& !task.payload.torrent_verify_only
|
&& !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.
|
/// 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 {
|
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 _gid_state = self.aria2_gid_state.lock().await;
|
||||||
let mut gids = self.aria2_gids.write().unwrap();
|
{
|
||||||
let Some(mapping) = gids.get_mut(gid) else {
|
let mut gids = self.aria2_gids.write().unwrap();
|
||||||
return false;
|
let Some(mapping) = gids.get_mut(gid) else {
|
||||||
};
|
return false;
|
||||||
if mapping.id != id {
|
};
|
||||||
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4068,6 +4117,28 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
PendingOutcome::Error(error) => {
|
PendingOutcome::Error(error) => {
|
||||||
self.forget_torrent_telemetry(id).await;
|
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") {
|
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
|
||||||
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
||||||
if let Ok(paths) =
|
if let Ok(paths) =
|
||||||
@@ -4112,7 +4183,16 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
self.emit_paused_with_error(id, error);
|
self.emit_paused_with_error(id, error);
|
||||||
} else {
|
} else {
|
||||||
let safe_error = crate::redact_sensitive_text(&error);
|
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);
|
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) {
|
pub async fn clear_aria2_retry_state(&self, id: &str) {
|
||||||
self.aria2_payloads.lock().await.remove(id);
|
self.aria2_payloads.lock().await.remove(id);
|
||||||
self.aria2_retry_strikes.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) {
|
pub async fn cancel_aria2_retries(&self, id: &str) {
|
||||||
@@ -4842,16 +4923,21 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.await
|
.await
|
||||||
.insert(id_for_task.clone(), strike + 1);
|
.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 new_gid_for_event = new_gid.clone();
|
||||||
let buffered_outcome = this.remember_gid(id_for_task.clone(), new_gid).await;
|
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);
|
this.aria2_retrying_gids.lock().await.remove(&retry_gid);
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
if let Some(outcome) = buffered_outcome {
|
if let Some(outcome) = buffered_outcome {
|
||||||
@@ -5174,20 +5260,76 @@ struct HttpTransferProbe {
|
|||||||
|
|
||||||
struct PreparedNormalTransfer {
|
struct PreparedNormalTransfer {
|
||||||
uris: Vec<String>,
|
uris: Vec<String>,
|
||||||
connections: i32,
|
requested_connections: i32,
|
||||||
|
effective_connections: i32,
|
||||||
credentials_allowed: bool,
|
credentials_allowed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn payload_has_credential_material(payload: &SpawnPayload) -> bool {
|
fn payload_has_credential_material(payload: &SpawnPayload) -> bool {
|
||||||
[
|
let inline_url_credentials = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref())
|
||||||
payload.username.as_deref(),
|
.into_iter()
|
||||||
payload.password.as_deref(),
|
.any(|uri| {
|
||||||
payload.cookies.as_deref(),
|
reqwest::Url::parse(&uri)
|
||||||
payload.headers.as_deref(),
|
.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()
|
.iter()
|
||||||
.flatten()
|
.any(|marker| name.contains(marker))
|
||||||
.any(|value| !value.trim().is_empty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_normal_transfer(
|
async fn prepare_normal_transfer(
|
||||||
@@ -5206,10 +5348,20 @@ async fn prepare_normal_transfer(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
if !is_http_uri(&uri) {
|
if !is_http_uri(&uri) {
|
||||||
crate::resolve_and_validate_url_host(
|
let parsed =
|
||||||
&reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?,
|
reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||||
)
|
crate::resolve_and_validate_url_host(&parsed).await?;
|
||||||
.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);
|
uris.push(uri);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -5247,13 +5399,41 @@ async fn prepare_normal_transfer(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) if is_fatal_range_probe_error(&error) => {
|
||||||
return Err(format!(
|
log::error!(
|
||||||
"normal transfer preflight failed for {}: {}",
|
"aria2 redirect [{}]: fatal transfer validation failed host={} error_code={}",
|
||||||
|
id,
|
||||||
uri_host_for_log(&uri),
|
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();
|
uris.dedup();
|
||||||
@@ -5268,11 +5448,42 @@ async fn prepare_normal_transfer(
|
|||||||
}
|
}
|
||||||
Ok(PreparedNormalTransfer {
|
Ok(PreparedNormalTransfer {
|
||||||
uris,
|
uris,
|
||||||
connections,
|
requested_connections: requested,
|
||||||
|
effective_connections: connections,
|
||||||
credentials_allowed,
|
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 {
|
fn is_http_uri(uri: &str) -> bool {
|
||||||
reqwest::Url::parse(uri)
|
reqwest::Url::parse(uri)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -5312,18 +5523,56 @@ async fn probe_bounded_range_support(
|
|||||||
uri: &str,
|
uri: &str,
|
||||||
payload: &SpawnPayload,
|
payload: &SpawnPayload,
|
||||||
credential_origin: &reqwest::Url,
|
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> {
|
) -> Result<HttpTransferProbe, String> {
|
||||||
crate::ensure_reqwest_crypto_provider();
|
crate::ensure_reqwest_crypto_provider();
|
||||||
|
|
||||||
let mut current = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
|
let mut current = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
|
||||||
let mut credentials_allowed = can_forward_payload_credentials(credential_origin, ¤t);
|
let mut credentials_allowed = can_forward_payload_credentials(credential_origin, ¤t);
|
||||||
for redirect_count in 0..=5 {
|
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::<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(¤t).await?
|
||||||
|
};
|
||||||
let mut builder = reqwest::Client::builder()
|
let mut builder = reqwest::Client::builder()
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.resolve(&host, address);
|
.resolve(&host, address);
|
||||||
|
|
||||||
|
if allow_localhost {
|
||||||
|
builder = builder.no_proxy();
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(proxy) = payload
|
if let Some(proxy) = payload
|
||||||
.proxy
|
.proxy
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -5363,8 +5612,12 @@ async fn probe_bounded_range_support(
|
|||||||
if !matches!(next.scheme(), "http" | "https") {
|
if !matches!(next.scheme(), "http" | "https") {
|
||||||
return Err("range probe redirect uses an unsupported scheme".to_string());
|
return Err("range probe redirect uses an unsupported scheme".to_string());
|
||||||
}
|
}
|
||||||
credentials_allowed =
|
let (next, next_credentials_allowed) = apply_redirect_credentials_policy(
|
||||||
credentials_allowed && can_forward_payload_credentials(credential_origin, &next);
|
credential_origin,
|
||||||
|
credentials_allowed,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
credentials_allowed = next_credentials_allowed;
|
||||||
current = next;
|
current = next;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -5394,6 +5647,20 @@ fn can_forward_payload_credentials(original: &reqwest::Url, current: &reqwest::U
|
|||||||
|| (original.scheme() == "http" && current.scheme() == "https"))
|
|| (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(
|
fn apply_payload_headers(
|
||||||
mut request: reqwest::RequestBuilder,
|
mut request: reqwest::RequestBuilder,
|
||||||
payload: &SpawnPayload,
|
payload: &SpawnPayload,
|
||||||
@@ -5515,17 +5782,24 @@ fn classify_bounded_range_response(
|
|||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
content_range: Option<&str>,
|
content_range: Option<&str>,
|
||||||
) -> BoundedRangeSupport {
|
) -> 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 {
|
if status == reqwest::StatusCode::PARTIAL_CONTENT {
|
||||||
return match content_range.and_then(parse_content_range_bounds) {
|
return match content_range.and_then(parse_content_range_bounds) {
|
||||||
Some((0, 0)) => BoundedRangeSupport::Supported,
|
Some((0, 0)) => BoundedRangeSupport::Supported,
|
||||||
Some((0, _)) => BoundedRangeSupport::Unsupported,
|
Some((0, _)) => BoundedRangeSupport::Unknown,
|
||||||
Some(_) => BoundedRangeSupport::Unknown,
|
Some(_) => BoundedRangeSupport::Unknown,
|
||||||
None => BoundedRangeSupport::Unknown,
|
None => BoundedRangeSupport::Unknown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
BoundedRangeSupport::Unsupported
|
BoundedRangeSupport::Unknown
|
||||||
} else {
|
} else {
|
||||||
BoundedRangeSupport::Unknown
|
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(
|
fn apply_aria2_normal_reliability_options(
|
||||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
payload: &SpawnPayload,
|
payload: &SpawnPayload,
|
||||||
@@ -6605,7 +6886,9 @@ impl ProductionSpawner {
|
|||||||
impl SidecarSpawner for ProductionSpawner {
|
impl SidecarSpawner for ProductionSpawner {
|
||||||
async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result<String, String> {
|
async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result<String, String> {
|
||||||
let state = self.app_handle.state::<crate::AppState>();
|
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 options = serde_json::Map::new();
|
||||||
|
let mut connection_options = None;
|
||||||
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
|
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
|
||||||
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
|
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
|
||||||
return Err("Path traversal blocked".to_string());
|
return Err("Path traversal blocked".to_string());
|
||||||
@@ -6628,18 +6911,22 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if !payload.is_torrent {
|
if !payload.is_torrent {
|
||||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||||
}
|
}
|
||||||
let (transfer_uris, transfer_connections, credentials_allowed) = if payload.is_torrent {
|
let (transfer_uris, requested_connections, transfer_connections, credentials_allowed) =
|
||||||
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, true)
|
if payload.is_torrent {
|
||||||
} else {
|
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, true)
|
||||||
let requested = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
} else {
|
||||||
validate_aria2_transfer_network_policy(&requested).await?;
|
let requested =
|
||||||
let prepared = prepare_normal_transfer(id, payload).await?;
|
crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
||||||
(
|
validate_aria2_transfer_network_policy(&requested).await?;
|
||||||
prepared.uris,
|
let prepared = prepare_normal_transfer(id, payload).await?;
|
||||||
prepared.connections,
|
connection_options = Some(prepared.effective_connections);
|
||||||
prepared.credentials_allowed,
|
(
|
||||||
)
|
prepared.uris,
|
||||||
};
|
prepared.requested_connections,
|
||||||
|
prepared.effective_connections,
|
||||||
|
prepared.credentials_allowed,
|
||||||
|
)
|
||||||
|
};
|
||||||
if should_apply_aria2_connection_options(payload) {
|
if should_apply_aria2_connection_options(payload) {
|
||||||
apply_aria2_connection_options(&mut options, transfer_connections);
|
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);
|
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 {
|
let (method, params) = if payload.is_torrent {
|
||||||
if let Some(path) = payload.torrent_path.as_deref() {
|
if let Some(path) = payload.torrent_path.as_deref() {
|
||||||
let path = crate::torrent::validate_managed_torrent_path(
|
let path = crate::torrent::validate_managed_torrent_path(
|
||||||
@@ -6751,8 +7051,10 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
("aria2.addUri", serde_json::json!([[magnet], options]))
|
("aria2.addUri", serde_json::json!([[magnet], options]))
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
match self.add_transfer_rpc(&state, method, ¶ms).await {
|
||||||
@@ -6761,13 +7063,29 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if gid.is_empty() {
|
if gid.is_empty() {
|
||||||
Err(format!("{method} returned an empty gid"))
|
Err(format!("{method} returned an empty gid"))
|
||||||
} else {
|
} 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);
|
log::info!("aria2 {} [{}]: created gid {}", method, id, gid);
|
||||||
Ok(gid)
|
Ok(gid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let safe_error = crate::redact_sensitive_text(&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}"))
|
Err(format!("aria2 {method} failed: {safe_error}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8618,6 +8936,21 @@ mod tests {
|
|||||||
assert!(!options.contains_key("http-user"));
|
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]
|
#[test]
|
||||||
fn normal_checksum_options_enable_aria2_integrity_checks() {
|
fn normal_checksum_options_enable_aria2_integrity_checks() {
|
||||||
let mut options = serde_json::Map::new();
|
let mut options = serde_json::Map::new();
|
||||||
@@ -8693,6 +9026,103 @@ mod tests {
|
|||||||
&reqwest::Url::parse("https://example.test/file").unwrap(),
|
&reqwest::Url::parse("https://example.test/file").unwrap(),
|
||||||
&reqwest::Url::parse("http://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]
|
#[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]
|
#[test]
|
||||||
fn bounded_range_probe_accepts_exact_requested_byte() {
|
fn bounded_range_probe_accepts_exact_requested_byte() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -9124,20 +9603,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bounded_range_probe_rejects_server_that_expands_to_end() {
|
fn bounded_range_probe_treats_expanded_response_as_unknown() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_bounded_range_response(
|
classify_bounded_range_response(
|
||||||
reqwest::StatusCode::PARTIAL_CONTENT,
|
reqwest::StatusCode::PARTIAL_CONTENT,
|
||||||
Some("bytes 0-383882117/383882118"),
|
Some("bytes 0-383882117/383882118"),
|
||||||
),
|
),
|
||||||
BoundedRangeSupport::Unsupported
|
BoundedRangeSupport::Unknown
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bounded_range_probe_rejects_ignored_range_request() {
|
fn bounded_range_probe_treats_ignored_range_request_as_unknown() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_bounded_range_response(reqwest::StatusCode::OK, None),
|
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
|
BoundedRangeSupport::Unsupported
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
||||||
|
|||||||
@@ -1496,7 +1496,8 @@ export const AddDownloadsModal = () => {
|
|||||||
lastError: undefined
|
lastError: undefined
|
||||||
}, pendingAction);
|
}, pendingAction);
|
||||||
if (!replaced) {
|
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
|
// The existing row was updated in place; do not create a
|
||||||
@@ -1625,7 +1626,8 @@ export const AddDownloadsModal = () => {
|
|||||||
sizeBytes: item.sizeBytes
|
sizeBytes: item.sizeBytes
|
||||||
}, action);
|
}, action);
|
||||||
if (!added) {
|
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;
|
addedCount += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -334,7 +334,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
title={
|
title={
|
||||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
download.lastError && (
|
||||||
|
download.status === 'failed'
|
||||||
|
|| download.status === 'retrying'
|
||||||
|
|| download.lastErrorKind === 'destinationAccess'
|
||||||
|
)
|
||||||
? download.lastError
|
? download.lastError
|
||||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||||
? `${downloadStatusLabel} #${queueIndex + 1}`
|
? `${downloadStatusLabel} #${queueIndex + 1}`
|
||||||
|
|||||||
@@ -227,9 +227,14 @@ describe('Properties window bridge', () => {
|
|||||||
size_is_final: true,
|
size_is_final: true,
|
||||||
active_connections: 3,
|
active_connections: 3,
|
||||||
requested_connections: 8,
|
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');
|
expect(normalSnapshot).not.toHaveProperty('connectedPeers');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
|||||||
lastResolverFallback?: boolean;
|
lastResolverFallback?: boolean;
|
||||||
activeConnections?: number;
|
activeConnections?: number;
|
||||||
requestedConnections?: number;
|
requestedConnections?: number;
|
||||||
|
effectiveConnections?: number;
|
||||||
uploadSpeed?: string;
|
uploadSpeed?: string;
|
||||||
torrentConnectedPeers?: number;
|
torrentConnectedPeers?: number;
|
||||||
torrentConnectedSeeders?: number;
|
torrentConnectedSeeders?: number;
|
||||||
@@ -437,6 +438,11 @@ const copyWithoutSecrets = (
|
|||||||
&& live.progress.requested_connections !== undefined
|
&& live.progress.requested_connections !== undefined
|
||||||
? { requestedConnections: live.progress.requested_connections }
|
? { 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
|
...(live.progress.uploaded_bytes !== undefined
|
||||||
? { torrentUploadedBytes: live.progress.uploaded_bytes }
|
? { torrentUploadedBytes: live.progress.uploaded_bytes }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -1082,6 +1082,35 @@ describe('useDownloadStore', () => {
|
|||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
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', () => {
|
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
siteLogins: [
|
siteLogins: [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { info } from '../utils/logger';
|
|||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
|
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
|
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||||
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||||
@@ -258,6 +259,18 @@ export class SystemProxyResolutionError extends Error {
|
|||||||
const isSystemProxyConfigurationError = (error: unknown): boolean =>
|
const isSystemProxyConfigurationError = (error: unknown): boolean =>
|
||||||
error instanceof SystemProxyResolutionError;
|
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 =>
|
const stripSensitiveMediaHeaders = (value: string | null | undefined): string =>
|
||||||
(value || '')
|
(value || '')
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -442,7 +455,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
|
|
||||||
useDownloadStore.getState().setPendingOrder(order);
|
useDownloadStore.getState().setPendingOrder(order);
|
||||||
useDownloadStore.getState().registerBackendIds([id]);
|
useDownloadStore.getState().registerBackendIds([id]);
|
||||||
useDownloadStore.getState().updateDownload(id, { lastError: undefined });
|
useDownloadStore.getState().updateDownload(id, {
|
||||||
|
lastError: undefined,
|
||||||
|
lastErrorKind: undefined
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to dispatch ${id}:`, 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)) {
|
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||||
const proxyBlocked = isSystemProxyConfigurationError(e);
|
const proxyBlocked = isSystemProxyConfigurationError(e);
|
||||||
|
const destinationAccessBlocked = isRetryableDestinationAccessError(e);
|
||||||
|
const message = errorMessage(e);
|
||||||
useDownloadStore.getState().updateDownload(id, {
|
useDownloadStore.getState().updateDownload(id, {
|
||||||
status: proxyBlocked ? 'queued' : 'failed',
|
status: proxyBlocked ? 'queued' : destinationAccessBlocked ? 'ready' : 'failed',
|
||||||
lastError: errorMessage(e)
|
hasBeenDispatched: false,
|
||||||
|
lastErrorKind: destinationAccessBlocked
|
||||||
|
? ('destinationAccess' as DownloadErrorKind)
|
||||||
|
: undefined,
|
||||||
|
lastError: destinationAccessBlocked
|
||||||
|
? destinationAccessErrorMessage(message)
|
||||||
|
: message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
|||||||
export const classifyDownloadError = (message: unknown): DownloadErrorKind | undefined => {
|
export const classifyDownloadError = (message: unknown): DownloadErrorKind | undefined => {
|
||||||
if (typeof message !== 'string') return undefined;
|
if (typeof message !== 'string') return undefined;
|
||||||
const lower = message.toLowerCase();
|
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 (
|
if (
|
||||||
lower.includes('aria2 error code 19')
|
lower.includes('aria2 error code 19')
|
||||||
|| (
|
|| (
|
||||||
|
|||||||
@@ -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', () => {
|
it('does not use tellActive connections for the Torrent header', () => {
|
||||||
expect(getPropertiesConnectionPresentation({
|
expect(getPropertiesConnectionPresentation({
|
||||||
isMedia: false,
|
isMedia: false,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const getPropertiesProgress = (
|
|||||||
: resolveDownloadFraction(snapshot);
|
: resolveDownloadFraction(snapshot);
|
||||||
|
|
||||||
export const getPropertiesConnectionPresentation = (
|
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 => {
|
): PropertiesConnectionPresentation => {
|
||||||
if (snapshot.isMedia === true) {
|
if (snapshot.isMedia === true) {
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +53,6 @@ export const getPropertiesConnectionPresentation = (
|
|||||||
kind: 'aria2',
|
kind: 'aria2',
|
||||||
showHeaderMetric: true,
|
showHeaderMetric: true,
|
||||||
labelKey: 'connections',
|
labelKey: 'connections',
|
||||||
value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.requestedConnections ?? snapshot.connections)}`,
|
value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.effectiveConnections ?? snapshot.requestedConnections ?? snapshot.connections)}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user