diff --git a/engines.lock.json b/engines.lock.json index 6fa194b..42b48c2 100644 --- a/engines.lock.json +++ b/engines.lock.json @@ -10,10 +10,16 @@ "sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443" }, "aria2c": { - "version": "1.37.0", - "source": "https://github.com/aria2/aria2", - "build": "arm64 executable with adjacent aria2-libs", - "sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d" + "version": "1.37.0-firelink-native-dns-v1", + "source": "https://github.com/aria2/aria2/tree/release-1.37.0", + "build": "Firelink native-async DNS and network-target-policy patch set; arm64 executable with adjacent aria2-libs", + "firelinkRouteContract": { + "revision": "firelink-native-dns-v1", + "dnsResolver": "native-async", + "networkTargetPolicy": "firelink-v1", + "networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705" + }, + "sha256": "913ddf47f1194f5f22c69f8b5179bbeb48ebf1cf73c62a8d385b05f8dd521614" }, "ffmpeg": { "version": "N-125892-g406c5a37aa", diff --git a/scripts/aria2-route-contract.js b/scripts/aria2-route-contract.js new file mode 100644 index 0000000..28dca5e --- /dev/null +++ b/scripts/aria2-route-contract.js @@ -0,0 +1,89 @@ +export const ARIA2_FIRELINK_REVISION = 'firelink-native-dns-v1'; +export const ARIA2_DNS_RESOLVER = 'native-async'; +export const ARIA2_NETWORK_TARGET_POLICY = 'firelink-v1'; +export const ARIA2_NETWORK_TARGET_POLICY_DIGEST = + 'sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705'; + +export const ARIA2_ROUTE_DAEMON_ARGS = Object.freeze([ + '--async-dns=true', + `--dns-resolver=${ARIA2_DNS_RESOLVER}`, + `--network-target-policy=${ARIA2_NETWORK_TARGET_POLICY}`, +]); + +export const ARIA2_ROUTE_OPTIONS = Object.freeze({ + 'async-dns': 'true', + 'dns-resolver': ARIA2_DNS_RESOLVER, + 'network-target-policy': ARIA2_NETWORK_TARGET_POLICY, +}); + +// Local HTTP servers are used only by engine smoke tests. Product transfers +// never apply this override; Firelink keeps the literal-target policy enabled. +export const ARIA2_LOCAL_FIXTURE_OPTIONS = Object.freeze({ + ...ARIA2_ROUTE_OPTIONS, + 'network-target-policy': 'none', +}); + +export function assertAria2RouteCapabilities(version) { + if (!Array.isArray(version?.enabledFeatures) + || !version.enabledFeatures.includes('Async DNS')) { + throw new Error(`aria2 does not advertise asynchronous DNS: ${JSON.stringify(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)) { + if (version?.[field] !== value) { + throw new Error(`aria2 route capability mismatch for ${field}: ${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 assertAria2RouteContract(version) { + assertAria2RouteCapabilities(version); + const expected = { + firelinkRevision: ARIA2_FIRELINK_REVISION, + firelinkDnsResolver: ARIA2_DNS_RESOLVER, + firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY, + firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST, + }; + for (const [field, value] of Object.entries(expected)) { + if (version?.[field] !== value) { + throw new Error(`aria2 route contract mismatch for ${field}: ${JSON.stringify(version)}`); + } + } + if (version.firelinkNetworkTargetPolicyEnforced !== true) { + throw new Error(`aria2 is not enforcing the Firelink network target policy: ${JSON.stringify(version)}`); + } +} + +export function assertAria2RouteOptions(options, context = 'aria2 transfer') { + for (const [field, value] of Object.entries(ARIA2_ROUTE_OPTIONS)) { + if (options?.[field] !== value) { + throw new Error(`${context} did not retain ${field}=${value}: ${JSON.stringify(options)}`); + } + } +} + +export function assertAria2RouteSource(source, target) { + const contract = source?.firelinkRouteContract; + const expected = { + revision: ARIA2_FIRELINK_REVISION, + dnsResolver: ARIA2_DNS_RESOLVER, + networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY, + networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST, + }; + for (const [field, value] of Object.entries(expected)) { + if (contract?.[field] !== value) { + throw new Error( + `aria2c source for ${target} is not a Firelink route-contract build; ` + + `expected firelinkRouteContract.${field}=${value}`, + ); + } + } +} diff --git a/scripts/aria2-route-contract.node-test.js b/scripts/aria2-route-contract.node-test.js new file mode 100644 index 0000000..d1f7f6e --- /dev/null +++ b/scripts/aria2-route-contract.node-test.js @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ARIA2_DNS_RESOLVER, + ARIA2_FIRELINK_REVISION, + ARIA2_NETWORK_TARGET_POLICY, + ARIA2_NETWORK_TARGET_POLICY_DIGEST, + assertAria2RouteCapabilities, + assertAria2RouteContract, + assertAria2RouteSource, +} from './aria2-route-contract.js'; + +const secureVersion = { + enabledFeatures: ['Async DNS'], + firelinkRevision: ARIA2_FIRELINK_REVISION, + firelinkDnsResolver: ARIA2_DNS_RESOLVER, + firelinkNetworkTargetPolicies: ['none', ARIA2_NETWORK_TARGET_POLICY], + firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY, + firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST, + firelinkNetworkTargetPolicyEnforced: true, +}; + +test('Aria2 source metadata must identify the Firelink route contract', () => { + const source = { + firelinkRouteContract: { + revision: ARIA2_FIRELINK_REVISION, + dnsResolver: ARIA2_DNS_RESOLVER, + networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY, + networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST, + }, + }; + + assert.doesNotThrow(() => assertAria2RouteSource(source, 'test-target')); + assert.throws( + () => assertAria2RouteSource({}, 'test-target'), + /not a Firelink route-contract build/, + ); + assert.throws( + () => assertAria2RouteSource({ + firelinkRouteContract: { + ...source.firelinkRouteContract, + networkTargetPolicyDigest: 'sha256:wrong', + }, + }, 'test-target'), + /networkTargetPolicyDigest/, + ); +}); + +test('route capabilities are distinct from the active local-fixture policy', () => { + assert.doesNotThrow(() => assertAria2RouteCapabilities({ + ...secureVersion, + firelinkNetworkTargetPolicy: 'none', + firelinkNetworkTargetPolicyEnforced: false, + })); + assert.doesNotThrow(() => assertAria2RouteContract(secureVersion)); + assert.throws( + () => assertAria2RouteContract({ + ...secureVersion, + firelinkNetworkTargetPolicy: 'none', + firelinkNetworkTargetPolicyEnforced: false, + }), + /route contract mismatch for firelinkNetworkTargetPolicy/, + ); +}); diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index fe40272..46d172d 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -12,6 +12,7 @@ import { removeOrphanedProvisioningDirectories, removePathWithRetry, } from './engine-payload-promotion.js'; +import { assertAria2RouteSource } from './aria2-route-contract.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -39,6 +40,17 @@ 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); +} + const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target); const isWindows = target.includes('windows'); const executableSuffix = isWindows ? '.exe' : ''; @@ -134,7 +146,10 @@ function writePayloadManifest() { { version: source.version, url: source.url || source.sourceUrl, - sha256: source.sha256 || source.sourceSha256 + sha256: source.sha256 || source.sourceSha256, + ...(name === 'aria2c' && source.firelinkRouteContract + ? { firelinkRouteContract: source.firelinkRouteContract } + : {}) } ]) ), diff --git a/scripts/smoke-aria2-resolver.js b/scripts/smoke-aria2-resolver.js index 112fd5f..71b2bf7 100644 --- a/scripts/smoke-aria2-resolver.js +++ b/scripts/smoke-aria2-resolver.js @@ -8,6 +8,12 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { + ARIA2_ROUTE_DAEMON_ARGS, + ARIA2_ROUTE_OPTIONS, + assertAria2RouteContract, + assertAria2RouteOptions, +} from './aria2-route-contract.js'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()]; @@ -195,7 +201,7 @@ const child = spawn(binaryPath, [ `--dir=${tempRoot}`, '--file-allocation=none', '--enable-dht=false', - '--async-dns=true', + ...ARIA2_ROUTE_DAEMON_ARGS, '--console-log-level=error', '--quiet=true', ], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }); @@ -204,18 +210,17 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); }); try { const version = await waitForRpc(rpcPort, secret); - const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : []; - 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`); + assertAria2RouteContract(version); + console.log(`[INFO] aria2 ${version.version || 'unknown'}; Firelink route contract verified`); const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], { + ...ARIA2_ROUTE_OPTIONS, + 'network-target-policy': 'none', out: 'resolver-normal.bin', }]); const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]); - if (uriOptions['async-dns'] === 'false') { - throw new Error(`direct aria2.addUri unexpectedly disabled asynchronous DNS: ${JSON.stringify(uriOptions)}`); + 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)}`); } const torrent = bencode({ @@ -227,16 +232,16 @@ try { }, }).toString('base64'); const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], { + ...ARIA2_ROUTE_OPTIONS, dir: tempRoot, }]); const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]); - if (torrentOptions['async-dns'] === 'false') { - throw new Error(`direct aria2.addTorrent unexpectedly disabled asynchronous DNS: ${JSON.stringify(torrentOptions)}`); - } + assertAria2RouteOptions(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, 'all-proxy': proxyRoute, pause: 'true', out: 'resolver-proxied.bin', @@ -245,11 +250,10 @@ try { if (proxiedUriOptions['all-proxy'] !== normalizedProxyRoute) { throw new Error(`aria2.addUri did not retain the configured proxy route: ${JSON.stringify(proxiedUriOptions)}`); } - if (proxiedUriOptions['async-dns'] === 'false') { - throw new Error(`proxied aria2.addUri unexpectedly forced system DNS resolution: ${JSON.stringify(proxiedUriOptions)}`); - } + assertAria2RouteOptions(proxiedUriOptions, 'proxied aria2.addUri'); const proxiedTorrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], { + ...ARIA2_ROUTE_OPTIONS, 'all-proxy': proxyRoute, pause: 'true', dir: tempRoot, @@ -258,9 +262,7 @@ try { if (proxiedTorrentOptions['all-proxy'] !== normalizedProxyRoute) { throw new Error(`aria2.addTorrent did not retain the configured proxy route: ${JSON.stringify(proxiedTorrentOptions)}`); } - if (proxiedTorrentOptions['async-dns'] === 'false') { - throw new Error(`proxied aria2.addTorrent unexpectedly forced system DNS resolution: ${JSON.stringify(proxiedTorrentOptions)}`); - } + assertAria2RouteOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent'); await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult); await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult); diff --git a/scripts/smoke-aria2-transfers.js b/scripts/smoke-aria2-transfers.js index 8782ecf..971f26a 100644 --- a/scripts/smoke-aria2-transfers.js +++ b/scripts/smoke-aria2-transfers.js @@ -8,6 +8,11 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { + ARIA2_LOCAL_FIXTURE_OPTIONS, + ARIA2_ROUTE_DAEMON_ARGS, + assertAria2RouteContract, +} from './aria2-route-contract.js'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()]; @@ -326,6 +331,7 @@ const child = spawn(binaryPath, [ '--enable-dht=false', '--console-log-level=error', '--quiet=true', + ...ARIA2_ROUTE_DAEMON_ARGS, `--server-stat-if=${serverStatPath}`, `--server-stat-of=${serverStatPath}`, ], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }); @@ -334,9 +340,11 @@ 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`); const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M', }]); const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid); @@ -345,6 +353,7 @@ try { } const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'no-range.bin', split: '1', 'max-connection-per-server': '1', }]); if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') { @@ -352,6 +361,7 @@ try { } const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password', header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'], }]); @@ -360,6 +370,7 @@ try { } const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'resume.bin', split: '1', continue: 'true', }]); await waitForProgress(rpcPort, secret, resumeGid); @@ -374,6 +385,7 @@ try { } const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'cancel.bin', split: '1', }]); await waitForProgress(rpcPort, secret, cancelGid); @@ -386,17 +398,19 @@ try { const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[ `http://127.0.0.1:${fixturePort}/missing`, `http://127.0.0.1:${fixturePort}/range`, - ], { out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]); + ], { ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]); const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid); if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`); const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true', }]); if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') { throw new Error('valid checksum transfer did not complete'); } const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true', }]); const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid); @@ -423,6 +437,7 @@ try { if (!redirectLocation) throw new Error('redirect preflight returned no Location header'); const resolvedRedirect = new URL(redirectLocation, redirectProbe.url); const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'redirect.bin', }]); const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid); @@ -431,6 +446,7 @@ try { } const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'missing.bin', 'max-tries': '1', }]); const missingStatus = await waitForTerminal(rpcPort, secret, missingGid); @@ -439,6 +455,7 @@ try { } const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20', }]); const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000); @@ -447,6 +464,7 @@ try { } const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'malformed.bin', 'max-tries': '1', }]); if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') { @@ -454,6 +472,7 @@ try { } const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], { + ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1', }]); if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') { diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index c4636f4..9eb6793 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -8,6 +8,10 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { + ARIA2_DNS_RESOLVER, + assertAria2RouteCapabilities, +} from './aria2-route-contract.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -68,6 +72,14 @@ 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)}`); + } +} + function childExited(child) { return child.exitCode !== null || child.signalCode !== null; } @@ -309,6 +321,8 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [ '--enable-peer-exchange=false', '--bt-enable-lpd=false', '--async-dns=true', + `--dns-resolver=${ARIA2_DNS_RESOLVER}`, + '--network-target-policy=none', '--console-log-level=error', '--quiet=true', ...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []), @@ -330,15 +344,15 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [ }; activeDaemons.add(daemon); try { - await waitFor(`${name} Aria2 RPC`, async () => { + const version = await waitFor(`${name} Aria2 RPC`, async () => { if (exit) throw new DaemonExitedError(`${name} exited: ${exit.error?.message || `${exit.code}/${exit.signal}`}`); try { - await rpc(selectedRpcPort, secret, 'aria2.getVersion'); - return true; + return await rpc(selectedRpcPort, secret, 'aria2.getVersion'); } catch { return false; } }, 10000); + assertAria2RouteCapabilities(version); return daemon; } catch (error) { lastError = error; @@ -770,7 +784,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', 'fresh direct Torrent unexpectedly disabled asynchronous DNS'); + assertFixtureRouteOptions(directOptions, 'fresh direct Torrent'); await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directGid]); try { await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]); @@ -800,7 +814,7 @@ async function main() { 'auto-file-renaming': 'false', }]); const probeOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [probeGid]); - assert(probeOptions['async-dns'] !== 'false', 'fresh direct magnet probe unexpectedly disabled asynchronous DNS'); + assertFixtureRouteOptions(probeOptions, 'fresh direct magnet probe'); const probeStatus = await waitForTerminal(client, probeGid, 30000); assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete'); const savedTorrentPaths = fs.readdirSync(probeDir) @@ -858,7 +872,7 @@ async function main() { }]); const proxiedProbeOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [proxiedProbeGid]); assert(proxiedProbeOptions['all-proxy'] === normalizedProxiedProbeRoute, 'proxied magnet metadata probe did not retain the configured proxy route'); - assert(proxiedProbeOptions['async-dns'] !== 'false', 'proxied magnet metadata probe unexpectedly forced system DNS resolution'); + 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'); diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js index 7c02a69..6591a40 100644 --- a/scripts/stage-engines.js +++ b/scripts/stage-engines.js @@ -9,6 +9,7 @@ import { resolveOutputRoot, resolveTargetTriple, } from './engine-workspace.js'; +import { assertAria2RouteSource } from './aria2-route-contract.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -45,6 +46,13 @@ if (!source) { } if (targetLock) { + try { + assertAria2RouteSource(targetLock.engines?.aria2c, target); + } catch (error) { + console.error(error.message); + process.exit(1); + } + for (const engine of engines) { const name = `${engine}-${target}${suffix}`; const expected = targetLock.engines?.[engine]?.sha256; @@ -78,6 +86,12 @@ 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); + } for (const [relative, expected] of Object.entries(manifest.files || {})) { const file = path.join(source, relative); if (!fs.existsSync(file) || sha256(file) !== expected) { diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index 7767a00..408e8b1 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -9,6 +9,10 @@ import { resolveOutputRoot, resolveTargetTriple, } from './engine-workspace.js'; +import { + ARIA2_ROUTE_DAEMON_ARGS, + assertAria2RouteContract, +} from './aria2-route-contract.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -469,6 +473,7 @@ if (canExecuteTarget) { '--quiet', '--console-log-level=error', '--rpc-listen-all=false', + ...ARIA2_ROUTE_DAEMON_ARGS, ], { env: engineEnv('aria2c'), stdio: ['ignore', 'ignore', 'pipe'], @@ -563,6 +568,7 @@ if (canExecuteTarget) { try { const resp = JSON.parse(result.data); if (resp?.result?.version) { + assertAria2RouteContract(resp.result); ok(`aria2 RPC version: ${resp.result.version}`); } else { fail(`aria2 RPC unexpected response: ${result.data}`); diff --git a/src-tauri/binaries/aria2c-aarch64-apple-darwin b/src-tauri/binaries/aria2c-aarch64-apple-darwin index a25284a..8a1cddd 100755 Binary files a/src-tauri/binaries/aria2c-aarch64-apple-darwin and b/src-tauri/binaries/aria2c-aarch64-apple-darwin differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d01254..07811dc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3466,17 +3466,6 @@ 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 { @@ -4031,6 +4020,10 @@ pub async fn rpc_call( ) -> Result { ensure_reqwest_crypto_provider(); + if port == 0 { + return Err("aria2 daemon is not ready".to_string()); + } + let url = format!("http://127.0.0.1:{}/jsonrpc", port); let mut payload = serde_json::Map::new(); payload.insert("jsonrpc".to_string(), serde_json::json!("2.0")); @@ -4120,8 +4113,13 @@ async fn test_aria2c( return Err(format!("aria2 daemon unavailable: {err}")); } + let port = state.aria2_port.load(std::sync::atomic::Ordering::Acquire); + if port == 0 { + return Err("aria2 daemon is not ready".to_string()); + } + let result = rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + port, &state.aria2_secret, "aria2.getVersion", serde_json::json!([]), @@ -4357,7 +4355,7 @@ async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> .clone(); (se, stderr) }; - let daemon_alive = startup_err.is_none(); + let daemon_alive = startup_err.is_none() && port != 0; let last_stderr_tail = if daemon_stderr.is_empty() { None } else { @@ -4371,7 +4369,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() + .is_ok_and(|version| crate::network::aria2_route_contract_error(&version).is_none()) } else { false }; @@ -14034,7 +14032,6 @@ 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, @@ -14072,17 +14069,6 @@ 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))); @@ -18457,8 +18443,10 @@ pub fn run() { tokio::sync::watch::channel(false); let frontend_exit_flush = Arc::new(FrontendExitFlush::new()); - let initial_aria2_port = 6800; // Will be determined dynamically in background - let aria2_port = Arc::new(std::sync::atomic::AtomicU16::new(initial_aria2_port)); + // 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. + 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(); let builder = tauri::Builder::default() @@ -18840,7 +18828,7 @@ pub fn run() { let app_handle_bg = app.handle().clone(); tauri::async_runtime::spawn(async move { - let mut ws_port = 6800; + let mut ws_port = None; match resolve_bundled_binary_path(&app_handle_bg, "aria2c") { Ok(binary_path) => { let mut success = false; @@ -18884,6 +18872,14 @@ pub fn run() { // 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 + )) .arg(format!("--stop-with-process={}", std::process::id())); apply_aria2_torrent_global_options( @@ -18974,10 +18970,6 @@ pub fn run() { log::info!("aria2c spawned successfully on port {}", attempt_port); - aria2_port_clone.store(attempt_port, std::sync::atomic::Ordering::Relaxed); - ws_port = attempt_port; - success = true; - let daemon_app = app_handle_bg.clone(); if let Some(stderr) = child.stderr.take() { std::thread::spawn(move || { @@ -19012,16 +19004,16 @@ 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 = - aria2_supports_async_dns(&ver); + let contract_error = + crate::network::aria2_route_contract_error(&ver); log::info!( - "aria2 daemon ready (version {}) on port {} (async DNS: {})", + "aria2 daemon ready (version {}) on port {} (route contract: {})", v, attempt_port, - async_dns_supported + if contract_error.is_none() { "verified" } else { "incompatible" } ); - if !async_dns_supported { - let error = "bundled aria2 does not support asynchronous DNS; network transfers are disabled".to_string(); + 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); @@ -19037,6 +19029,13 @@ pub fn run() { ); return; } + aria2_port_clone.store( + attempt_port, + std::sync::atomic::Ordering::Release, + ); + ws_port = Some(attempt_port); + success = true; + *guard.startup_error.lock().unwrap() = None; ready = true; break; } @@ -19050,6 +19049,12 @@ pub fn run() { let err = if last_err.is_empty() { "aria2 daemon did not become ready within 5 seconds".to_string() } else { format!("aria2 did not become ready: {last_err}") }; log::error!("{}", err); *guard.startup_error.lock().unwrap() = Some(err); + shutdown_aria2_daemon(app_handle_bg.clone()).await; + aria2_port_clone.store( + 0, + std::sync::atomic::Ordering::Release, + ); + return; } break; } @@ -19068,15 +19073,34 @@ pub fn run() { "No Aria2 RPC port is available outside the configured Torrent TCP listen ports".to_string() } })); + aria2_port_clone.store( + 0, + std::sync::atomic::Ordering::Release, + ); + return; } } Err(e) => { log::error!("Failed to resolve aria2c binary: {}", e); let guard = app_handle_bg.state::(); *guard.startup_error.lock().unwrap() = Some(format!("Failed to resolve aria2c: {e}")); + aria2_port_clone.store( + 0, + std::sync::atomic::Ordering::Release, + ); + return; } } + let Some(ws_port) = ws_port else { + log::error!("aria2 startup completed without a verified WebSocket port"); + aria2_port_clone.store( + 0, + std::sync::atomic::Ordering::Release, + ); + return; + }; + let mut ws_retries = 0; loop { if ws_retries == 10 { @@ -19241,6 +19265,13 @@ pub fn run() { let mut relocation_checks = HashSet::new(); loop { 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. + continue; + } // Terminal cleanup removes a download's GID mapping. Do // not retain one observation per historical download for // the lifetime of the poller. diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index a7d665d..5b99e80 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -9,6 +9,79 @@ use std::net::IpAddr; use reqwest::{ClientBuilder, Proxy, Url}; +pub(crate) const ARIA2_FIRELINK_REVISION: &str = "firelink-native-dns-v1"; +pub(crate) const ARIA2_DNS_RESOLVER: &str = "native-async"; +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. +pub(crate) fn apply_aria2_route_contract( + options: &mut serde_json::Map, +) { + options.insert("async-dns".to_string(), serde_json::json!("true")); + options.insert( + "dns-resolver".to_string(), + serde_json::json!(ARIA2_DNS_RESOLVER), + ); + options.insert( + "network-target-policy".to_string(), + serde_json::json!(ARIA2_NETWORK_TARGET_POLICY), + ); +} + +/// Verify the exact fork contract before Firelink admits network work. +pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option { + let async_dns = version + .get("enabledFeatures") + .and_then(serde_json::Value::as_array) + .is_some_and(|features| { + features + .iter() + .any(|feature| feature.as_str() == Some("Async DNS")) + }); + if !async_dns { + return Some("bundled aria2 does not support asynchronous DNS".to_string()); + } + + 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", + ), + ( + "firelinkNetworkTargetPolicyDigest", + ARIA2_NETWORK_TARGET_POLICY_DIGEST, + "network target policy digest", + ), + ] { + if version.get(field).and_then(serde_json::Value::as_str) != Some(expected) { + return Some(format!("bundled aria2 has an incompatible {label}")); + } + } + if version + .get("firelinkNetworkTargetPolicyEnforced") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return Some("bundled aria2 is not enforcing the network target policy".to_string()); + } + None +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum NetworkRoute { /// Preserve reqwest's normal environment/OS route selection. @@ -141,10 +214,17 @@ pub(crate) fn parse_and_validate_url( pub(crate) fn is_local_hostname(host: &str) -> bool { let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase(); normalized == "localhost" + || matches!(normalized.as_str(), "local" | "broadcasthost") || normalized.ends_with(".localhost") || matches!( normalized.as_str(), - "localhost.localdomain" | "ip6-localhost" | "ip6-loopback" + "localhost.localdomain" + | "localhost6" + | "localhost6.localdomain6" + | "ip6-localhost" + | "ip6-loopback" + | "ip6-allnodes" + | "ip6-allrouters" ) || normalized.ends_with(".local") } @@ -221,7 +301,14 @@ pub(crate) fn is_blocked_network_address(ip: IpAddr) -> bool { return true; } match ip { - IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_link_local(), + IpAddr::V4(ipv4) => { + let octets = ipv4.octets(); + octets[0] == 0 + || ipv4.is_private() + || ipv4.is_link_local() + || (octets[0] == 100 && octets[1] & 0xc0 == 0x40) + || octets == [255, 255, 255, 255] + } IpAddr::V6(ipv6) => { // Check both IPv4-mapped and deprecated IPv4-compatible forms; // either can encode a local IPv4 destination behind an IPv6 @@ -231,6 +318,7 @@ pub(crate) fn is_blocked_network_address(ip: IpAddr) -> bool { .is_some_and(|ipv4| is_blocked_network_address(ipv4.into())) || (ipv6.segments()[0] & 0xfe00) == 0xfc00 || (ipv6.segments()[0] & 0xffc0) == 0xfe80 + || (ipv6.segments()[0] & 0xffc0) == 0xfec0 } } } @@ -243,17 +331,69 @@ mod tests { parse_and_validate_url(raw, schemes, CredentialPolicy::Allow).map(|_| ()) } + #[test] + fn aria2_route_contract_requires_exact_capabilities() { + let valid = serde_json::json!({ + "enabledFeatures": ["Async DNS", "BitTorrent"], + "firelinkRevision": ARIA2_FIRELINK_REVISION, + "firelinkDnsResolver": ARIA2_DNS_RESOLVER, + "firelinkNetworkTargetPolicy": ARIA2_NETWORK_TARGET_POLICY, + "firelinkNetworkTargetPolicyDigest": ARIA2_NETWORK_TARGET_POLICY_DIGEST, + "firelinkNetworkTargetPolicyEnforced": true, + }); + assert_eq!(aria2_route_contract_error(&valid), None); + + for field in [ + "firelinkRevision", + "firelinkDnsResolver", + "firelinkNetworkTargetPolicy", + "firelinkNetworkTargetPolicyDigest", + "firelinkNetworkTargetPolicyEnforced", + ] { + let mut invalid = valid.clone(); + invalid.as_object_mut().unwrap().remove(field); + assert!(aria2_route_contract_error(&invalid).is_some(), "{field}"); + } + + let mut wrong_digest = valid.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!({ + "enabledFeatures": ["BitTorrent"] + })) + .is_some()); + } + + #[test] + fn aria2_route_options_are_explicit_per_transfer() { + let mut options = serde_json::Map::new(); + apply_aria2_route_contract(&mut options); + assert_eq!(options.get("async-dns"), Some(&serde_json::json!("true"))); + assert_eq!( + options.get("dns-resolver"), + Some(&serde_json::json!(ARIA2_DNS_RESOLVER)) + ); + assert_eq!( + options.get("network-target-policy"), + Some(&serde_json::json!(ARIA2_NETWORK_TARGET_POLICY)) + ); + } + #[test] fn rejects_literal_local_and_mapped_addresses() { for raw in [ "http://127.0.0.1/file", + "http://0.0.0.1/file", "http://10.0.0.8/file", + "http://100.64.0.1/file", "http://169.254.10.2/file", + "http://255.255.255.255/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://[fec0::1]/file", "http://127.0.0.1./file", // URL parsers commonly canonicalize these legacy IPv4 literal // spellings, but keep the policy test explicit so a parser @@ -279,6 +419,9 @@ mod tests { "http://localhost./file", "http://media.localhost/file", "http://localhost.localdomain/file", + "http://localhost6/file", + "http://broadcasthost/file", + "http://local/file", "http://printer.local/file", ] { assert_eq!( diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 0e977ad..987205a 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -2,6 +2,79 @@ use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; +/// Return a stable identity for an existing directory. +/// +/// Canonical paths alone are not a sufficient ownership fence on Windows, +/// where Aria2 may report different casing or separators for the same +/// directory. Pair canonicalization with the platform's filesystem identity +/// so callers compare the object a path resolves to instead of its spelling. +pub fn directory_identity(path: &Path) -> io::Result { + let canonical = std::fs::canonicalize(path)?; + let metadata = std::fs::metadata(&canonical)?; + if !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "path is not a directory", + )); + } + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + return Ok(format!("{}:{}", metadata.dev(), metadata.ino())); + } + + #[cfg(target_os = "windows")] + { + return windows_directory_identity(&canonical); + } + + #[allow(unreachable_code)] + Ok(canonical.to_string_lossy().into_owned()) +} + +#[cfg(target_os = "windows")] +fn windows_directory_identity(path: &Path) -> io::Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + + let wide_path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { + CreateFileW( + wide_path.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + + let mut metadata = BY_HANDLE_FILE_INFORMATION::default(); + let succeeded = unsafe { GetFileInformationByHandle(handle, &mut metadata) != 0 }; + let error = (!succeeded).then(io::Error::last_os_error); + unsafe { + let _ = CloseHandle(handle); + } + match error { + Some(error) => Err(error), + None => Ok(format_file_identity(&metadata)), + } +} + /// Return a stable filesystem identity for an existing Windows file without /// relying on unstable `std::fs::MetadataExt` APIs. The handle is opened with /// delete sharing so inspection does not unnecessarily block normal cleanup diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index c24626f..2caf327 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -6574,6 +6574,7 @@ fn aria2_retry_action(payload: &SpawnPayload, error: &str, strike: usize) -> Ari fn is_aria2_rpc_unavailable(error: &str) -> bool { let lower = error.to_ascii_lowercase(); is_transient_network_error(error) + || lower.contains("aria2 daemon is not ready") || lower.contains("aria2 did not become ready") || lower.contains("connection refused") || lower.contains("failed to connect") @@ -8341,6 +8342,7 @@ 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); 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) { @@ -12180,6 +12182,7 @@ mod tests { assert!(is_aria2_rpc_unavailable( "aria2 did not become ready: connection refused" )); + assert!(is_aria2_rpc_unavailable("aria2 daemon is not ready")); assert!(!is_aria2_rpc_unavailable( "aria2 error code 3: Resource not found" )); diff --git a/src-tauri/src/torrent_probe.rs b/src-tauri/src/torrent_probe.rs index 878713c..9cc0877 100644 --- a/src-tauri/src/torrent_probe.rs +++ b/src-tauri/src/torrent_probe.rs @@ -67,11 +67,17 @@ pub(crate) async fn run_metadata_probe_with_deadlines( // 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); options.insert("follow-torrent".to_string(), json!("false")); options.insert("follow-metalink".to_string(), json!("false")); let planned_gid = new_probe_gid(); options.insert("gid".to_string(), json!(&planned_gid)); - let mut cleanup_guard = ProbeCleanupGuard::new(Arc::clone(&client), metadata_path); + // No Aria2 request has started until the ownership fence is recorded. A + // setup failure is therefore a metadata failure, allowing the caller that + // created the probe directory to remove it without treating it as a live + // remote-GID cleanup failure. + let mut cleanup_guard = ProbeCleanupGuard::new(Arc::clone(&client), metadata_path) + .map_err(|error| ProbeFailure::Metadata(format!("could not prepare magnet metadata probe: {error}")))?; cleanup_guard.set_planned_gid(planned_gid); cleanup_guard.set_pending_add(tokio::spawn({ let client = Arc::clone(&client); @@ -354,20 +360,34 @@ struct ProbeCleanupGuard { planned_gid: Option, pending_add: Option>>, probe_dir: Option, + probe_dir_identity: Option, } impl ProbeCleanupGuard { - fn new(client: Arc, metadata_path: &Path) -> Self { - Self { + fn new(client: Arc, metadata_path: &Path) -> Result { + let probe_dir = metadata_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_path_buf); + let probe_dir_identity = probe_dir + .as_deref() + .ok_or_else(|| "cannot record magnet metadata probe ownership".to_string()) + .and_then(|path| { + crate::platform::directory_identity(path).map_err(|error| { + format!( + "cannot record magnet metadata probe ownership ({:?})", + error.kind() + ) + }) + })?; + Ok(Self { client, gids: Vec::new(), planned_gid: None, pending_add: None, - probe_dir: metadata_path - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .map(Path::to_path_buf), - } + probe_dir, + probe_dir_identity: Some(probe_dir_identity), + }) } fn set_planned_gid(&mut self, gid: String) { @@ -429,16 +449,23 @@ impl ProbeCleanupGuard { self.planned_gid = Some(planned_gid); return Err("cannot verify magnet metadata probe ownership".to_string()); }; + let Some(probe_dir_identity) = self.probe_dir_identity.as_ref() else { + self.planned_gid = Some(planned_gid); + return Err("cannot verify magnet metadata probe ownership".to_string()); + }; let ownership = tokio::time::timeout( deadline.saturating_duration_since(Instant::now()), - query_probe_gid_directory(self.client.as_ref(), &planned_gid), + verify_probe_gid_ownership( + self.client.as_ref(), + &planned_gid, + probe_dir, + probe_dir_identity, + ), ) .await; match ownership { - Ok(Ok(Some(directory))) if directory == *probe_dir => { - self.gids.push(planned_gid); - } - Ok(Ok(Some(_))) | Ok(Ok(None)) => { + Ok(Ok(true)) => self.gids.push(planned_gid), + Ok(Ok(false)) => { // A planned GID that belongs to another directory, or // no longer exists, is not ours to remove. This closes the // collision/ambiguous-add path without force-removing an @@ -492,6 +519,7 @@ impl ProbeCleanupGuard { self.planned_gid = None; self.pending_add = None; self.probe_dir = None; + self.probe_dir_identity = None; } } @@ -501,10 +529,12 @@ impl Drop for ProbeCleanupGuard { let mut planned_gid = self.planned_gid.take(); let pending_add = self.pending_add.take(); let probe_dir = self.probe_dir.take(); + let probe_dir_identity = self.probe_dir_identity.take(); if gids.is_empty() && planned_gid.is_none() && pending_add.is_none() && probe_dir.is_none() + && probe_dir_identity.is_none() { return; } @@ -555,16 +585,25 @@ impl Drop for ProbeCleanupGuard { ); return; }; + let Some(probe_dir_identity) = probe_dir_identity.as_ref() else { + log::warn!( + "canceled magnet metadata probe has no recorded directory identity" + ); + return; + }; match tokio::time::timeout( CANCELLATION_CLEANUP_ATTEMPT_TIMEOUT, - query_probe_gid_directory(client.as_ref(), &candidate), + verify_probe_gid_ownership( + client.as_ref(), + &candidate, + probe_dir, + probe_dir_identity, + ), ) .await { - Ok(Ok(Some(directory))) if directory == *probe_dir => { - gids.push(candidate); - } - Ok(Ok(Some(_))) | Ok(Ok(None)) => { + Ok(Ok(true)) => gids.push(candidate), + Ok(Ok(false)) => { // The candidate is either another transfer's GID or // already gone; neither case is safe to force-remove. } @@ -640,6 +679,36 @@ impl Drop for ProbeCleanupGuard { } } +/// Verify ownership of an addUri GID before force-removing it. +/// +/// The normal fence is the filesystem identity recorded before the addUri +/// call. If the probe directory has already disappeared, there is no identity +/// left to compare; in that one case an exact route/path match is sufficient +/// to remove the remote GID. forceRemove only affects Aria2 state, so this +/// closes the orphaned-GID leak without deleting a replacement directory. +async fn verify_probe_gid_ownership( + client: &C, + gid: &str, + probe_dir: &Path, + probe_dir_identity: &str, +) -> Result { + let Some(reported_directory) = query_probe_gid_directory(client, gid).await? else { + return Ok(false); + }; + + match crate::platform::directory_identity(&reported_directory) { + Ok(reported_identity) => Ok(reported_identity == probe_dir_identity), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok( + !probe_dir.exists() + && crate::platform::paths_equal(probe_dir, &reported_directory), + ), + Err(error) => Err(format!( + "could not verify magnet metadata probe directory identity ({:?})", + error.kind() + )), + } +} + async fn cleanup_metadata_probe(client: &C, gid: &str) -> Result<(), String> { match client.call("aria2.forceRemove", json!([gid])).await { Ok(result) => { @@ -675,12 +744,26 @@ async fn query_probe_gid_directory( )); } }; - let directory = result + if let Some(directory) = result .get("dir") .and_then(Value::as_str) .filter(|value| !value.is_empty()) - .ok_or_else(|| format!("aria2 gid {gid} ownership response has no directory"))?; - Ok(Some(PathBuf::from(directory))) + { + return Ok(Some(PathBuf::from(directory))); + } + if result + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| matches!(status, "complete" | "error" | "removed")) + { + // A terminal GID no longer needs a forceRemove. Some Aria2 versions + // omit `dir` from terminal status, so absence of that field is not an + // ownership uncertainty in this state. + return Ok(None); + } + Err(format!( + "aria2 gid {gid} ownership response has no directory" + )) } async fn aria2_status(client: &C, gid: &str) -> Result { @@ -1050,6 +1133,30 @@ mod tests { (temporary, probe_dir, metadata_path) } + #[tokio::test(flavor = "current_thread")] + async fn probe_setup_failure_is_reported_before_any_aria2_request() { + let temporary = tempfile::tempdir().expect("temporary probe storage should exist"); + let metadata_path = temporary.path().join("missing").join("metadata.torrent"); + let server = ScriptedRpcServer::start(Vec::new()).await; + + let error = run_metadata_probe( + server.client(), + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + Map::new(), + &metadata_path, + Duration::from_secs(1), + Duration::ZERO, + ) + .await + .expect_err("a missing probe directory should fail before Aria2 is contacted"); + assert!(matches!( + error, + ProbeFailure::Metadata(message) if message.contains("could not prepare") + )); + assert!(server.calls().is_empty()); + server.shutdown().await; + } + async fn wait_for_path(path: &Path, should_exist: bool) { let deadline = Instant::now() + Duration::from_secs(3); while Instant::now() < deadline { @@ -1528,7 +1635,10 @@ mod tests { ( "aria2.tellStatus", vec![ - status_reply_with_directory("active", &probe_dir), + // Aria2 may spell the same directory differently across + // platforms. Ownership must use filesystem identity, not + // textual PathBuf equality. + status_reply_with_directory("active", &probe_dir.join(".")), status_reply("removed"), ], ), @@ -1579,6 +1689,70 @@ mod tests { server.shutdown().await; } + #[tokio::test(flavor = "current_thread")] + async fn cleanup_removes_planned_gid_when_probe_directory_is_already_missing() { + let (_temporary, probe_dir, metadata_path) = probe_fixture().await; + let server = ScriptedRpcServer::start(scripts([ + ( + "aria2.tellStatus", + vec![ + status_reply_with_directory("active", &probe_dir), + status_reply("removed"), + ], + ), + ( + "aria2.forceRemove", + vec![ScriptedReply::Result(json!("gid-planned"))], + ), + ])) + .await; + let mut guard = ProbeCleanupGuard::new(server.client(), &metadata_path) + .expect("probe ownership should be recorded before deletion"); + guard.set_planned_gid("gid-planned".to_string()); + tokio::fs::remove_dir_all(&probe_dir) + .await + .expect("test should remove the probe directory before cleanup"); + + guard + .cleanup_until(Instant::now() + Duration::from_secs(1)) + .await + .expect("an exact missing probe path still owns the planned GID"); + assert!(server + .calls() + .iter() + .any(|(method, params)| method == "aria2.forceRemove" + && params + .as_array() + .and_then(|values| values.last()) + .and_then(Value::as_str) + == Some("gid-planned"))); + server.shutdown().await; + } + + #[tokio::test(flavor = "current_thread")] + async fn cleanup_accepts_terminal_planned_gid_without_directory() { + let (_temporary, probe_dir, metadata_path) = probe_fixture().await; + let server = ScriptedRpcServer::start(scripts([( + "aria2.tellStatus", + vec![status_reply("removed")], + )])) + .await; + let mut guard = ProbeCleanupGuard::new(server.client(), &metadata_path) + .expect("probe ownership should be recorded before cleanup"); + guard.set_planned_gid("gid-terminal".to_string()); + + guard + .cleanup_until(Instant::now() + Duration::from_secs(1)) + .await + .expect("a terminal planned GID needs no directory ownership check"); + assert!(!server.calls().iter().any(|(method, _)| + method == "aria2.forceRemove")); + server.shutdown().await; + tokio::fs::remove_dir_all(&probe_dir) + .await + .expect("terminal probe fixture should be removable"); + } + #[tokio::test(flavor = "current_thread")] async fn cleanup_uncertainty_stops_the_bounded_probe() { let server = ScriptedRpcServer::start(scripts([