fix(network): harden route-aware torrent resolution

- keep reqwest metadata on its supported proxy route and require Aria2 asynchronous DNS
- fence magnet probe ownership and delayed RPC cleanup without cross-transfer removal
- validate Torrent DHT nodes, web seeds, proxy inputs, and literal IPv4/IPv6 targets
- extend regression and smoke coverage for issue #38

Refs: #38
This commit is contained in:
NimBold
2026-08-30 14:58:01 +03:30
parent 1d629873b5
commit b564e92532
8 changed files with 918 additions and 709 deletions
+10 -8
View File
@@ -189,6 +189,7 @@ const child = spawn(binaryPath, [
`--dir=${tempRoot}`,
'--file-allocation=none',
'--enable-dht=false',
'--async-dns=true',
'--console-log-level=error',
'--quiet=true',
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
@@ -198,15 +199,17 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
if (!features.includes('Async DNS')) {
throw new Error(`packaged aria2 must advertise Async DNS for route-safe transfers: ${JSON.stringify(version)}`);
}
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: supported`);
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
'async-dns': 'false',
out: 'resolver-normal.bin',
}]);
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
if (uriOptions['async-dns'] !== 'false') {
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
if (uriOptions['async-dns'] === 'false') {
throw new Error(`direct aria2.addUri unexpectedly disabled asynchronous DNS: ${JSON.stringify(uriOptions)}`);
}
const torrent = bencode({
@@ -218,12 +221,11 @@ try {
},
}).toString('base64');
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
'async-dns': 'false',
dir: tempRoot,
}]);
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
if (torrentOptions['async-dns'] !== 'false') {
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
if (torrentOptions['async-dns'] === 'false') {
throw new Error(`direct aria2.addTorrent unexpectedly disabled asynchronous DNS: ${JSON.stringify(torrentOptions)}`);
}
const proxyRoute = 'http://127.0.0.1:9';
@@ -256,7 +258,7 @@ try {
await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult);
await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult);
console.log('[PASS] Aria2 retained system-resolver mode for direct normal/Torrent transfers and configured proxy mode for proxied transfers');
console.log('[PASS] Aria2 kept asynchronous DNS for fresh direct and proxied normal/Torrent transfers');
} catch (error) {
const detail = stderr.trim();
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
+4 -5
View File
@@ -305,6 +305,7 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
'--enable-dht=false',
'--enable-peer-exchange=false',
'--bt-enable-lpd=false',
'--async-dns=true',
'--console-log-level=error',
'--quiet=true',
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
@@ -720,7 +721,6 @@ async function main() {
'bt-metadata-only': 'false',
'bt-save-metadata': 'false',
'follow-torrent': 'false',
'async-dns': 'false',
'max-tries': '3',
'retry-wait': '2',
'connect-timeout': '20',
@@ -767,7 +767,7 @@ async function main() {
assert(directHandoff.parent.status === 'complete', `normal magnet parent did not complete metadata: ${JSON.stringify(directHandoff.parent)}`);
assert(directHandoff.parent.files?.some(file => String(file.path).startsWith('[METADATA]')), 'normal magnet parent did not expose a metadata file');
assert(directOptions['bt-metadata-only'] === 'false', 'normal magnet child did not retain payload mode');
assert(directOptions['async-dns'] === 'false', 'direct Torrent did not retain system DNS resolution');
assert(directOptions['async-dns'] !== 'false', 'fresh direct Torrent unexpectedly disabled asynchronous DNS');
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directGid]);
try {
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]);
@@ -790,7 +790,6 @@ async function main() {
dir: probeDir,
'bt-metadata-only': 'true',
'bt-save-metadata': 'true',
'async-dns': 'false',
'max-tries': '3',
'retry-wait': '1',
'connect-timeout': '5',
@@ -798,7 +797,7 @@ async function main() {
'auto-file-renaming': 'false',
}]);
const probeOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [probeGid]);
assert(probeOptions['async-dns'] === 'false', 'direct magnet metadata probe did not retain system DNS resolution');
assert(probeOptions['async-dns'] !== 'false', 'fresh direct magnet probe unexpectedly disabled asynchronous DNS');
const probeStatus = await waitForTerminal(client, probeGid, 30000);
assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete');
const savedTorrentPaths = fs.readdirSync(probeDir)
@@ -859,7 +858,7 @@ async function main() {
assert(proxiedProbeOptions['async-dns'] !== 'false', 'proxied magnet metadata probe unexpectedly forced system DNS resolution');
assert(await forceRemoveIfPresent(client, proxiedProbeGid), 'proxied magnet metadata probe was not removable');
await waitForRemoved(client, proxiedProbeGid);
console.log('[OK] metadata probe was removed after resolution; direct and proxied resolver modes were retained');
console.log('[OK] metadata probe was removed after resolution; direct and proxied probes kept asynchronous DNS');
const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
trackerlessTorrentBytes.toString('base64'),
+71 -50
View File
@@ -1739,11 +1739,11 @@ async fn fetch_remote_torrent_bytes(
cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>,
) -> Result<Vec<u8>, String> {
ensure_reqwest_crypto_provider();
let proxy = proxy
.map(crate::queue::aria2_all_proxy_value)
.transpose()?
.flatten();
let route = crate::network::NetworkRoute::from_proxy(proxy.as_deref());
// This fetch is reqwest-backed, so preserve the route exactly as supplied.
// In particular, SOCKS is a valid reqwest route even though Aria2 cannot
// use it for normal transfers. Sharing Aria2's proxy validator here would
// reject a supported remote-torrent metadata path before reqwest sees it.
let route = crate::network::NetworkRoute::from_proxy(proxy);
let mut current = reqwest::Url::parse(source)
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
let original_origin = Some(current.clone());
@@ -3466,6 +3466,17 @@ struct Aria2DaemonGuard {
shutdown_state: AtomicU8,
}
fn aria2_supports_async_dns(version: &serde_json::Value) -> bool {
version
.get("enabledFeatures")
.and_then(|features| features.as_array())
.is_some_and(|features| {
features
.iter()
.any(|feature| feature.as_str() == Some("Async DNS"))
})
}
impl Aria2DaemonGuard {
fn new() -> Self {
Self {
@@ -9190,6 +9201,10 @@ async fn resolve_magnet_metadata(
cache: bool,
) -> Result<crate::ipc::TorrentMetadata, String> {
let expected = crate::torrent::inspect_source(source)?;
// Validate and normalize the Magnet before creating any filesystem
// state. Otherwise a malformed secondary parameter can leave an orphaned
// probe directory even though Aria2 was never contacted.
let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?;
let managed_path = crate::torrent::managed_torrent_path(app_handle, id)?;
let proxy_value = proxy
.map(crate::queue::aria2_all_proxy_value)
@@ -9244,15 +9259,6 @@ async fn resolve_magnet_metadata(
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
options.insert("timeout".to_string(), serde_json::json!("60"));
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
if proxy_value
.as_deref()
.is_none_or(|proxy| proxy.trim().is_empty())
{
// Keep optional magnet metadata refreshes on the same system DNS
// route as direct Torrent transfers when a TUN client owns resolver
// interception. Explicit proxy routes retain their own DNS policy.
options.insert("async-dns".to_string(), serde_json::json!("false"));
}
if let Some(proxy) = proxy_value.as_deref() {
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
}
@@ -9274,23 +9280,26 @@ async fn resolve_magnet_metadata(
}
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?;
let direct_route = proxy_value
let resolver_mode = if proxy_value
.as_deref()
.is_none_or(|proxy| proxy.trim().is_empty());
let alternate_resolver_available =
direct_route && state.queue_manager.aria2_async_dns_supported();
let first_resolver_mode = if direct_route { "system" } else { "configured" };
let metadata_result = crate::torrent_probe::run_metadata_probe_with_resolver_fallback(
.is_none_or(|proxy| proxy.trim().is_empty())
{
"automatic"
} else {
"configured"
};
let metadata_result = crate::torrent_probe::run_bounded_metadata_probe(
client,
&sanitized_source,
options,
&metadata_path,
first_resolver_mode,
alternate_resolver_available,
Duration::from_secs(60),
Duration::from_secs(20),
Duration::from_millis(250),
resolver_mode,
crate::torrent_probe::MetadataProbeSchedule {
total_timeout: Duration::from_secs(60),
metadata_timeout: Duration::from_secs(55),
cleanup_reserve: Duration::from_secs(5),
poll_interval: Duration::from_millis(250),
},
)
.await;
@@ -14025,6 +14034,7 @@ mod tests {
normalize_media_cookie_source,
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
validate_torrent_metadata_network_policy,
aria2_supports_async_dns,
aria2_gid_not_found,
aria2_download_state_progress, preflight_download_destination_access,
retained_torrent_id_from_persisted_record,
@@ -14062,6 +14072,17 @@ mod tests {
assert!(!aria2_gid_not_found("aria2 error code 3: Resource not found"));
}
#[test]
fn aria2_async_dns_capability_requires_an_explicit_feature() {
assert!(aria2_supports_async_dns(&json!({
"enabledFeatures": ["Async DNS", "BitTorrent"]
})));
assert!(!aria2_supports_async_dns(&json!({
"enabledFeatures": ["BitTorrent"]
})));
assert!(!aria2_supports_async_dns(&json!({})));
}
#[test]
fn stale_lifecycle_cleanup_only_targets_the_expected_native_owner() {
assert!(!stale_lifecycle_cleanup_is_noop(Some(7), Some(7)));
@@ -15057,7 +15078,7 @@ mod tests {
validate_torrent_metadata_network_policy(
b"d8:announce25:http://127.0.0.1/announce4:infod6:lengthi5e4:name4:test12:piece lengthi2e6:pieces20:01234567890123456789ee"
),
Err("SSRF blocked: Private/local IP not allowed".to_string())
Err("torrent metadata contains an invalid network destination".to_string())
);
assert_eq!(
validate_torrent_metadata_network_policy(
@@ -15103,16 +15124,6 @@ mod tests {
.await,
Err("Torrent metadata URLs must not contain credentials".to_string())
);
let proxy_error = super::fetch_remote_torrent_bytes(
"https://example.com/sample.torrent",
Some("socks5://127.0.0.1:1080"),
None,
None,
None,
)
.await
.expect_err("remote Torrent metadata must use the shared proxy policy");
assert!(proxy_error.contains("SOCKS"));
}
#[tokio::test]
@@ -18696,8 +18707,6 @@ pub fn run() {
});
let queue_manager_poll = Arc::clone(&queue_manager);
let queue_manager_capability = Arc::clone(&queue_manager);
app.manage(AppState {
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
storage_layout,
@@ -18871,6 +18880,10 @@ pub fn run() {
.arg("--download-result=hide")
.arg("--max-concurrent-downloads=9999")
.arg("--check-certificate=true")
// Firelink relies on Aria2's asynchronous
// resolver so a slow DNS answer cannot block
// the daemon's shared RPC/control loop.
.arg("--async-dns=true")
.arg(format!("--stop-with-process={}", std::process::id()));
apply_aria2_torrent_global_options(
@@ -18999,23 +19012,31 @@ pub fn run() {
match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await {
Ok(ver) => {
let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown");
let async_dns_supported = ver
.get("enabledFeatures")
.and_then(|features| features.as_array())
.map(|features| {
features.iter().any(|feature| {
feature.as_str() == Some("Async DNS")
})
})
.unwrap_or(false);
queue_manager_capability
.set_aria2_async_dns_supported(async_dns_supported);
let async_dns_supported =
aria2_supports_async_dns(&ver);
log::info!(
"aria2 daemon ready (version {}) on port {} (async DNS: {})",
v,
attempt_port,
async_dns_supported
);
if !async_dns_supported {
let error = "bundled aria2 does not support asynchronous DNS; network transfers are disabled".to_string();
log::error!("{}", error);
*guard.startup_error.lock().unwrap() =
Some(error);
// The route-safe Aria2 contract depends on
// asynchronous DNS. Do not leave a daemon
// running in a mode that can block its RPC
// loop behind a system resolver.
shutdown_aria2_daemon(app_handle_bg.clone())
.await;
aria2_port_clone.store(
0,
std::sync::atomic::Ordering::Relaxed,
);
return;
}
ready = true;
break;
}
+123 -17
View File
@@ -45,22 +45,31 @@ impl NetworkRoute {
/// Translate the route into Aria2's all-proxy option without changing the
/// target hostname. Aria2 accepts HTTP-family proxy endpoints for normal
/// transfers; SOCKS remains a yt-dlp-only route in Firelink.
/// transfers; reqwest and yt-dlp consumers may still retain SOCKS routes.
pub(crate) fn aria2_proxy_value(&self) -> Result<Option<String>, String> {
match self {
Self::Inherited => Ok(None),
Self::Direct => Ok(Some(String::new())),
Self::Proxy(value) => {
let is_socks = value.split_once("://").is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("socks")
|| scheme.to_ascii_lowercase().starts_with("socks")
});
let parsed = Url::parse(value).map_err(|error| {
crate::redact_sensitive_text(&format!("invalid Aria2 proxy URL: {error}"))
})?;
if parsed.host_str().is_none_or(str::is_empty) {
return Err("invalid Aria2 proxy URL: proxy must include a host".to_string());
}
let is_socks = parsed.scheme().eq_ignore_ascii_case("socks")
|| parsed.scheme().to_ascii_lowercase().starts_with("socks");
if is_socks {
return Err(
"SOCKS system proxies are not supported for normal file downloads because aria2 only accepts HTTP/HTTPS/FTP proxy URLs. Use an HTTP proxy endpoint for normal downloads, or use media downloads where yt-dlp supports SOCKS."
.to_string(),
);
}
if !matches!(parsed.scheme(), "http" | "https" | "ftp") {
return Err(
"Aria2 proxy must use an HTTP, HTTPS, or FTP proxy URL".to_string(),
);
}
Ok(Some(value.clone()))
}
}
@@ -116,15 +125,7 @@ pub(crate) fn validate_url(
}
}
let normalized_host = host.trim_matches(['[', ']']);
if is_local_hostname(normalized_host) {
return Err("SSRF blocked: Private/local IP not allowed".to_string());
}
if parse_literal_ip(normalized_host).is_some_and(is_blocked_network_address) {
return Err("SSRF blocked: Private/local IP not allowed".to_string());
}
Ok(())
validate_host(host)
}
pub(crate) fn parse_and_validate_url(
@@ -148,13 +149,71 @@ pub(crate) fn is_local_hostname(host: &str) -> bool {
|| normalized.ends_with(".local")
}
/// Validate a network hostname without asking the application to resolve it.
///
/// This is also used for hostname/port pairs embedded in Torrent metadata,
/// where no URL parser has normalized legacy numeric IPv4 spellings for us.
pub(crate) fn validate_host(host: &str) -> Result<(), String> {
if host.is_empty()
|| host
.chars()
.any(|character| character.is_control() || character.is_whitespace())
{
return Err("SSRF blocked: Invalid host".to_string());
}
let normalized_host = host.trim_end_matches('.');
let bracketed = normalized_host.starts_with('[') || normalized_host.ends_with(']');
let normalized_host = match (
normalized_host.starts_with('['),
normalized_host.ends_with(']'),
) {
(true, true) => &normalized_host[1..normalized_host.len() - 1],
(false, false) => normalized_host,
_ => return Err("SSRF blocked: Invalid host".to_string()),
};
if normalized_host.is_empty() {
return Err("SSRF blocked: Invalid host".to_string());
}
if bracketed && (!normalized_host.contains(':') || parse_literal_ip(normalized_host).is_none())
{
return Err("SSRF blocked: Invalid host".to_string());
}
if is_local_hostname(normalized_host)
|| parse_literal_ip(normalized_host).is_some_and(is_blocked_network_address)
{
return Err("SSRF blocked: Private/local IP not allowed".to_string());
}
if normalized_host.contains(':') && parse_literal_ip(normalized_host).is_none() {
return Err("SSRF blocked: Invalid host".to_string());
}
if !normalized_host.contains(':') && url::Host::parse(normalized_host).is_err() {
return Err("SSRF blocked: Invalid host".to_string());
}
Ok(())
}
fn parse_literal_ip(host: &str) -> Option<IpAddr> {
let host = host.trim_end_matches('.');
if let Ok(ip) = host.parse::<IpAddr>() {
return Some(ip);
}
host.split_once("%25")
.and_then(|(address, _zone)| address.parse::<IpAddr>().ok())
if let Some((address, _zone)) = host.split_once("%25") {
if let Ok(ip) = address.parse::<IpAddr>() {
return Some(ip);
}
}
// URL parsers commonly canonicalize legacy IPv4 literals such as 127.1,
// decimal IPv4, hexadecimal IPv4, and octal IPv4. Reuse that canonical
// parser for raw Torrent node hosts so those spellings cannot bypass the
// literal-target policy without performing DNS.
let candidate = if host.contains(':') {
format!("http://[{host}]/")
} else {
format!("http://{host}/")
};
let parsed = Url::parse(&candidate).ok()?;
parsed.host_str()?.parse::<IpAddr>().ok()
}
pub(crate) fn is_blocked_network_address(ip: IpAddr) -> bool {
@@ -164,7 +223,11 @@ pub(crate) fn is_blocked_network_address(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_link_local(),
IpAddr::V6(ipv6) => {
ipv6.to_ipv4()
// Check both IPv4-mapped and deprecated IPv4-compatible forms;
// either can encode a local IPv4 destination behind an IPv6
// literal.
ipv6.to_ipv4_mapped()
.or_else(|| ipv6.to_ipv4())
.is_some_and(|ipv4| is_blocked_network_address(ipv4.into()))
|| (ipv6.segments()[0] & 0xfe00) == 0xfc00
|| (ipv6.segments()[0] & 0xffc0) == 0xfe80
@@ -188,6 +251,7 @@ mod tests {
"http://169.254.10.2/file",
"http://[::1]/file",
"http://[::ffff:127.0.0.1]/file",
"http://[::ffff:169.254.169.254]/file",
"http://[fc00::1]/file",
"http://[fe80::1]/file",
"http://127.0.0.1./file",
@@ -242,10 +306,32 @@ mod tests {
),
Ok(())
);
assert_eq!(
validate("https://[2001:db8::1]/file", &["http", "https"]),
Ok(())
);
assert_eq!(
validate_host("2001:db8::1"),
Ok(()),
"public IPv6 literals must remain usable without DNS"
);
}
#[test]
fn raw_hosts_reject_legacy_literals_without_resolving_public_names() {
for host in ["127.1", "2130706433", "0x7f000001", "0177.0.0.1", "0"] {
assert_eq!(
validate_host(host),
Err("SSRF blocked: Private/local IP not allowed".to_string()),
"{host}"
);
}
assert!(validate_host("node-does-not-resolve.invalid").is_ok());
}
#[test]
fn route_mapping_preserves_direct_and_proxy_choices() {
crate::ensure_reqwest_crypto_provider();
assert_eq!(NetworkRoute::from_proxy(None), NetworkRoute::Inherited);
assert_eq!(NetworkRoute::from_proxy(Some("none")), NetworkRoute::Direct);
assert_eq!(NetworkRoute::from_proxy(Some(" ")), NetworkRoute::Direct);
@@ -274,6 +360,26 @@ mod tests {
.aria2_proxy_value()
.is_err()
);
assert!(NetworkRoute::from_proxy(Some("http://[invalid"))
.aria2_proxy_value()
.is_err());
assert!(NetworkRoute::from_proxy(Some("file:///tmp/proxy"))
.aria2_proxy_value()
.is_err());
assert!(
NetworkRoute::from_proxy(Some("socks5://proxy.example:1080"))
.configure_reqwest(reqwest::Client::builder())
.and_then(|builder| builder.build().map_err(|error| error.to_string()))
.is_ok(),
"reqwest-backed metadata must retain supported SOCKS routes"
);
}
#[test]
fn raw_hosts_reject_unbalanced_ipv6_brackets() {
assert!(validate_host("[2001:db8::1").is_err());
assert!(validate_host("2001:db8::1]").is_err());
assert!(validate_host("[download.example]").is_err());
}
#[test]
+32 -300
View File
@@ -5,8 +5,8 @@ use crate::ipc::{
};
use crate::power::PowerManager;
use crate::retry::{
aria2_error_code, backoff_and_emit, is_aria2_name_resolution_error,
is_transient_network_error, network_error_class, BackoffOutcome, MAX_RETRIES,
aria2_error_code, backoff_and_emit, is_transient_network_error, network_error_class,
BackoffOutcome, MAX_RETRIES,
};
use log;
use serde::Deserialize;
@@ -1002,28 +1002,13 @@ fn is_direct_magnet_payload(payload: &SpawnPayload) -> bool {
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:"))
}
fn proxy_uses_direct_network(proxy: Option<&str>) -> bool {
proxy.is_none_or(|proxy| {
let proxy = proxy.trim();
proxy.is_empty() || proxy.eq_ignore_ascii_case("none")
})
}
fn payload_uses_direct_network(payload: &SpawnPayload) -> bool {
proxy_uses_direct_network(payload.proxy.as_deref())
}
fn torrent_uses_direct_network(payload: &SpawnPayload) -> bool {
payload.is_torrent
&& !payload.torrent_verify_only
&& payload_uses_direct_network(payload)
}
fn aria2_effective_resolver_mode(payload: &SpawnPayload) -> &'static str {
if payload.aria2_resolver_mode == Aria2ResolverMode::System
&& payload_uses_direct_network(payload)
fn aria2_resolver_route_for_log(payload: &SpawnPayload) -> &'static str {
if payload
.proxy
.as_deref()
.is_some_and(|proxy| !proxy.trim().is_empty() && !proxy.eq_ignore_ascii_case("none"))
{
"system"
"configured"
} else {
"automatic"
}
@@ -1123,23 +1108,6 @@ enum SeedAdmissionOutcome {
/// Args mirroring start_download / start_media_download. Kept untyped-loose
/// (String/Option) to match the existing command signatures exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Aria2ResolverMode {
/// Use Aria2's normal asynchronous resolver configuration. This is the
/// bounded alternate mode for a direct transfer after the system route
/// cannot resolve the target.
Automatic,
/// Use the host operating system resolver for this direct transfer. This
/// is the initial mode so TUN/VPN DNS interception remains authoritative.
System,
}
impl Default for Aria2ResolverMode {
fn default() -> Self {
Self::Automatic
}
}
#[derive(Debug, Clone, Default)]
pub struct SpawnPayload {
pub url: String,
@@ -1160,9 +1128,6 @@ pub struct SpawnPayload {
pub retry_not_found_errors: bool,
pub adaptive_mirror_selection: bool,
pub proxy: Option<String>,
/// Runtime-only resolver selection. This is never part of an enqueue or
/// persisted download payload.
pub aria2_resolver_mode: Aria2ResolverMode,
pub format_selector: Option<String>,
pub cookie_source: Option<String>,
pub is_media: bool,
@@ -1365,11 +1330,6 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// cap as a degraded connection pool.
aria2_global_speed_limit: Arc<StdMutex<Option<String>>>,
/// Capability reported by aria2.getVersion. Keep the fallback disabled
/// until the daemon explicitly advertises Async DNS; an unknown
/// capability must not be treated as permission to change resolver mode.
aria2_async_dns_supported: AtomicBool,
/// 0-based transient-error strike counter per aria2 download id.
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
@@ -1466,7 +1426,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
aria2_dispatch_inflight: Mutex::new(HashMap::new()),
aria2_dispatch_notify: Notify::new(),
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
aria2_async_dns_supported: AtomicBool::new(false),
aria2_retry_strikes: Mutex::new(HashMap::new()),
aria2_retry_cancelled: Mutex::new(HashSet::new()),
aria2_retry_inflight: Mutex::new(HashMap::new()),
@@ -1488,19 +1447,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
Arc::clone(&self.power_manager)
}
pub fn set_aria2_async_dns_supported(&self, supported: bool) {
self.aria2_async_dns_supported
.store(supported, Ordering::Relaxed);
}
pub(crate) fn aria2_async_dns_supported(&self) -> bool {
self.aria2_async_dns_supported.load(Ordering::Relaxed)
}
fn aria2_alternate_resolver_available(&self) -> bool {
self.aria2_async_dns_supported()
}
/// Accept one lifecycle-fenced Aria2 status sample and return Firelink's
/// monotonic lifetime counters. Poller callers must already have checked
/// the mapping; the key and epoch checks here provide a second fence at
@@ -6062,13 +6008,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
*entry
};
let retry_action = aria2_retry_action(
&payload,
&error,
strike,
self.aria2_alternate_resolver_available(),
);
let resolver_fallback = retry_action == Aria2RetryAction::AlternateResolverFallback;
let retry_action = aria2_retry_action(&payload, &error, strike);
let requested_connections = self
.aria2_requested_connections(&id)
.await
@@ -6078,7 +6018,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
.await
.unwrap_or(requested_connections);
let action = match retry_action {
Aria2RetryAction::AlternateResolverFallback => "alternate_resolver_fallback",
Aria2RetryAction::OrdinaryRetry => "ordinary_retry",
Aria2RetryAction::Terminal => "terminal",
};
@@ -6091,16 +6030,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
mapping.epoch,
strike,
action,
aria2_effective_resolver_mode(&payload),
aria2_resolver_route_for_log(&payload),
network_error_class(&error),
error_code,
requested_connections,
effective_connections
);
// Switching resolver strategy is a bounded transfer repair, not an
// ordinary retry. It must still run when the user configured zero
// automatic retries, while every later failure follows the normal
// retry budget and never switches back to the first strategy.
if retry_action == Aria2RetryAction::Terminal {
self.apply_completion_locked_with_progress(
&id,
@@ -6145,14 +6080,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
.insert(id.clone(), payload.clone());
}
if resolver_fallback {
payload.aria2_resolver_mode = Aria2ResolverMode::Automatic;
self.aria2_payloads
.lock()
.await
.insert(id.clone(), payload.clone());
}
let this = Arc::clone(self);
let id_for_task = id.clone();
let error_for_emit = error.clone();
@@ -6174,11 +6101,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
};
let outcome = backoff_and_emit(strike, error_for_emit, retry_cancel, |reason| {
use tauri::Emitter;
let event = if resolver_fallback {
DownloadStateEvent::retrying_with_resolver_fallback(&id_for_task, reason)
} else {
DownloadStateEvent::retrying(&id_for_task, reason)
};
let event = DownloadStateEvent::retrying(&id_for_task, reason);
let _ = this.app_handle.emit("download-state", event);
})
.await;
@@ -6262,12 +6185,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
.await;
return;
}
if !resolver_fallback {
this.aria2_retry_strikes
.lock()
.await
.insert(id_for_task.clone(), strike + 1);
}
this.aria2_retry_strikes
.lock()
.await
.insert(id_for_task.clone(), strike + 1);
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
@@ -6634,33 +6555,13 @@ fn is_retryable_aria2_error_for_payload(payload: &SpawnPayload, error: &str) ->
&& is_aria2_low_speed_error(error))
}
fn should_use_aria2_alternate_resolver_fallback(
payload: &SpawnPayload,
error: &str,
async_dns_supported: bool,
) -> bool {
async_dns_supported
&& payload.aria2_resolver_mode == Aria2ResolverMode::System
&& payload_uses_direct_network(payload)
&& is_aria2_name_resolution_error(error)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Aria2RetryAction {
AlternateResolverFallback,
OrdinaryRetry,
Terminal,
}
fn aria2_retry_action(
payload: &SpawnPayload,
error: &str,
strike: usize,
async_dns_supported: bool,
) -> Aria2RetryAction {
if should_use_aria2_alternate_resolver_fallback(payload, error, async_dns_supported) {
return Aria2RetryAction::AlternateResolverFallback;
}
fn aria2_retry_action(payload: &SpawnPayload, error: &str, strike: usize) -> Aria2RetryAction {
if is_retryable_aria2_error_for_payload(payload, error)
&& strike < automatic_retry_limit(payload.max_tries)
{
@@ -7407,17 +7308,6 @@ fn apply_aria2_normal_reliability_options(
Ok(())
}
fn apply_aria2_resolver_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
) {
if payload.aria2_resolver_mode == Aria2ResolverMode::System
&& payload_uses_direct_network(payload)
{
options.insert("async-dns".to_string(), serde_json::json!("false"));
}
}
fn should_apply_aria2_connection_options(payload: &SpawnPayload) -> bool {
!payload.is_torrent
}
@@ -8215,18 +8105,6 @@ fn apply_aria2_torrent_options(
options.insert("bt-metadata-only".to_string(), serde_json::json!("false"));
options.insert("bt-save-metadata".to_string(), serde_json::json!("false"));
options.insert("follow-torrent".to_string(), serde_json::json!("false"));
if torrent_uses_direct_network(payload)
&& payload.aria2_resolver_mode == Aria2ResolverMode::System
{
// Aria2's async resolver is independent of the system resolver. TUN
// clients commonly install DNS interception/routing at the system
// resolver boundary, so direct Torrent discovery must use the same
// resolver path as the working HTTP/media clients. Do not override an
// explicitly configured proxy route, where local DNS could leak or
// bypass the proxy's name-resolution policy.
options.insert("async-dns".to_string(), serde_json::json!("false"));
}
let seed_time = payload
.torrent_seed_time
.map(|value| format_aria2_torrent_number(value, "seed time"))
@@ -8574,7 +8452,6 @@ impl SidecarSpawner for ProductionSpawner {
if let Some(prox) = proxy_value {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
apply_aria2_resolver_options(&mut options, payload);
let retry_strike = state.queue_manager.aria2_retry_strike(id).await;
let transfer_host = transfer_uris
.first()
@@ -8590,7 +8467,7 @@ impl SidecarSpawner for ProductionSpawner {
requested_connections,
transfer_connections,
transfer_uris.len(),
aria2_effective_resolver_mode(payload),
aria2_resolver_route_for_log(payload),
proxy_route_for_log(payload.proxy.as_deref()),
admission_started.elapsed().as_millis()
);
@@ -9258,11 +9135,6 @@ impl EnqueueItem {
let mut item = self;
item.strip_torrent_credentials();
let media = item.is_media.unwrap_or(false);
let aria2_resolver_mode = if proxy_uses_direct_network(item.proxy.as_deref()) {
Aria2ResolverMode::System
} else {
Aria2ResolverMode::Automatic
};
let kind = if media {
TaskKind::Media
} else {
@@ -9299,7 +9171,6 @@ impl EnqueueItem {
retry_not_found_errors: item.retry_not_found_errors.unwrap_or(false),
adaptive_mirror_selection: item.adaptive_mirror_selection.unwrap_or(true),
proxy: item.proxy,
aria2_resolver_mode,
format_selector: item.format_selector,
cookie_source: item.cookie_source,
is_media: media,
@@ -9898,59 +9769,6 @@ mod tests {
.is_err());
}
#[test]
fn aria2_system_resolver_mode_is_per_transfer_and_preserves_automatic_default() {
let mut automatic = serde_json::Map::new();
apply_aria2_resolver_options(
&mut automatic,
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::Automatic,
..Default::default()
},
);
assert!(!automatic.contains_key("async-dns"));
let mut system = serde_json::Map::new();
apply_aria2_resolver_options(
&mut system,
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
..Default::default()
},
);
assert_eq!(system.get("async-dns"), Some(&serde_json::json!("false")));
}
#[test]
fn enqueue_route_selects_system_resolver_for_direct_transfers() {
let direct: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "direct",
"queue_id": "main",
"url": "https://example.com/file",
"destination": "/tmp",
"filename": "file"
}))
.unwrap();
assert_eq!(
direct.into_task().payload.aria2_resolver_mode,
Aria2ResolverMode::System
);
let proxied: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "proxied",
"queue_id": "main",
"url": "https://example.com/file",
"destination": "/tmp",
"filename": "file",
"proxy": "http://proxy.example:8080"
}))
.unwrap();
assert_eq!(
proxied.into_task().payload.aria2_resolver_mode,
Aria2ResolverMode::Automatic
);
}
#[test]
fn resolver_state_events_are_typed_and_redacted() {
let event = DownloadStateEvent::failed(
@@ -10198,7 +10016,6 @@ mod tests {
&mut options,
&SpawnPayload {
is_torrent: true,
aria2_resolver_mode: Aria2ResolverMode::System,
..Default::default()
},
)
@@ -10216,10 +10033,7 @@ mod tests {
options.get("follow-torrent"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("async-dns"),
Some(&serde_json::json!("false"))
);
assert!(!options.contains_key("async-dns"));
let mut proxied_options = serde_json::Map::new();
apply_aria2_torrent_options(
@@ -10232,54 +10046,6 @@ mod tests {
)
.unwrap();
assert!(!proxied_options.contains_key("async-dns"));
assert_eq!(
aria2_effective_resolver_mode(&SpawnPayload {
is_torrent: true,
aria2_resolver_mode: Aria2ResolverMode::System,
..Default::default()
}),
"system"
);
assert_eq!(
aria2_effective_resolver_mode(&SpawnPayload {
is_torrent: true,
proxy: Some("http://127.0.0.1:8080".to_string()),
..Default::default()
}),
"automatic"
);
}
#[test]
fn resolver_options_never_force_local_dns_for_configured_proxy_routes() {
let mut direct = serde_json::Map::new();
apply_aria2_resolver_options(
&mut direct,
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
..Default::default()
},
);
assert_eq!(direct.get("async-dns"), Some(&serde_json::json!("false")));
let mut proxied = serde_json::Map::new();
apply_aria2_resolver_options(
&mut proxied,
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
proxy: Some("http://proxy.example:8080".to_string()),
..Default::default()
},
);
assert!(!proxied.contains_key("async-dns"));
assert_eq!(
aria2_effective_resolver_mode(&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
proxy: Some("http://proxy.example:8080".to_string()),
..Default::default()
}),
"automatic"
);
}
#[test]
@@ -12319,7 +12085,7 @@ mod tests {
let low_speed = "aria2 error code 5: Download speed is too slow";
assert_eq!(
aria2_retry_action(&SpawnPayload::default(), not_found, 0, false),
aria2_retry_action(&SpawnPayload::default(), not_found, 0),
Aria2RetryAction::Terminal
);
assert_eq!(
@@ -12331,7 +12097,6 @@ mod tests {
},
not_found,
0,
false,
),
Aria2RetryAction::OrdinaryRetry
);
@@ -12344,12 +12109,11 @@ mod tests {
},
not_found,
1,
false,
),
Aria2RetryAction::Terminal
);
assert_eq!(
aria2_retry_action(&SpawnPayload::default(), low_speed, 0, false),
aria2_retry_action(&SpawnPayload::default(), low_speed, 0),
Aria2RetryAction::Terminal
);
assert_eq!(
@@ -12361,7 +12125,6 @@ mod tests {
},
low_speed,
0,
false,
),
Aria2RetryAction::OrdinaryRetry
);
@@ -12376,68 +12139,37 @@ mod tests {
},
low_speed,
0,
false,
),
Aria2RetryAction::Terminal
);
}
#[test]
fn aria2_name_resolution_error_is_retryable_for_resolver_recovery() {
fn aria2_name_resolution_error_stays_on_bounded_retry_path() {
let error = "aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers.";
assert!(is_retryable_aria2_error(error));
let direct_initial = SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
..Default::default()
};
assert!(should_use_aria2_alternate_resolver_fallback(
&direct_initial,
error,
true,
));
assert_eq!(
aria2_retry_action(&direct_initial, error, 0, true),
Aria2RetryAction::AlternateResolverFallback
);
assert!(!should_use_aria2_alternate_resolver_fallback(
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::Automatic,
..Default::default()
},
error,
true,
));
assert!(!should_use_aria2_alternate_resolver_fallback(
&SpawnPayload::default(),
error,
false,
));
assert_eq!(
aria2_retry_action(
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
max_tries: Some(0),
..Default::default()
},
error,
0,
true,
),
Aria2RetryAction::AlternateResolverFallback
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::Automatic,
max_tries: Some(1),
..Default::default()
},
error,
0,
true,
),
Aria2RetryAction::OrdinaryRetry
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
max_tries: Some(0),
..Default::default()
},
error,
0,
),
Aria2RetryAction::Terminal
);
}
#[test]
+112 -5
View File
@@ -9,6 +9,7 @@ use tokio::io::AsyncReadExt;
use crate::ipc::{TorrentFile, TorrentMetadata};
pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024;
const MAX_TORRENT_DHT_NODES: usize = 256;
#[derive(Debug, Clone)]
pub struct ParsedTorrent {
@@ -417,6 +418,12 @@ fn bounded_uri(value: &str, schemes: &[&str]) -> Option<String> {
{
return None;
}
crate::network::validate_url(
&parsed,
schemes,
crate::network::CredentialPolicy::Allow,
)
.ok()?;
Some(parsed.to_string())
}
@@ -487,6 +494,40 @@ fn torrent_tracker_metadata_is_safe(root: &BTreeMap<Vec<u8>, BencodeValue>) -> b
true
}
fn torrent_nodes_metadata_is_safe(root: &BTreeMap<Vec<u8>, BencodeValue>) -> bool {
let Some(nodes) = root.get(b"nodes".as_slice()) else {
return true;
};
let BencodeValue::List(nodes) = nodes else {
return false;
};
if nodes.len() > MAX_TORRENT_DHT_NODES {
return false;
}
nodes.iter().all(|node| {
let BencodeValue::List(parts) = node else {
return false;
};
if parts.len() != 2 {
return false;
}
let BencodeValue::Bytes(host) = &parts[0] else {
return false;
};
if host.is_empty() || host.len() > crate::queue::MAX_TORRENT_NETWORK_VALUE_LENGTH {
return false;
}
let Ok(host) = std::str::from_utf8(host) else {
return false;
};
if crate::network::validate_host(host).is_err() {
return false;
}
matches!(&parts[1], BencodeValue::Integer(port) if (1..=u16::MAX as i64).contains(port))
})
}
/// Validate the tracker fields before handing original metainfo to Aria2.
/// `torrent_details_from_bytes` intentionally omits malformed tracker values
/// from its display projection, but Aria2 consumes the original bencode and
@@ -501,10 +542,10 @@ pub fn validate_torrent_tracker_metadata(bytes: &[u8]) -> Result<(), String> {
BencodeValue::Dict(value) => value,
_ => return Err("torrent root is not a dictionary".to_string()),
};
if torrent_tracker_metadata_is_safe(&root) {
if torrent_tracker_metadata_is_safe(&root) && torrent_nodes_metadata_is_safe(&root) {
Ok(())
} else {
Err("torrent metadata contains an invalid tracker URI".to_string())
Err("torrent metadata contains an invalid network destination".to_string())
}
}
@@ -528,8 +569,25 @@ fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>,
let value = String::from_utf8(bytes.clone())
.map_err(|_| "torrent url-list contains invalid UTF-8".to_string())?;
let value = value.trim();
let uri = bounded_uri(value, &["http", "https"])
.ok_or_else(|| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?;
if value.len() > 2_048 || value.chars().any(char::is_control) {
return Err("torrent url-list contains an invalid HTTP(S) web seed".to_string());
}
let parsed = url::Url::parse(value)
.map_err(|_| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none_or(str::is_empty)
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
{
return Err("torrent url-list contains an invalid HTTP(S) web seed".to_string());
}
crate::network::validate_url(
&parsed,
&["http", "https"],
crate::network::CredentialPolicy::Allow,
)?;
let uri = parsed.to_string();
if !normalized.contains(&uri) {
normalized.push(uri);
}
@@ -1367,6 +1425,21 @@ pub async fn remove_managed_torrent<R: tauri::Runtime>(
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn torrent_with_root_value(key: &[u8], value: BencodeValue) -> Vec<u8> {
let info = BencodeValue::Dict(BTreeMap::from([
(b"length".to_vec(), BencodeValue::Integer(5)),
(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec())),
]));
let root = BencodeValue::Dict(BTreeMap::from([
(b"info".to_vec(), info),
(key.to_vec(), value),
]));
let mut bytes = Vec::new();
encode(&root, &mut bytes);
bytes
}
#[test]
fn parses_single_file_torrent_and_hashes_info_dictionary() {
@@ -1560,7 +1633,7 @@ mod tests {
assert!(validate_torrent_tracker_metadata(
b"d8:announce25:http://127.0.0.1/announce4:infod6:lengthi5e4:name4:testee"
)
.is_ok());
.is_err());
assert!(validate_torrent_tracker_metadata(
b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
)
@@ -1571,6 +1644,40 @@ mod tests {
.is_err());
}
#[test]
fn torrent_network_metadata_rejects_local_nodes_and_seeds_without_dns() {
for host in [
b"127.1".as_slice(),
b"2130706433".as_slice(),
b"[::ffff:127.0.0.1]".as_slice(),
b"localhost".as_slice(),
] {
let bytes = torrent_with_root_value(
b"nodes",
BencodeValue::List(vec![BencodeValue::List(vec![
BencodeValue::Bytes(host.to_vec()),
BencodeValue::Integer(6881),
])]),
);
assert!(
validate_torrent_tracker_metadata(&bytes).is_err(),
"{host:?}"
);
}
let public_node = torrent_with_root_value(
b"nodes",
BencodeValue::List(vec![BencodeValue::List(vec![
BencodeValue::Bytes(b"node-does-not-resolve.invalid".to_vec()),
BencodeValue::Integer(6881),
])]),
);
assert!(validate_torrent_tracker_metadata(&public_node).is_ok());
let local_seed = b"d4:infod6:lengthi5e4:name4:teste8:url-list17:http://127.0.0.1/ee";
assert!(parse_torrent_bytes(local_seed).is_err());
}
#[test]
fn canonical_cache_temporary_names_are_strictly_recognized() {
assert!(is_canonical_torrent_temp_file(
File diff suppressed because it is too large Load Diff
+11 -41
View File
@@ -1,6 +1,6 @@
use firelink_lib::queue::{
Aria2RecreateOutcome, Aria2RefreshOutcome, Aria2ResolverMode, QueueManager, QueuedTask,
SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -27,15 +27,7 @@ struct CountingSpawner {
torrent_peer_options_release: tokio::sync::Notify,
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
add_resolver_modes: std::sync::Mutex<Vec<Aria2ResolverMode>>,
add_transfer_context: std::sync::Mutex<
Vec<(
Aria2ResolverMode,
Option<String>,
Option<String>,
Option<i32>,
)>,
>,
add_transfer_context: std::sync::Mutex<Vec<(Option<String>, Option<String>, Option<i32>)>>,
block_speed_limit: std::sync::atomic::AtomicBool,
speed_limit_started: tokio::sync::Notify,
speed_limit_release: tokio::sync::Notify,
@@ -220,7 +212,6 @@ impl CountingSpawner {
torrent_peer_options_release: tokio::sync::Notify::new(),
add_speed_limits: std::sync::Mutex::new(Vec::new()),
add_peer_options: std::sync::Mutex::new(Vec::new()),
add_resolver_modes: std::sync::Mutex::new(Vec::new()),
add_transfer_context: std::sync::Mutex::new(Vec::new()),
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
speed_limit_started: tokio::sync::Notify::new(),
@@ -305,12 +296,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
payload.torrent_max_peers,
payload.torrent_peer_speed_limit.clone(),
));
self.add_resolver_modes
.lock()
.unwrap()
.push(payload.aria2_resolver_mode);
self.add_transfer_context.lock().unwrap().push((
payload.aria2_resolver_mode,
payload.headers.clone(),
payload.proxy.clone(),
payload.connections,
@@ -2297,15 +2283,13 @@ async fn transient_aria2_error_reissues_after_backoff() {
}
#[tokio::test]
async fn resolver_failure_uses_one_system_fallback_without_retry_budget() {
async fn resolver_failure_retries_without_entering_blocking_system_dns() {
use firelink_lib::queue::PendingOutcome;
let (mgr, spawner) = make_manager(1);
let manager = Arc::new(mgr);
manager.set_aria2_async_dns_supported(true);
let mut task = aria2_task("resolver-fallback");
task.payload.aria2_resolver_mode = Aria2ResolverMode::System;
task.payload.max_tries = Some(0);
task.payload.max_tries = Some(1);
task.payload.headers = Some("X-Test: retained".to_string());
manager.push(task).await.unwrap();
@@ -2341,33 +2325,19 @@ async fn resolver_failure_uses_one_system_fallback_without_retry_budget() {
}
})
.await
.expect("resolver failure should re-add once with Aria2's alternate resolver");
assert_eq!(
*spawner.add_resolver_modes.lock().unwrap(),
vec![Aria2ResolverMode::System, Aria2ResolverMode::Automatic]
);
.expect("resolver failure should re-add once on the non-blocking resolver");
assert_eq!(
*spawner.add_transfer_context.lock().unwrap(),
vec![
(
Aria2ResolverMode::System,
Some("X-Test: retained".to_string()),
None,
None,
),
(
Aria2ResolverMode::Automatic,
Some("X-Test: retained".to_string()),
None,
None,
),
(Some("X-Test: retained".to_string()), None, None),
(Some("X-Test: retained".to_string()), None, None),
]
);
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
// A second resolver failure is now on the alternate mode. With
// max_tries=0 it must terminate instead of switching back or consuming
// another add.
// The configured retry budget is exhausted after one non-blocking retry.
// A repeated DNS error must terminate without entering system DNS or
// scheduling another add.
manager
.handle_aria2_event(
"gid-2",