mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 09:16:57 +00:00
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:
+10
-4
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
])
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user