mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 02:40:21 +00:00
488 lines
19 KiB
JavaScript
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');
|