Files
Firelink/scripts/smoke-aria2-transfers.js
T

488 lines
19 KiB
JavaScript

#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
const platform = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[process.platform];
if (!arch || !platform) throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
const targetTriple = `${arch}-${platform}`;
const argumentIndex = process.argv.indexOf('--binary');
const binaryPath = path.resolve(argumentIndex >= 0
? process.argv[argumentIndex + 1]
: path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`));
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Could not allocate local fixture port');
return address.port;
}
async function availablePort() {
const server = net.createServer();
const port = await listen(server);
await new Promise(resolve => server.close(resolve));
return port;
}
async function rpc(port, secret, method, params = []) {
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method,
params: [`token:${secret}`, ...params],
}),
signal: AbortSignal.timeout(5000),
});
const body = await response.json();
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
return body.result;
}
async function waitForRpc(port, secret) {
const deadline = Date.now() + 10000;
let lastError;
while (Date.now() < deadline) {
try {
return await rpc(port, secret, 'aria2.getVersion');
} catch (error) {
lastError = error;
await wait(100);
}
}
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
}
async function waitForTerminal(port, secret, gid, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
let latest;
while (Date.now() < deadline) {
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, [
'status', 'errorCode', 'errorMessage', 'completedLength', 'totalLength',
]]);
if (['complete', 'error', 'removed'].includes(latest.status)) return latest;
await wait(100);
}
throw new Error(`Aria2 gid ${gid} did not become terminal: ${JSON.stringify(latest)}`);
}
async function waitForProgress(port, secret, gid, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
let latest;
while (Date.now() < deadline) {
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, ['status', 'completedLength']]);
if (latest.status === 'active' && Number(latest.completedLength) > 0) return latest;
await wait(25);
}
throw new Error(`Aria2 gid ${gid} made no observable progress: ${JSON.stringify(latest)}`);
}
function serveBuffer(request, response, buffer) {
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
if (!match) {
response.writeHead(200, { 'content-length': String(buffer.length), 'accept-ranges': 'bytes' });
response.end(buffer);
return;
}
const start = Number(match[1]);
const requestedEnd = match[2] ? Number(match[2]) : buffer.length - 1;
const end = Math.min(requestedEnd, buffer.length - 1);
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
response.end();
return;
}
const body = buffer.subarray(start, end + 1);
response.writeHead(206, {
'content-length': String(body.length),
'content-range': `bytes ${start}-${end}/${buffer.length}`,
'accept-ranges': 'bytes',
});
response.end(body);
}
function serveThrottledBuffer(request, response, buffer) {
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
const start = match ? Number(match[1]) : 0;
const requestedEnd = match && match[2] ? Number(match[2]) : buffer.length - 1;
const end = Math.min(requestedEnd, buffer.length - 1);
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
response.end();
return;
}
const body = buffer.subarray(start, end + 1);
response.writeHead(match ? 206 : 200, {
'content-length': String(body.length),
...(match ? { 'content-range': `bytes ${start}-${end}/${buffer.length}` } : {}),
'accept-ranges': 'bytes',
});
let offset = 0;
const timer = setInterval(() => {
if (response.destroyed || offset >= body.length) {
clearInterval(timer);
if (!response.destroyed) response.end();
return;
}
const next = Math.min(offset + 32 * 1024, body.length);
response.write(body.subarray(offset, next));
offset = next;
}, 20);
response.once('close', () => clearInterval(timer));
}
function childExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForChildExit(child, timeoutMs = 3000) {
if (childExited(child)) return true;
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.off('exit', onExit);
resolve(result);
};
const onExit = () => finish(true);
const timer = setTimeout(() => finish(false), timeoutMs);
child.once('exit', onExit);
if (childExited(child)) finish(true);
});
}
async function stop(child, port, secret) {
if (!child || childExited(child)) return;
try {
await rpc(port, secret, 'aria2.shutdown');
} catch {
// It may already be stopping.
}
let exited = await waitForChildExit(child);
if (!exited) {
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', timeout: 3000 });
} catch {
// It may have exited between the timeout and taskkill.
}
} else {
child.kill('SIGTERM');
}
exited = await waitForChildExit(child);
}
if (!exited && process.platform !== 'win32') {
child.kill('SIGKILL');
exited = await waitForChildExit(child);
}
if (!exited) throw new Error(`Aria2 process ${child.pid} did not exit after cleanup`);
}
async function removeTempRoot(tempRoot) {
let lastError;
for (let attempt = 1; attempt <= 10; attempt += 1) {
try {
fs.rmSync(tempRoot, { recursive: true, force: true });
return;
} catch (error) {
lastError = error;
await wait(100 * attempt);
}
}
throw lastError;
}
const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a);
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
const serverStatOutputPath = path.join(tempRoot, 'server-stat.next');
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
let finalRequests = 0;
let finalCredentials = [];
let redirectSourceCredentials;
let smokePassed = false;
let smokeFailure;
const targetServer = http.createServer((request, response) => {
finalRequests += 1;
finalCredentials.push({
authorization: request.headers.authorization,
cookie: request.headers.cookie,
custom: request.headers['x-firelink-secret'],
});
serveBuffer(request, response, payload);
});
const targetPort = await listen(targetServer);
const fixtureServer = http.createServer((request, response) => {
switch (new URL(request.url, 'http://fixture.invalid').pathname) {
case '/range':
serveBuffer(request, response, payload);
break;
case '/no-range':
response.writeHead(200, { 'content-length': String(payload.length) });
response.end(payload);
break;
case '/throttled':
serveThrottledBuffer(request, response, payload);
break;
case '/authenticated': {
const expectedAuthorization = `Basic ${Buffer.from('fixture-user:fixture-password').toString('base64')}`;
if (request.headers.authorization !== expectedAuthorization
|| request.headers.cookie !== 'fixture-cookie=present'
|| request.headers['x-firelink-auth'] !== 'present') {
response.writeHead(401, { 'content-length': '0' });
response.end();
break;
}
serveBuffer(request, response, payload);
break;
}
case '/redirect':
redirectSourceCredentials = {
authorization: request.headers.authorization,
cookie: request.headers.cookie,
custom: request.headers['x-firelink-secret'],
};
response.writeHead(302, { location: `http://127.0.0.1:${targetPort}/final` });
response.end();
break;
case '/missing':
response.writeHead(404, { 'content-length': '0' });
response.end();
break;
case '/malformed':
response.writeHead(200, { 'content-length': String(payload.length * 2) });
response.write(payload.subarray(0, 1024));
response.destroy();
break;
case '/slow': {
const slowLength = 64 * 1024;
let written = 0;
response.writeHead(200, { 'content-length': String(slowLength) });
const timer = setInterval(() => {
if (response.destroyed || written >= slowLength) {
clearInterval(timer);
if (!response.destroyed) response.end();
return;
}
response.write(Buffer.alloc(1024, 0x73));
written += 1024;
}, 500);
response.once('close', () => clearInterval(timer));
break;
}
default:
response.writeHead(404, { 'content-length': '0' });
response.end();
}
});
const fixturePort = await listen(fixtureServer);
const rpcPort = await availablePort();
const unavailableProxyPort = await availablePort();
const secret = `firelink-transfers-${crypto.randomUUID()}`;
const configPath = path.join(tempRoot, 'aria2.conf');
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
const environment = fs.existsSync(libraryPath)
? {
...process.env,
OPENSSL_MODULES: libraryPath,
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
}
: process.env;
const child = spawn(binaryPath, [
'--enable-rpc=true',
`--conf-path=${configPath}`,
`--rpc-listen-port=${rpcPort}`,
'--rpc-listen-all=false',
`--dir=${tempRoot}`,
'--file-allocation=none',
'--enable-dht=false',
'--console-log-level=error',
'--quiet=true',
`--server-stat-if=${serverStatPath}`,
`--server-stat-of=${serverStatOutputPath}`,
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
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`], {
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
}]);
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
if (rangeStatus.status !== 'complete' || !fs.readFileSync(path.join(tempRoot, 'range.bin')).equals(payload)) {
throw new Error(`bounded-range transfer failed: ${JSON.stringify(rangeStatus)}`);
}
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
}]);
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
throw new Error('single-connection no-range transfer did not complete');
}
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
}]);
if ((await waitForTerminal(rpcPort, secret, authenticatedGid)).status !== 'complete') {
throw new Error('authenticated cookie/header transfer did not complete');
}
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
out: 'resume.bin', split: '1', continue: 'true',
}]);
await waitForProgress(rpcPort, secret, resumeGid);
await rpc(rpcPort, secret, 'aria2.pause', [resumeGid]);
const pausedStatus = await rpc(rpcPort, secret, 'aria2.tellStatus', [resumeGid, ['status', 'completedLength']]);
if (pausedStatus.status !== 'paused' || Number(pausedStatus.completedLength) <= 0) {
throw new Error(`normal transfer did not pause with resumable progress: ${JSON.stringify(pausedStatus)}`);
}
await rpc(rpcPort, secret, 'aria2.unpause', [resumeGid]);
if ((await waitForTerminal(rpcPort, secret, resumeGid)).status !== 'complete') {
throw new Error('paused normal transfer did not resume to completion');
}
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
out: 'cancel.bin', split: '1',
}]);
await waitForProgress(rpcPort, secret, cancelGid);
await rpc(rpcPort, secret, 'aria2.remove', [cancelGid]);
const cancelStatus = await waitForTerminal(rpcPort, secret, cancelGid);
if (cancelStatus.status !== 'removed') {
throw new Error(`normal transfer cancellation did not reach removed: ${JSON.stringify(cancelStatus)}`);
}
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' }]);
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`], {
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`], {
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
}]);
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
if (mismatchStatus.status !== 'error') throw new Error(`checksum mismatch was not rejected: ${JSON.stringify(mismatchStatus)}`);
// Model Firelink's manual preflight: credentials reach the original origin,
// the redirect is not followed by the HTTP client, and Aria2 receives only
// the resolved cross-origin URL without credential options.
const redirectProbe = await fetch(`http://127.0.0.1:${fixturePort}/redirect`, {
headers: {
Range: 'bytes=0-0',
Authorization: 'Bearer fixture-secret',
Cookie: 'fixture=secret',
'X-Firelink-Secret': 'fixture',
},
redirect: 'manual',
signal: AbortSignal.timeout(5000),
});
if (redirectProbe.status !== 302) throw new Error(`redirect preflight returned HTTP ${redirectProbe.status}`);
if (!redirectSourceCredentials || Object.values(redirectSourceCredentials).some(value => !value)) {
throw new Error(`redirect source did not receive its scoped credentials: ${JSON.stringify(redirectSourceCredentials)}`);
}
const redirectLocation = redirectProbe.headers.get('location');
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()], {
out: 'redirect.bin',
}]);
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
if (redirectStatus.status !== 'complete' || finalRequests === 0 || finalCredentials.some(headers => Object.values(headers).some(Boolean))) {
throw new Error(`redirect credential boundary failed: ${JSON.stringify({ redirectStatus, finalRequests, finalCredentials })}`);
}
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
out: 'missing.bin', 'max-tries': '1',
}]);
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
if (missingStatus.status !== 'error' || !['3', '4'].includes(missingStatus.errorCode)) {
throw new Error(`not-found error classification changed: ${JSON.stringify(missingStatus)}`);
}
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
}]);
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
if (lowSpeedStatus.status !== 'error' || lowSpeedStatus.errorCode !== '5') {
throw new Error(`low-speed error classification changed: ${JSON.stringify(lowSpeedStatus)}`);
}
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
out: 'malformed.bin', 'max-tries': '1',
}]);
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
throw new Error('malformed response unexpectedly completed');
}
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
}]);
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
throw new Error('unavailable proxy unexpectedly completed');
}
smokePassed = true;
} catch (error) {
const detail = stderr.trim();
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
} finally {
try {
await stop(child, rpcPort, secret);
if (smokePassed) {
const stat = fs.readFileSync(serverStatOutputPath, 'utf8');
if (!stat.includes('host=127.0.0.1')) {
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
}
}
} catch (error) {
if (!smokeFailure) smokeFailure = error;
} finally {
try {
await Promise.all([
new Promise(resolve => fixtureServer.close(resolve)),
new Promise(resolve => targetServer.close(resolve)),
]);
await removeTempRoot(tempRoot);
} catch (error) {
if (!smokeFailure) smokeFailure = error;
}
}
}
if (smokeFailure) throw smokeFailure;
console.log('[PASS] Aria2 normal transfers, auth/cookies, resume/cancel, mirrors, integrity, redirects, low-speed/not-found classification, malformed responses, proxy failures, and server statistics');