diff --git a/scripts/aria2-route-contract.js b/scripts/aria2-route-contract.js index 28dca5e..c5eeb67 100644 --- a/scripts/aria2-route-contract.js +++ b/scripts/aria2-route-contract.js @@ -4,6 +4,17 @@ export const ARIA2_NETWORK_TARGET_POLICY = 'firelink-v1'; export const ARIA2_NETWORK_TARGET_POLICY_DIGEST = 'sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705'; +// The standard Aria2 option is the production default. It keeps hostname +// resolution on the OS/TUN route and is accepted by stock and patched builds. +// There is intentionally no daemon-wide flag here: async-dns=false is stamped +// per transfer after the caller has selected the compatible route. +export const ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS = Object.freeze([]); +export const ARIA2_SYSTEM_RESOLVER_OPTIONS = Object.freeze({ + 'async-dns': 'false', +}); + +// Firelink-patched Aria2 exposes these options for the bounded alternate +// magnet-probe attempt. They must never be sent to an unverified binary. export const ARIA2_ROUTE_DAEMON_ARGS = Object.freeze([ '--async-dns=true', `--dns-resolver=${ARIA2_DNS_RESOLVER}`, @@ -17,12 +28,18 @@ export const ARIA2_ROUTE_OPTIONS = Object.freeze({ }); // Local HTTP servers are used only by engine smoke tests. Product transfers -// never apply this override; Firelink keeps the literal-target policy enabled. +// never apply the fixture exception; Firelink's own admission policy still +// rejects literal local targets before Aria2 is contacted. export const ARIA2_LOCAL_FIXTURE_OPTIONS = Object.freeze({ - ...ARIA2_ROUTE_OPTIONS, - 'network-target-policy': 'none', + ...ARIA2_SYSTEM_RESOLVER_OPTIONS, }); +export function assertAria2Baseline(version) { + if (typeof version?.version !== 'string' || version.version.trim() === '') { + throw new Error(`aria2 returned an invalid baseline version response: ${JSON.stringify(version)}`); + } +} + export function assertAria2RouteCapabilities(version) { if (!Array.isArray(version?.enabledFeatures) || !version.enabledFeatures.includes('Async DNS')) { @@ -30,7 +47,6 @@ export function assertAria2RouteCapabilities(version) { } const expected = { firelinkRevision: ARIA2_FIRELINK_REVISION, - firelinkDnsResolver: ARIA2_DNS_RESOLVER, firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST, }; for (const [field, value] of Object.entries(expected)) { @@ -38,12 +54,39 @@ export function assertAria2RouteCapabilities(version) { throw new Error(`aria2 route capability mismatch for ${field}: ${JSON.stringify(version)}`); } } + if (!Array.isArray(version.firelinkDnsResolvers) + || !version.firelinkDnsResolvers.includes(ARIA2_DNS_RESOLVER)) { + throw new Error(`aria2 does not advertise the Firelink native resolver: ${JSON.stringify(version)}`); + } if (!Array.isArray(version.firelinkNetworkTargetPolicies) || !version.firelinkNetworkTargetPolicies.includes(ARIA2_NETWORK_TARGET_POLICY)) { throw new Error(`aria2 does not advertise the Firelink target policy: ${JSON.stringify(version)}`); } } +export function hasAria2RouteCapabilities(version) { + try { + assertAria2RouteCapabilities(version); + return true; + } catch { + return false; + } +} + +export function assertAria2SystemResolverOptions(options, context = 'aria2 transfer') { + if (options?.['async-dns'] !== 'false') { + throw new Error(`${context} did not retain async-dns=false: ${JSON.stringify(options)}`); + } + if (Object.hasOwn(options || {}, 'dns-resolver') + && options['dns-resolver'] !== ARIA2_DNS_RESOLVER) { + throw new Error(`${context} retained an unknown resolver mode: ${JSON.stringify(options)}`); + } + if (Object.hasOwn(options || {}, 'network-target-policy') + && options['network-target-policy'] !== 'none') { + throw new Error(`${context} retained an active target policy: ${JSON.stringify(options)}`); + } +} + export function assertAria2RouteContract(version) { assertAria2RouteCapabilities(version); const expected = { diff --git a/scripts/aria2-route-contract.node-test.js b/scripts/aria2-route-contract.node-test.js index d1f7f6e..807f121 100644 --- a/scripts/aria2-route-contract.node-test.js +++ b/scripts/aria2-route-contract.node-test.js @@ -5,6 +5,7 @@ import { ARIA2_FIRELINK_REVISION, ARIA2_NETWORK_TARGET_POLICY, ARIA2_NETWORK_TARGET_POLICY_DIGEST, + assertAria2SystemResolverOptions, assertAria2RouteCapabilities, assertAria2RouteContract, assertAria2RouteSource, @@ -14,6 +15,7 @@ const secureVersion = { enabledFeatures: ['Async DNS'], firelinkRevision: ARIA2_FIRELINK_REVISION, firelinkDnsResolver: ARIA2_DNS_RESOLVER, + firelinkDnsResolvers: [ARIA2_DNS_RESOLVER], firelinkNetworkTargetPolicies: ['none', ARIA2_NETWORK_TARGET_POLICY], firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY, firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST, @@ -62,3 +64,20 @@ test('route capabilities are distinct from the active local-fixture policy', () /route contract mismatch for firelinkNetworkTargetPolicy/, ); }); + +test('system resolver options cannot retain the custom target policy', () => { + assert.doesNotThrow(() => assertAria2SystemResolverOptions({ + 'async-dns': 'false', + })); + assert.doesNotThrow(() => assertAria2SystemResolverOptions({ + 'async-dns': 'false', + 'network-target-policy': 'none', + })); + assert.throws( + () => assertAria2SystemResolverOptions({ + 'async-dns': 'false', + 'network-target-policy': ARIA2_NETWORK_TARGET_POLICY, + }), + /active target policy/, + ); +}); diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index 46d172d..774c4d1 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -40,15 +40,13 @@ if (!targetSources) { process.exit(1); } -try { - // Firelink passes route-contract options to every production Aria2 daemon. - // A stock archive would either reject those options or silently omit the - // literal-target policy on non-macOS targets, so fail before downloading or - // staging an unusable payload. - assertAria2RouteSource(targetSources.aria2c, target); -} catch (error) { - console.error(error.message); - process.exit(1); +if (targetSources.aria2c?.firelinkRouteContract) { + try { + assertAria2RouteSource(targetSources.aria2c, target); + } catch (error) { + console.error(error.message); + process.exit(1); + } } const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target); diff --git a/scripts/smoke-aria2-resolver.js b/scripts/smoke-aria2-resolver.js index 71b2bf7..b5a95c1 100644 --- a/scripts/smoke-aria2-resolver.js +++ b/scripts/smoke-aria2-resolver.js @@ -9,10 +9,13 @@ import path from 'node:path'; import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { - ARIA2_ROUTE_DAEMON_ARGS, + ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, + ARIA2_SYSTEM_RESOLVER_OPTIONS, ARIA2_ROUTE_OPTIONS, - assertAria2RouteContract, + assertAria2Baseline, assertAria2RouteOptions, + assertAria2SystemResolverOptions, + hasAria2RouteCapabilities, } from './aria2-route-contract.js'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -201,7 +204,7 @@ const child = spawn(binaryPath, [ `--dir=${tempRoot}`, '--file-allocation=none', '--enable-dht=false', - ...ARIA2_ROUTE_DAEMON_ARGS, + ...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, '--console-log-level=error', '--quiet=true', ], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }); @@ -210,18 +213,26 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); }); try { const version = await waitForRpc(rpcPort, secret); - assertAria2RouteContract(version); - console.log(`[INFO] aria2 ${version.version || 'unknown'}; Firelink route contract verified`); + assertAria2Baseline(version); + const routeCapabilitiesAvailable = hasAria2RouteCapabilities(version); + if (routeCapabilitiesAvailable) { + // The fixture server is intentionally loopback. Disable only the custom + // target policy for this smoke daemon after capabilities are attested. + await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]); + console.log(`[INFO] aria2 ${version.version || 'unknown'}; optional Firelink route capabilities available`); + } else { + console.log(`[INFO] aria2 ${version.version || 'unknown'}; using stock system-resolver capabilities`); + } + const systemFixtureOptions = routeCapabilitiesAvailable + ? { ...ARIA2_SYSTEM_RESOLVER_OPTIONS, 'network-target-policy': 'none' } + : { ...ARIA2_SYSTEM_RESOLVER_OPTIONS }; const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], { - ...ARIA2_ROUTE_OPTIONS, - 'network-target-policy': 'none', + ...systemFixtureOptions, out: 'resolver-normal.bin', }]); const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]); - if (uriOptions['async-dns'] !== 'true' || uriOptions['dns-resolver'] !== 'native-async') { - throw new Error(`direct fixture transfer did not retain native asynchronous DNS: ${JSON.stringify(uriOptions)}`); - } + assertAria2SystemResolverOptions(uriOptions, 'direct aria2.addUri'); const torrent = bencode({ info: { @@ -232,16 +243,16 @@ try { }, }).toString('base64'); const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], { - ...ARIA2_ROUTE_OPTIONS, + ...systemFixtureOptions, dir: tempRoot, }]); const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]); - assertAria2RouteOptions(torrentOptions, 'direct aria2.addTorrent'); + assertAria2SystemResolverOptions(torrentOptions, 'direct aria2.addTorrent'); const proxyRoute = 'http://127.0.0.1:9'; const normalizedProxyRoute = new URL(proxyRoute).toString(); const proxiedUriResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/file'], { - ...ARIA2_ROUTE_OPTIONS, + ...systemFixtureOptions, 'all-proxy': proxyRoute, pause: 'true', out: 'resolver-proxied.bin', @@ -250,10 +261,10 @@ try { if (proxiedUriOptions['all-proxy'] !== normalizedProxyRoute) { throw new Error(`aria2.addUri did not retain the configured proxy route: ${JSON.stringify(proxiedUriOptions)}`); } - assertAria2RouteOptions(proxiedUriOptions, 'proxied aria2.addUri'); + assertAria2SystemResolverOptions(proxiedUriOptions, 'proxied aria2.addUri'); const proxiedTorrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], { - ...ARIA2_ROUTE_OPTIONS, + ...systemFixtureOptions, 'all-proxy': proxyRoute, pause: 'true', dir: tempRoot, @@ -262,11 +273,22 @@ try { if (proxiedTorrentOptions['all-proxy'] !== normalizedProxyRoute) { throw new Error(`aria2.addTorrent did not retain the configured proxy route: ${JSON.stringify(proxiedTorrentOptions)}`); } - assertAria2RouteOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent'); + assertAria2SystemResolverOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent'); + + if (routeCapabilitiesAvailable) { + const alternateResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/alternate'], { + ...ARIA2_ROUTE_OPTIONS, + pause: 'true', + out: 'resolver-alternate.bin', + }]); + const alternateOptions = await rpc(rpcPort, secret, 'aria2.getOption', [alternateResult]); + assertAria2RouteOptions(alternateOptions, 'alternate aria2.addUri'); + await forceRemoveIfPresent(rpcPort, secret, alternateResult); + } await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult); await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult); - console.log('[PASS] Aria2 kept asynchronous DNS for fresh direct and proxied normal/Torrent transfers'); + console.log('[PASS] Aria2 preserved system route options for direct/proxied normal/Torrent transfers'); } catch (error) { const detail = stderr.trim(); throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`); diff --git a/scripts/smoke-aria2-transfers.js b/scripts/smoke-aria2-transfers.js index 971f26a..941b3cd 100644 --- a/scripts/smoke-aria2-transfers.js +++ b/scripts/smoke-aria2-transfers.js @@ -10,8 +10,9 @@ import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { ARIA2_LOCAL_FIXTURE_OPTIONS, - ARIA2_ROUTE_DAEMON_ARGS, - assertAria2RouteContract, + ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, + assertAria2Baseline, + hasAria2RouteCapabilities, } from './aria2-route-contract.js'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -331,7 +332,7 @@ const child = spawn(binaryPath, [ '--enable-dht=false', '--console-log-level=error', '--quiet=true', - ...ARIA2_ROUTE_DAEMON_ARGS, + ...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, `--server-stat-if=${serverStatPath}`, `--server-stat-of=${serverStatPath}`, ], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }); @@ -340,8 +341,13 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); }); try { const version = await waitForRpc(rpcPort, secret); - assertAria2RouteContract(version); - console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke`); + assertAria2Baseline(version); + if (hasAria2RouteCapabilities(version)) { + // The fixture server is intentionally loopback. Disable only the custom + // target policy after the optional capabilities have been attested. + await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]); + } + console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke (system resolver)`); const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], { ...ARIA2_LOCAL_FIXTURE_OPTIONS, diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index 9eb6793..6e33e78 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -10,7 +10,9 @@ import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { ARIA2_DNS_RESOLVER, - assertAria2RouteCapabilities, + ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, + assertAria2Baseline, + hasAria2RouteCapabilities, } from './aria2-route-contract.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -73,10 +75,10 @@ let signalTerminationRequested = false; class DaemonExitedError extends Error {} function assertFixtureRouteOptions(options, context) { - if (options['async-dns'] !== 'true' - || options['dns-resolver'] !== ARIA2_DNS_RESOLVER - || options['network-target-policy'] !== 'none') { - throw new Error(`${context} did not retain the local-fixture resolver contract: ${JSON.stringify(options)}`); + if (options['async-dns'] !== 'false' + || (Object.hasOwn(options, 'dns-resolver') + && options['dns-resolver'] !== ARIA2_DNS_RESOLVER)) { + throw new Error(`${context} did not retain the system resolver contract: ${JSON.stringify(options)}`); } } @@ -320,9 +322,7 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [ '--enable-dht=false', '--enable-peer-exchange=false', '--bt-enable-lpd=false', - '--async-dns=true', - `--dns-resolver=${ARIA2_DNS_RESOLVER}`, - '--network-target-policy=none', + ...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, '--console-log-level=error', '--quiet=true', ...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []), @@ -352,7 +352,20 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [ return false; } }, 10000); - assertAria2RouteCapabilities(version); + assertAria2Baseline(version); + if (Array.isArray(version.enabledFeatures) && version.enabledFeatures.includes('Async DNS')) { + // Use the standard system resolver for every fixture transfer. This + // also covers fixture additions that intentionally omit per-transfer + // route fields; product additions still stamp async-dns=false on + // every request. + await rpc(selectedRpcPort, secret, 'aria2.changeGlobalOption', [{ 'async-dns': 'false' }]); + } + if (hasAria2RouteCapabilities(version)) { + // The smoke fixtures intentionally use loopback tracker/peer routes. + // Disable only the custom target policy after capabilities are + // attested; stock Aria2 has no such option to send. + await rpc(selectedRpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]); + } return daemon; } catch (error) { lastError = error; @@ -875,7 +888,7 @@ async function main() { assertFixtureRouteOptions(proxiedProbeOptions, 'proxied magnet metadata probe'); 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 probes kept asynchronous DNS'); + console.log('[OK] metadata probe was removed after resolution; direct and proxied probes kept system resolver options'); const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ trackerlessTorrentBytes.toString('base64'), diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js index 6591a40..30dbc20 100644 --- a/scripts/stage-engines.js +++ b/scripts/stage-engines.js @@ -46,11 +46,13 @@ if (!source) { } if (targetLock) { - try { - assertAria2RouteSource(targetLock.engines?.aria2c, target); - } catch (error) { - console.error(error.message); - process.exit(1); + if (targetLock.engines?.aria2c?.firelinkRouteContract) { + try { + assertAria2RouteSource(targetLock.engines.aria2c, target); + } catch (error) { + console.error(error.message); + process.exit(1); + } } for (const engine of engines) { @@ -86,11 +88,13 @@ if (targetLock) { console.error(`Payload manifest target mismatch: ${manifest.target}`); process.exit(1); } - try { - assertAria2RouteSource(manifest.generatedFrom?.aria2c, target); - } catch (error) { - console.error(error.message); - process.exit(1); + if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) { + try { + assertAria2RouteSource(manifest.generatedFrom.aria2c, target); + } catch (error) { + console.error(error.message); + process.exit(1); + } } for (const [relative, expected] of Object.entries(manifest.files || {})) { const file = path.join(source, relative); diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index 408e8b1..bdb3f71 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -10,8 +10,8 @@ import { resolveTargetTriple, } from './engine-workspace.js'; import { - ARIA2_ROUTE_DAEMON_ARGS, - assertAria2RouteContract, + ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, + assertAria2Baseline, } from './aria2-route-contract.js'; const __filename = fileURLToPath(import.meta.url); @@ -473,7 +473,7 @@ if (canExecuteTarget) { '--quiet', '--console-log-level=error', '--rpc-listen-all=false', - ...ARIA2_ROUTE_DAEMON_ARGS, + ...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, ], { env: engineEnv('aria2c'), stdio: ['ignore', 'ignore', 'pipe'], @@ -568,7 +568,7 @@ if (canExecuteTarget) { try { const resp = JSON.parse(result.data); if (resp?.result?.version) { - assertAria2RouteContract(resp.result); + assertAria2Baseline(resp.result); ok(`aria2 RPC version: ${resp.result.version}`); } else { fail(`aria2 RPC unexpected response: ${result.data}`); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 07811dc..dd02e95 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3463,6 +3463,7 @@ struct Aria2DaemonGuard { startup_error: Mutex>, last_stderr: Mutex, config_path: Mutex>, + route_contract_available: AtomicBool, shutdown_state: AtomicU8, } @@ -3473,10 +3474,20 @@ impl Aria2DaemonGuard { startup_error: Mutex::new(None), last_stderr: Mutex::new(String::new()), config_path: Mutex::new(None), + route_contract_available: AtomicBool::new(false), shutdown_state: AtomicU8::new(0), } } + fn route_contract_available(&self) -> bool { + self.route_contract_available.load(Ordering::Acquire) + } + + fn set_route_contract_available(&self, available: bool) { + self.route_contract_available + .store(available, Ordering::Release); + } + fn exit_allowed(&self) -> bool { self.shutdown_state.load(Ordering::SeqCst) == 2 } @@ -4369,7 +4380,7 @@ async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> let rpc_ready = if daemon_alive { rpc_call(port, secret, "aria2.getVersion", serde_json::json!([])) .await - .is_ok_and(|version| crate::network::aria2_route_contract_error(&version).is_none()) + .is_ok_and(|version| crate::network::aria2_baseline_error(&version).is_none()) } else { false }; @@ -9188,6 +9199,20 @@ async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), } } +const MAGNET_METADATA_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); +const MAGNET_SYSTEM_RESOLVER_ATTEMPT: Duration = Duration::from_secs(20); +const MAGNET_ALTERNATE_RESOLVER_ATTEMPT: Duration = Duration::from_secs(40); +const MAGNET_PROBE_CLEANUP_RESERVE: Duration = Duration::from_secs(5); + +fn magnet_probe_schedule(total_timeout: Duration) -> crate::torrent_probe::MetadataProbeSchedule { + crate::torrent_probe::MetadataProbeSchedule { + total_timeout, + metadata_timeout: total_timeout.saturating_sub(MAGNET_PROBE_CLEANUP_RESERVE), + cleanup_reserve: MAGNET_PROBE_CLEANUP_RESERVE.min(total_timeout), + poll_interval: Duration::from_millis(250), + } +} + async fn resolve_magnet_metadata( app_handle: &tauri::AppHandle, state: &AppState, @@ -9235,6 +9260,7 @@ async fn resolve_magnet_metadata( let storage_root = managed_path .parent() .ok_or_else(|| "torrent storage has no parent directory".to_string())?; + let operation_deadline = Instant::now() + MAGNET_METADATA_TOTAL_TIMEOUT; let probe_dir = storage_root.join(format!(".probe-{}", uuid::Uuid::new_v4().simple())); tokio::fs::create_dir_all(&probe_dir) .await @@ -9245,20 +9271,20 @@ async fn resolve_magnet_metadata( .load(std::sync::atomic::Ordering::Relaxed); let secret = state.aria2_secret.clone(); let metadata_path = probe_dir.join(format!("{}.torrent", expected.info_hash)); - let mut options = serde_json::Map::new(); - options.insert( + let mut base_options = serde_json::Map::new(); + base_options.insert( "dir".to_string(), serde_json::json!(probe_dir.to_string_lossy().to_string()), ); - options.insert("bt-metadata-only".to_string(), serde_json::json!("true")); - options.insert("bt-save-metadata".to_string(), serde_json::json!("true")); - options.insert("max-tries".to_string(), serde_json::json!("3")); - options.insert("retry-wait".to_string(), serde_json::json!("2")); - 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")); + base_options.insert("bt-metadata-only".to_string(), serde_json::json!("true")); + base_options.insert("bt-save-metadata".to_string(), serde_json::json!("true")); + base_options.insert("max-tries".to_string(), serde_json::json!("3")); + base_options.insert("retry-wait".to_string(), serde_json::json!("2")); + base_options.insert("connect-timeout".to_string(), serde_json::json!("20")); + base_options.insert("timeout".to_string(), serde_json::json!("60")); + base_options.insert("auto-file-renaming".to_string(), serde_json::json!("false")); if let Some(proxy) = proxy_value.as_deref() { - options.insert("all-proxy".to_string(), serde_json::json!(proxy)); + base_options.insert("all-proxy".to_string(), serde_json::json!(proxy)); } let mut header_list = Vec::new(); if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) { @@ -9274,33 +9300,93 @@ async fn resolve_magnet_metadata( ); } if !header_list.is_empty() { - options.insert("header".to_string(), serde_json::json!(header_list)); + base_options.insert("header".to_string(), serde_json::json!(header_list)); } let client = std::sync::Arc::new(Aria2RpcClient { port, secret }); - let resolver_mode = if proxy_value + let direct_route = proxy_value .as_deref() - .is_none_or(|proxy| proxy.trim().is_empty()) - { - "automatic" - } else { - "configured" - }; - let metadata_result = crate::torrent_probe::run_bounded_metadata_probe( + .is_none_or(|proxy| proxy.trim().is_empty()); + let route_contract_available = app_handle + .state::() + .route_contract_available(); + let mut system_options = base_options.clone(); + crate::network::apply_aria2_system_resolver_for_daemon( + &mut system_options, + route_contract_available, + ); + let first_result = crate::torrent_probe::run_bounded_metadata_probe( client, &sanitized_source, - options, + system_options, &metadata_path, - 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), - }, + "system", + magnet_probe_schedule(MAGNET_SYSTEM_RESOLVER_ATTEMPT), ) .await; + let metadata_result = match first_result { + error @ Err(_) if direct_route + && route_contract_available + && error + .as_ref() + .err() + .is_some_and(crate::torrent_probe::allows_resolver_fallback) => + { + let cleanup_budget = operation_deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(cleanup_budget, remove_magnet_metadata_probe_dir(&probe_dir)) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + return Err(format!( + "could not clean up magnet metadata probe before resolver fallback: {error}" + )); + } + Err(_) => { + return Err( + "could not clean up magnet metadata probe before resolver fallback: cleanup timed out" + .to_string(), + ); + } + } + let create_budget = operation_deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(create_budget, tokio::fs::create_dir_all(&probe_dir)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + return Err(format!( + "could not recreate magnet metadata probe before resolver fallback: {error}" + )); + } + Err(_) => { + return Err( + "could not recreate magnet metadata probe before resolver fallback: creation timed out" + .to_string(), + ); + } + } + let alternate_budget = MAGNET_ALTERNATE_RESOLVER_ATTEMPT + .min(operation_deadline.saturating_duration_since(Instant::now())); + let mut alternate_options = base_options; + crate::network::apply_aria2_route_contract(&mut alternate_options); + crate::torrent_probe::run_bounded_metadata_probe( + std::sync::Arc::new(Aria2RpcClient { + port: state + .aria2_port + .load(std::sync::atomic::Ordering::Relaxed), + secret: state.aria2_secret.clone(), + }), + &sanitized_source, + alternate_options, + &metadata_path, + "alternate", + magnet_probe_schedule(alternate_budget), + ) + .await + } + other => other, + }; + let bytes = match metadata_result { Ok(bytes) => bytes, Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => { @@ -18444,8 +18530,8 @@ pub fn run() { let frontend_exit_flush = Arc::new(FrontendExitFlush::new()); // Zero is the explicit not-ready sentinel. Publishing a candidate port - // before the daemon's route contract is attested could make commands or - // the WebSocket listener attach to an unrelated local service. + // before the daemon's baseline RPC is attested could make commands or the + // WebSocket listener attach to an unrelated local service. let aria2_port = Arc::new(std::sync::atomic::AtomicU16::new(0)); let aria2_port_clone = Arc::clone(&aria2_port); let aria2_secret = uuid::Uuid::new_v4().to_string(); @@ -18846,6 +18932,9 @@ pub fn run() { continue; } attempted_rpc_port = true; + app_handle_bg + .state::() + .set_route_contract_available(false); let mut cmd = std::process::Command::new(&binary_path); crate::platform::hide_child_console(&mut cmd); crate::engines::apply_aria2_environment(&mut cmd, &binary_path); @@ -18868,18 +18957,12 @@ 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!( - "--dns-resolver={}", - crate::network::ARIA2_DNS_RESOLVER - )) - .arg(format!( - "--network-target-policy={}", - crate::network::ARIA2_NETWORK_TARGET_POLICY - )) + // Resolver choice is explicit per transfer. + // Do not force Firelink-only options at daemon + // startup: the locked Windows/Linux engines + // are stock Aria2 builds, while a patched + // build advertises its optional fallback via + // getVersion. .arg(format!("--stop-with-process={}", std::process::id())); apply_aria2_torrent_global_options( @@ -19003,31 +19086,30 @@ pub fn run() { while start.elapsed() < std::time::Duration::from_secs(5) { match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await { Ok(ver) => { + if let Some(error) = + crate::network::aria2_baseline_error(&ver) + { + last_err = error; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown"); - let contract_error = - crate::network::aria2_route_contract_error(&ver); + let capability_error = + crate::network::aria2_route_capability_error(&ver); log::info!( - "aria2 daemon ready (version {}) on port {} (route contract: {})", + "aria2 daemon ready (version {}) on port {} (alternate resolver: {})", v, attempt_port, - if contract_error.is_none() { "verified" } else { "incompatible" } + if capability_error.is_none() { "available" } else { "unavailable" } ); - if let Some(contract_error) = contract_error { - let error = format!("{contract_error}; network transfers are disabled"); - 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, + guard.set_route_contract_available( + capability_error.is_none(), + ); + if let Some(capability_error) = capability_error { + log::warn!( + "aria2 alternate resolver unavailable; using system resolver only: {}", + capability_error ); - return; } aria2_port_clone.store( attempt_port, @@ -19267,9 +19349,8 @@ pub fn run() { interval.tick().await; if poll_port.load(std::sync::atomic::Ordering::Acquire) == 0 { // The daemon launcher publishes the port only after - // the exact route contract is attested. Avoid - // generating RPC failures while startup is pending or - // permanently unavailable. + // baseline RPC readiness is attested. Avoid generating + // RPC failures while startup is pending or unavailable. continue; } // Terminal cleanup removes a download's GID mapping. Do diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index 5b99e80..8cff10a 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -15,9 +15,43 @@ pub(crate) const ARIA2_NETWORK_TARGET_POLICY: &str = "firelink-v1"; pub(crate) const ARIA2_NETWORK_TARGET_POLICY_DIGEST: &str = "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"; -/// Stamp the route contract on each Aria2 request as well as on the daemon. -/// This keeps a later global-option mutation from silently changing the DNS or -/// literal-target policy of a queued transfer. +/// Select Aria2's standard resolver path for a transfer. +/// +/// This is deliberately the production default: hostname resolution remains +/// owned by the OS/TUN route and stock Aria2 builds remain usable. Remove the +/// optional Firelink-only fields as well so a caller cannot accidentally carry +/// alternate-resolver options into a system-resolver request. +pub(crate) fn apply_aria2_system_resolver( + options: &mut serde_json::Map, +) { + options.insert("async-dns".to_string(), serde_json::json!("false")); + options.remove("dns-resolver"); + options.remove("network-target-policy"); +} + +/// Select the system resolver for a transfer on a daemon that has already +/// advertised the optional Firelink route contract. +/// +/// The custom daemon's default target policy is route-aware and would still +/// reject private answers if the policy were merely omitted. Set it to `none` +/// explicitly for the system-resolver route; stock daemons never receive this +/// optional field. +pub(crate) fn apply_aria2_system_resolver_for_daemon( + options: &mut serde_json::Map, + firelink_route_contract_available: bool, +) { + apply_aria2_system_resolver(options); + if firelink_route_contract_available { + options.insert( + "network-target-policy".to_string(), + serde_json::json!("none"), + ); + } +} + +/// Select the optional Firelink-patched Aria2 resolver and target policy for a +/// transfer. Callers must first attest the daemon capabilities with +/// `aria2_route_capability_error`. pub(crate) fn apply_aria2_route_contract( options: &mut serde_json::Map, ) { @@ -32,8 +66,19 @@ pub(crate) fn apply_aria2_route_contract( ); } -/// Verify the exact fork contract before Firelink admits network work. -pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option { +fn has_string_capability(version: &serde_json::Value, field: &str, expected: &str) -> bool { + version + .get(field) + .and_then(serde_json::Value::as_array) + .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(expected))) +} + +/// Verify that an Aria2 daemon exposes the optional Firelink route features. +/// +/// The selected resolver and policy are intentionally not checked here: the +/// daemon starts in system-resolver mode and the alternate settings are +/// applied only to an individual fallback transfer. +pub(crate) fn aria2_route_capability_error(version: &serde_json::Value) -> Option { let async_dns = version .get("enabledFeatures") .and_then(serde_json::Value::as_array) @@ -47,21 +92,7 @@ pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option< } for (field, expected, label) in [ - ( - "firelinkRevision", - ARIA2_FIRELINK_REVISION, - "Firelink engine revision", - ), - ( - "firelinkDnsResolver", - ARIA2_DNS_RESOLVER, - "native DNS resolver", - ), - ( - "firelinkNetworkTargetPolicy", - ARIA2_NETWORK_TARGET_POLICY, - "network target policy", - ), + ("firelinkRevision", ARIA2_FIRELINK_REVISION, "Firelink engine revision"), ( "firelinkNetworkTargetPolicyDigest", ARIA2_NETWORK_TARGET_POLICY_DIGEST, @@ -72,6 +103,40 @@ pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option< return Some(format!("bundled aria2 has an incompatible {label}")); } } + if !has_string_capability(version, "firelinkDnsResolvers", ARIA2_DNS_RESOLVER) { + return Some("bundled aria2 does not expose the native DNS resolver".to_string()); + } + if !has_string_capability( + version, + "firelinkNetworkTargetPolicies", + ARIA2_NETWORK_TARGET_POLICY, + ) { + return Some("bundled aria2 does not expose the network target policy".to_string()); + } + None +} + +/// Verify that the optional Firelink route settings are active for the +/// current transfer/daemon option scope. +#[cfg(test)] +pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option { + if let Some(error) = aria2_route_capability_error(version) { + return Some(error); + } + if version + .get("firelinkDnsResolver") + .and_then(serde_json::Value::as_str) + != Some(ARIA2_DNS_RESOLVER) + { + return Some("bundled aria2 is not using the native DNS resolver".to_string()); + } + if version + .get("firelinkNetworkTargetPolicy") + .and_then(serde_json::Value::as_str) + != Some(ARIA2_NETWORK_TARGET_POLICY) + { + return Some("bundled aria2 is not using the network target policy".to_string()); + } if version .get("firelinkNetworkTargetPolicyEnforced") .and_then(serde_json::Value::as_bool) @@ -82,6 +147,18 @@ pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option< None } +/// Verify the standard RPC response needed by every supported Aria2 build. +pub(crate) fn aria2_baseline_error(version: &serde_json::Value) -> Option { + if version + .get("version") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + { + return Some("bundled aria2 returned an invalid version response".to_string()); + } + None +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum NetworkRoute { /// Preserve reqwest's normal environment/OS route selection. @@ -332,30 +409,38 @@ mod tests { } #[test] - fn aria2_route_contract_requires_exact_capabilities() { + fn aria2_route_capabilities_are_distinct_from_active_options() { let valid = serde_json::json!({ + "version": "1.37.0", "enabledFeatures": ["Async DNS", "BitTorrent"], "firelinkRevision": ARIA2_FIRELINK_REVISION, - "firelinkDnsResolver": ARIA2_DNS_RESOLVER, + "firelinkDnsResolver": "disabled", + "firelinkDnsResolvers": [ARIA2_DNS_RESOLVER], "firelinkNetworkTargetPolicy": ARIA2_NETWORK_TARGET_POLICY, + "firelinkNetworkTargetPolicies": ["none", ARIA2_NETWORK_TARGET_POLICY], "firelinkNetworkTargetPolicyDigest": ARIA2_NETWORK_TARGET_POLICY_DIGEST, - "firelinkNetworkTargetPolicyEnforced": true, + "firelinkNetworkTargetPolicyEnforced": false, }); - assert_eq!(aria2_route_contract_error(&valid), None); + assert_eq!(aria2_route_capability_error(&valid), None); + assert!(aria2_route_contract_error(&valid).is_some()); + + let mut active = valid.clone(); + active["firelinkDnsResolver"] = serde_json::json!(ARIA2_DNS_RESOLVER); + active["firelinkNetworkTargetPolicyEnforced"] = serde_json::json!(true); + assert_eq!(aria2_route_contract_error(&active), None); for field in [ "firelinkRevision", - "firelinkDnsResolver", - "firelinkNetworkTargetPolicy", + "firelinkDnsResolvers", + "firelinkNetworkTargetPolicies", "firelinkNetworkTargetPolicyDigest", - "firelinkNetworkTargetPolicyEnforced", ] { - let mut invalid = valid.clone(); + let mut invalid = active.clone(); invalid.as_object_mut().unwrap().remove(field); - assert!(aria2_route_contract_error(&invalid).is_some(), "{field}"); + assert!(aria2_route_capability_error(&invalid).is_some(), "{field}"); } - let mut wrong_digest = valid.clone(); + let mut wrong_digest = active.clone(); wrong_digest["firelinkNetworkTargetPolicyDigest"] = serde_json::json!("sha256:wrong"); assert!(aria2_route_contract_error(&wrong_digest).is_some()); assert!(aria2_route_contract_error(&serde_json::json!({ @@ -379,6 +464,48 @@ mod tests { ); } + #[test] + fn aria2_system_options_remove_optional_route_fields() { + let mut options = serde_json::Map::from_iter([ + ("dns-resolver".to_string(), serde_json::json!(ARIA2_DNS_RESOLVER)), + ( + "network-target-policy".to_string(), + serde_json::json!(ARIA2_NETWORK_TARGET_POLICY), + ), + ]); + apply_aria2_system_resolver(&mut options); + assert_eq!(options.get("async-dns"), Some(&serde_json::json!("false"))); + assert!(!options.contains_key("dns-resolver")); + assert!(!options.contains_key("network-target-policy")); + } + + #[test] + fn aria2_system_options_disable_the_custom_default_policy_only_when_attested() { + let mut stock_options = serde_json::Map::new(); + apply_aria2_system_resolver_for_daemon(&mut stock_options, false); + assert!(!stock_options.contains_key("network-target-policy")); + + let mut patched_options = serde_json::Map::new(); + apply_aria2_system_resolver_for_daemon(&mut patched_options, true); + assert_eq!( + patched_options.get("network-target-policy"), + Some(&serde_json::json!("none")) + ); + assert_eq!(patched_options.get("async-dns"), Some(&serde_json::json!("false"))); + } + + #[test] + fn stock_aria2_baseline_response_is_accepted_without_firelink_fields() { + assert_eq!( + aria2_baseline_error(&serde_json::json!({ + "version": "1.37.0", + "enabledFeatures": ["Async DNS", "BitTorrent"], + })), + None + ); + assert!(aria2_baseline_error(&serde_json::json!({})).is_some()); + } + #[test] fn rejects_literal_local_and_mapped_addresses() { for raw in [ diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 2caf327..3ae59ca 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1010,7 +1010,7 @@ fn aria2_resolver_route_for_log(payload: &SpawnPayload) -> &'static str { { "configured" } else { - "automatic" + "system" } } @@ -8342,7 +8342,18 @@ impl SidecarSpawner for ProductionSpawner { let attempt_epoch = state.queue_manager.current_aria2_control_epoch(id).await; let admission_started = Instant::now(); let mut options = serde_json::Map::new(); - crate::network::apply_aria2_route_contract(&mut options); + // Keep hostname resolution on the system/TUN route for every normal + // and Torrent transfer. The optional Firelink resolver is reserved for + // an explicit magnet metadata fallback and is never forced onto stock + // Aria2 builds. + let route_contract_available = self + .app_handle + .state::() + .route_contract_available(); + crate::network::apply_aria2_system_resolver_for_daemon( + &mut options, + route_contract_available, + ); 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) { diff --git a/src-tauri/src/torrent_probe.rs b/src-tauri/src/torrent_probe.rs index 9cc0877..6b27bf8 100644 --- a/src-tauri/src/torrent_probe.rs +++ b/src-tauri/src/torrent_probe.rs @@ -33,11 +33,12 @@ pub(crate) struct MetadataProbeSchedule { pub(crate) async fn run_metadata_probe( client: Arc, source: &str, - options: Map, + mut options: Map, metadata_path: &Path, timeout: Duration, poll_interval: Duration, ) -> Result, ProbeFailure> { + crate::network::apply_aria2_system_resolver(&mut options); let deadline = Instant::now() + timeout; run_metadata_probe_with_deadlines( client, @@ -66,8 +67,9 @@ pub(crate) async fn run_metadata_probe_with_deadlines( ) -> Result, ProbeFailure> { // This probe only resolves magnet metadata. It must never allow Aria2 to // interpret a downloaded metadata file as another child download because - // the probe cleanup guard owns the complete addUri lifecycle. - crate::network::apply_aria2_route_contract(&mut options); + // the probe cleanup guard owns the complete addUri lifecycle. The caller + // selects the system or optional alternate resolver before entering this + // lifecycle; this function must not silently change that route. options.insert("follow-torrent".to_string(), json!("false")); options.insert("follow-metalink".to_string(), json!("false")); let planned_gid = new_probe_gid(); @@ -283,11 +285,10 @@ async fn finish_failed_probe( } } -/// Run magnet metadata discovery through Aria2's asynchronous resolver while -/// reserving part of the absolute deadline for deterministic GID cleanup. A -/// synchronous system-resolver retry is intentionally excluded because -/// Aria2 executes getaddrinfo on its shared event loop and can otherwise -/// stall every transfer and the local RPC control channel. +/// Run one bounded magnet metadata attempt while reserving part of the +/// absolute deadline for deterministic GID cleanup. Resolver selection is +/// explicit in `options`; this function never starts a second route or hides a +/// cleanup failure behind a retry. pub(crate) async fn run_bounded_metadata_probe( client: Arc, source: &str, @@ -354,6 +355,16 @@ fn probe_error_class(error: &str) -> &'static str { } } +pub(crate) fn allows_resolver_fallback(error: &ProbeFailure) -> bool { + match error { + ProbeFailure::Cleanup(_) => false, + ProbeFailure::Metadata(error) => { + matches!(probe_error_class(error), "timeout" | "name_resolution") + || crate::retry::is_transient_network_error(error) + } + } +} + struct ProbeCleanupGuard { client: Arc, gids: Vec, @@ -1178,6 +1189,7 @@ mod tests { statuses: Mutex>>, force_remove: Mutex>, removed: AtomicBool, + hang_first_status: AtomicBool, calls: Mutex>, call_notification: tokio::sync::Notify, } @@ -1191,11 +1203,16 @@ mod tests { statuses: Mutex::new(statuses.into_iter().collect()), force_remove: Mutex::new(force_remove), removed: AtomicBool::new(false), + hang_first_status: AtomicBool::new(false), calls: Mutex::new(Vec::new()), call_notification: tokio::sync::Notify::new(), } } + fn hang_first_status(&self) { + self.hang_first_status.store(true, Ordering::Release); + } + fn status(name: &str) -> Result { Ok(json!({ "status": name })) } @@ -1228,6 +1245,10 @@ mod tests { match method { "aria2.addUri" => Ok(json!("gid-1")), "aria2.tellStatus" => { + if self.hang_first_status.swap(false, Ordering::AcqRel) { + std::future::pending::<()>().await; + unreachable!("the first status call should remain pending"); + } if let Some(status) = self .statuses .lock() @@ -1576,25 +1597,30 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn generic_metadata_timeout_does_not_enter_blocking_system_resolver() { - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ScriptedReply::Hang, status_reply("removed")], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::Result(json!("gid-1"))], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; + // Use a deterministic pending RPC rather than a loopback HTTP server. + // The assertion is about the probe deadline and cleanup ownership, so + // socket scheduling must not decide whether the addUri call settles + // before a 100ms test budget on a busy hosted runner. + let rpc = Arc::new(FakeRpc::new( + [FakeRpc::status("removed")], + Ok(json!("gid-1")), + )); + rpc.hang_first_status(); + let temporary = tempfile::tempdir().expect("temporary probe storage should exist"); + let metadata_path = temporary.path().join("metadata.torrent"); + tokio::fs::write(&metadata_path, b"torrent metadata") + .await + .expect("metadata fixture should be writable"); let error = run_bounded_metadata_probe( - server.client(), + rpc.clone(), "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), + { + let mut options = Map::new(); + crate::network::apply_aria2_system_resolver(&mut options); + options + }, &metadata_path, - "automatic", + "system", MetadataProbeSchedule { total_timeout: Duration::from_millis(500), metadata_timeout: Duration::from_millis(100), @@ -1606,18 +1632,13 @@ mod tests { .expect_err("a metadata timeout should remain on the non-blocking route"); assert!(matches!(error, ProbeFailure::Metadata(message) if message.contains("timed out"))); assert_eq!( - server - .calls() + rpc.call_names() .iter() - .filter(|(method, _)| method == "aria2.addUri") + .filter(|method| method.as_str() == "aria2.addUri") .count(), 1, "peer or tracker timeouts must not trigger the synchronous resolver" ); - tokio::fs::remove_dir_all(&probe_dir) - .await - .expect("timed-out probe fixture should be removable"); - server.shutdown().await; } #[tokio::test(flavor = "current_thread")]