mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 07:36:17 +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/")]
|
||||
pub enum DownloadErrorKind {
|
||||
NameResolution,
|
||||
DestinationAccess,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+197
-21
@@ -1470,6 +1470,7 @@ fn emit_media_progress(
|
||||
total_is_estimate,
|
||||
active_connections: None,
|
||||
requested_connections: None,
|
||||
effective_connections: None,
|
||||
uploaded_bytes: None,
|
||||
upload_speed: None,
|
||||
num_seeders: None,
|
||||
@@ -2010,6 +2011,14 @@ async fn fetch_metadata(
|
||||
|
||||
loop {
|
||||
if redirects >= 5 {
|
||||
log::warn!(
|
||||
"metadata [stage=metadata host={} redirects={} error_code=redirect_limit]",
|
||||
reqwest::Url::parse(¤t_url)
|
||||
.ok()
|
||||
.and_then(|value| value.host_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "<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(¤t_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(¤t_url).await?,
|
||||
"ftp" | "sftp" => resolve_and_validate_url_host(&parsed_current_url)
|
||||
@@ -2104,10 +2117,16 @@ async fn fetch_metadata(
|
||||
let mut current_res = match head_req.send().await {
|
||||
Ok(response) => response,
|
||||
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
|
||||
let head_error = crate::redact_sensitive_text(&head_error.to_string());
|
||||
let get_error = crate::redact_sensitive_text(&get_error.to_string());
|
||||
log::warn!(
|
||||
"metadata [stage=metadata host={} error_code=head_{}+get_{}]",
|
||||
metadata_host,
|
||||
reqwest_error_code(&head_error),
|
||||
reqwest_error_code(&get_error)
|
||||
);
|
||||
format!(
|
||||
"HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})"
|
||||
"HEAD metadata request failed ({}); ranged GET fallback failed ({})",
|
||||
reqwest_error_code(&head_error),
|
||||
reqwest_error_code(&get_error)
|
||||
)
|
||||
})?,
|
||||
};
|
||||
@@ -2137,7 +2156,14 @@ async fn fetch_metadata(
|
||||
}
|
||||
|
||||
if needs_fallback {
|
||||
current_res = build_get_range().send().await.map_err(|e| e.to_string())?;
|
||||
current_res = build_get_range().send().await.map_err(|error| {
|
||||
log::warn!(
|
||||
"metadata [stage=metadata host={} error_code=get_{}]",
|
||||
metadata_host,
|
||||
reqwest_error_code(&error)
|
||||
);
|
||||
error.to_string()
|
||||
})?;
|
||||
|
||||
if retry_metadata_with_cookies(
|
||||
current_res.status(),
|
||||
@@ -2161,6 +2187,11 @@ async fn fetch_metadata(
|
||||
if let Ok(new_url) = parsed_base.join(loc_str) {
|
||||
current_url = new_url.to_string();
|
||||
redirects += 1;
|
||||
log::debug!(
|
||||
"metadata [stage=redirect host={} redirect_count={}]",
|
||||
metadata_host,
|
||||
redirects
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2179,6 +2210,11 @@ async fn fetch_metadata(
|
||||
}
|
||||
|
||||
if let Some(error) = metadata_response_error(current_res.status()) {
|
||||
log::warn!(
|
||||
"metadata [stage=metadata host={} status={} error_code=response]",
|
||||
metadata_host,
|
||||
current_res.status().as_u16()
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
@@ -2246,6 +2282,20 @@ async fn fetch_metadata(
|
||||
})
|
||||
}
|
||||
|
||||
fn reqwest_error_code(error: &reqwest::Error) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
"timeout"
|
||||
} else if error.is_connect() {
|
||||
"connect"
|
||||
} else if error.is_request() {
|
||||
"request"
|
||||
} else if error.is_body() {
|
||||
"body"
|
||||
} else {
|
||||
"transport"
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_METADATA_CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55);
|
||||
const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128;
|
||||
@@ -2940,6 +2990,118 @@ pub(crate) fn is_safe_path<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
@@ -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, ¤t);
|
||||
for redirect_count in 0..=5 {
|
||||
let (host, address) = crate::resolve_and_validate_url_host(¤t).await?;
|
||||
let (host, address) = if allow_localhost {
|
||||
let host = current
|
||||
.host_str()
|
||||
.ok_or_else(|| "range probe test URL has no host".to_string())?;
|
||||
let ip = host
|
||||
.parse::<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()
|
||||
.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, ¶ms).await {
|
||||
@@ -6761,13 +7063,29 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
if gid.is_empty() {
|
||||
Err(format!("{method} returned an empty gid"))
|
||||
} else {
|
||||
if let Some(effective) = connection_options {
|
||||
state
|
||||
.queue_manager
|
||||
.set_aria2_connection_options(
|
||||
id,
|
||||
attempt_epoch,
|
||||
effective,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
log::info!("aria2 {} [{}]: created gid {}", method, id, gid);
|
||||
Ok(gid)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let safe_error = crate::redact_sensitive_text(&e);
|
||||
log::error!("aria2 {} [{}] failed: {}", method, id, safe_error);
|
||||
log::error!(
|
||||
"aria2 admission [stage=admission id={} epoch={} method={} error={}]",
|
||||
id,
|
||||
attempt_epoch,
|
||||
method,
|
||||
safe_error
|
||||
);
|
||||
Err(format!("aria2 {method} failed: {safe_error}"))
|
||||
}
|
||||
}
|
||||
@@ -8618,6 +8936,21 @@ mod tests {
|
||||
assert!(!options.contains_key("http-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_url_credentials_are_treated_as_credential_material() {
|
||||
assert!(payload_has_credential_material(&SpawnPayload {
|
||||
url: "https://alice:secret@example.test/file".to_string(),
|
||||
..SpawnPayload::default()
|
||||
}));
|
||||
assert!(payload_has_credential_material(&SpawnPayload {
|
||||
url: "https://example.test/file".to_string(),
|
||||
mirrors: Some("https://alice:secret@mirror.example/file".to_string()),
|
||||
..SpawnPayload::default()
|
||||
}));
|
||||
assert!(header_name_has_credential_material("X-Request-Signature"));
|
||||
assert!(!header_name_has_credential_material("Referer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_checksum_options_enable_aria2_integrity_checks() {
|
||||
let mut options = serde_json::Map::new();
|
||||
@@ -8693,6 +9026,103 @@ mod tests {
|
||||
&reqwest::Url::parse("https://example.test/file").unwrap(),
|
||||
&reqwest::Url::parse("http://example.test/file").unwrap()
|
||||
));
|
||||
|
||||
let ftp_origin = reqwest::Url::parse("ftp://example.test/file").unwrap();
|
||||
assert!(can_forward_payload_credentials(
|
||||
&ftp_origin,
|
||||
&reqwest::Url::parse("ftp://example.test/other").unwrap(),
|
||||
));
|
||||
assert!(!can_forward_payload_credentials(
|
||||
&ftp_origin,
|
||||
&reqwest::Url::parse("ftp://mirror.example.test/file").unwrap(),
|
||||
));
|
||||
|
||||
let redirected_with_credentials = reqwest::Url::parse(
|
||||
"https://alice:secret@cdn.example.test/file",
|
||||
)
|
||||
.unwrap();
|
||||
let (sanitized, credentials_allowed) = apply_redirect_credentials_policy(
|
||||
&original,
|
||||
true,
|
||||
redirected_with_credentials,
|
||||
);
|
||||
assert!(!credentials_allowed);
|
||||
assert!(sanitized.username().is_empty());
|
||||
assert!(sanitized.password().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_probe_fixture_follows_redirect_and_forwards_same_origin_credentials() {
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
async fn source() -> Redirect {
|
||||
Redirect::temporary("/final")
|
||||
}
|
||||
|
||||
async fn final_resource(headers: HeaderMap) -> impl IntoResponse {
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let cookie = headers
|
||||
.get("cookie")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
if range == Some("bytes=0-0") && cookie == Some("session=secret") {
|
||||
(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
[("content-range", "bytes 0-0/8")],
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/source", get(source))
|
||||
.route("/final", get(final_resource));
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("fixture listener");
|
||||
let port = listener.local_addr().expect("fixture address").port();
|
||||
let server = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
let source_url = format!("http://127.0.0.1:{port}/source");
|
||||
let payload = SpawnPayload {
|
||||
url: source_url.clone(),
|
||||
cookies: Some("session=secret".to_string()),
|
||||
connections: Some(16),
|
||||
..SpawnPayload::default()
|
||||
};
|
||||
let origin = reqwest::Url::parse(&source_url).expect("fixture URL");
|
||||
let probe = probe_bounded_range_support_local_test(&source_url, &payload, &origin)
|
||||
.await
|
||||
.expect("range probe");
|
||||
|
||||
assert_eq!(
|
||||
probe.final_uri,
|
||||
format!("http://127.0.0.1:{port}/final")
|
||||
);
|
||||
assert_eq!(probe.range_support, BoundedRangeSupport::Supported);
|
||||
assert!(probe.credentials_allowed);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_probe_transport_failures_are_advisory_but_security_failures_are_fatal() {
|
||||
assert!(!is_fatal_range_probe_error("error sending request"));
|
||||
assert_eq!(range_probe_error_code("error sending request"), "transport");
|
||||
assert!(is_fatal_range_probe_error("SSRF blocked: Private/local IP not allowed"));
|
||||
assert_eq!(
|
||||
range_probe_error_code("SSRF blocked: DNS resolution timed out"),
|
||||
"timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9087,6 +9517,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_aria2_connection_options_cannot_overwrite_a_newer_epoch() {
|
||||
let app = tauri::test::mock_builder()
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets()))
|
||||
.expect("mock app");
|
||||
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
|
||||
let first_epoch = manager.next_aria2_control_epoch("download").await;
|
||||
|
||||
manager
|
||||
.set_aria2_connection_options("download", first_epoch, 1)
|
||||
.await;
|
||||
assert_eq!(
|
||||
manager
|
||||
.aria2_effective_connections("download", first_epoch)
|
||||
.await,
|
||||
Some(1)
|
||||
);
|
||||
|
||||
let second_epoch = manager.next_aria2_control_epoch("download").await;
|
||||
assert_eq!(
|
||||
manager
|
||||
.aria2_effective_connections("download", second_epoch)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
manager
|
||||
.set_aria2_connection_options("download", second_epoch, 4)
|
||||
.await;
|
||||
manager
|
||||
.set_aria2_connection_options("download", first_epoch, 1)
|
||||
.await;
|
||||
assert_eq!(
|
||||
manager
|
||||
.aria2_effective_connections("download", second_epoch)
|
||||
.await,
|
||||
Some(4)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_add_uri_uses_the_prepared_attempt_uris() {
|
||||
let prepared = vec!["https://cdn.example.test/signed-attempt".to_string()];
|
||||
let source = "https://downloads.example.test/stable-source";
|
||||
let params = aria2_add_uri_params(prepared.clone(), serde_json::Map::new());
|
||||
|
||||
assert_eq!(params[0], serde_json::json!(prepared));
|
||||
assert_ne!(params[0], serde_json::json!([source]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_range_probe_accepts_exact_requested_byte() {
|
||||
assert_eq!(
|
||||
@@ -9124,20 +9603,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_range_probe_rejects_server_that_expands_to_end() {
|
||||
fn bounded_range_probe_treats_expanded_response_as_unknown() {
|
||||
assert_eq!(
|
||||
classify_bounded_range_response(
|
||||
reqwest::StatusCode::PARTIAL_CONTENT,
|
||||
Some("bytes 0-383882117/383882118"),
|
||||
),
|
||||
BoundedRangeSupport::Unsupported
|
||||
BoundedRangeSupport::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_range_probe_rejects_ignored_range_request() {
|
||||
fn bounded_range_probe_treats_ignored_range_request_as_unknown() {
|
||||
assert_eq!(
|
||||
classify_bounded_range_response(reqwest::StatusCode::OK, None),
|
||||
BoundedRangeSupport::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_range_probe_classifies_explicit_range_rejection() {
|
||||
assert_eq!(
|
||||
classify_bounded_range_response(reqwest::StatusCode::RANGE_NOT_SATISFIABLE, None),
|
||||
BoundedRangeSupport::Unsupported
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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, };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|| (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user