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