fix(network): harden TUN-aware DNS routing (#38)

- Keep hostname resolution on the active OS, TUN, proxy, or Aria2 route while blocking literal local targets.\n- Propagate and attest the Firelink Aria2 resolver and network-policy contract across normal, Torrent, and magnet paths.\n- Fence magnet probe cleanup, redirect/range policy, lifecycle races, and engine staging with regression and smoke coverage.
This commit is contained in:
NimBold
2026-08-31 07:36:24 +03:30
parent 725f5e40ec
commit 4777f1e3c3
15 changed files with 745 additions and 92 deletions
+10 -4
View File
@@ -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",
+89
View File
@@ -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}`,
);
}
}
}
+64
View File
@@ -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/,
);
});
+16 -1
View File
@@ -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 }
: {})
}
])
),
+19 -17
View File
@@ -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);
+20 -1
View File
@@ -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') {
+20 -6
View File
@@ -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');
+14
View File
@@ -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) {
+6
View File
@@ -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}`);
Binary file not shown.
+70 -39
View File
@@ -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<serde_json::Value, String> {
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::<Aria2DaemonGuard>();
*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.
+145 -2
View File
@@ -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<String, serde_json::Value>,
) {
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<String> {
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!(
+73
View File
@@ -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<String> {
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<String> {
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::<Vec<_>>();
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
+3
View File
@@ -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"
));
+196 -22
View File
@@ -67,11 +67,17 @@ pub(crate) async fn run_metadata_probe_with_deadlines<C: RpcClient + 'static>(
// 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<C: RpcClient + 'static> {
planned_gid: Option<String>,
pending_add: Option<tokio::task::JoinHandle<Result<Value, String>>>,
probe_dir: Option<PathBuf>,
probe_dir_identity: Option<String>,
}
impl<C: RpcClient + 'static> ProbeCleanupGuard<C> {
fn new(client: Arc<C>, metadata_path: &Path) -> Self {
Self {
fn new(client: Arc<C>, metadata_path: &Path) -> Result<Self, String> {
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<C: RpcClient + 'static> ProbeCleanupGuard<C> {
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<C: RpcClient + 'static> ProbeCleanupGuard<C> {
self.planned_gid = None;
self.pending_add = None;
self.probe_dir = None;
self.probe_dir_identity = None;
}
}
@@ -501,10 +529,12 @@ impl<C: RpcClient + 'static> Drop for ProbeCleanupGuard<C> {
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<C: RpcClient + 'static> Drop for ProbeCleanupGuard<C> {
);
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<C: RpcClient + 'static> Drop for ProbeCleanupGuard<C> {
}
}
/// 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<C: RpcClient>(
client: &C,
gid: &str,
probe_dir: &Path,
probe_dir_identity: &str,
) -> Result<bool, String> {
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<C: RpcClient>(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<C: RpcClient>(
));
}
};
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<C: RpcClient>(client: &C, gid: &str) -> Result<String, String> {
@@ -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([