323 lines
18 KiB
JavaScript
323 lines
18 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { createServer } from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
import { test } from 'node:test';
|
|
import { createIpfsIndex, ipfsAddUrl, packageRelease, releaseFiles, targets, uploadRelease } from './ci-release.mjs';
|
|
import { resolveProjectPath } from './project-paths.mjs';
|
|
|
|
const directoryCid = 'bafybei' + 'a'.repeat(52);
|
|
const metadata = { version: '0.3.0', commit: 'a'.repeat(64), buildTime: '2026-09-24T00:00:00Z' };
|
|
|
|
function fixture(context) {
|
|
const root = mkdtempSync(join(tmpdir(), 'linkport-ci-release-test-'));
|
|
context.after(() => {
|
|
assert.equal(dirname(resolve(root)), resolve(tmpdir()));
|
|
assert(basename(root).startsWith('linkport-ci-release-test-'));
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const input = join(root, 'binaries with spaces');
|
|
const output = join(root, 'archives with spaces');
|
|
mkdirSync(input);
|
|
writeFileSync(join(input, 'build-info.json'), JSON.stringify(metadata));
|
|
for (const target of targets) {
|
|
const directory = join(input, `${target.os}-${target.arch}`);
|
|
mkdirSync(directory);
|
|
writeFileSync(join(directory, target.os === 'windows' ? 'linkport.exe' : 'linkport'), `fixture ${target.os}/${target.arch}`, { mode: 0o755 });
|
|
}
|
|
return { root, input, output };
|
|
}
|
|
|
|
function runUpload(directory, api, token = '') {
|
|
return new Promise((resolveResult, reject) => {
|
|
const child = spawn(process.execPath, [resolveProjectPath('scripts/ci-release.mjs'), 'upload', directory], {
|
|
windowsHide: true,
|
|
env: { ...process.env, IPFS_API: api, IPFS_TOKEN: token },
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', data => { stdout += data; });
|
|
child.stderr.on('data', data => { stderr += data; });
|
|
child.once('error', reject);
|
|
child.once('close', code => resolveResult({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
async function apiFixture(context, status, response) {
|
|
const requests = [];
|
|
const server = createServer((request, reply) => {
|
|
const chunks = [];
|
|
request.on('data', data => chunks.push(data));
|
|
request.on('end', () => {
|
|
requests.push({ url: request.url, method: request.method, headers: request.headers, body: Buffer.concat(chunks).toString('latin1') });
|
|
const result = typeof response === 'function' ? response(requests.at(-1), requests.length) : { status, body: response };
|
|
reply.writeHead(result.status, { 'Content-Type': 'application/json' });
|
|
reply.end(result.body);
|
|
});
|
|
});
|
|
await new Promise(resolveReady => server.listen(0, '127.0.0.1', resolveReady));
|
|
context.after(() => new Promise(resolveClosed => server.close(resolveClosed)));
|
|
return { api: `http://127.0.0.1:${server.address().port}/prefix/api/v0/`, requests };
|
|
}
|
|
|
|
test('six platform archives retain executable, metadata and checksums without user data', context => {
|
|
const { root, input, output } = fixture(context);
|
|
const manifest = packageRelease(input, output);
|
|
assert.equal(manifest.commit, metadata.commit);
|
|
assert.deepEqual(manifest.packages.map(entry => `${entry.os}/${entry.arch}`), ['linux/amd64', 'linux/arm64', 'windows/amd64', 'windows/arm64', 'darwin/amd64', 'darwin/arm64']);
|
|
assert.equal(manifest.packages.length, 6);
|
|
assert.equal(manifest.packages.filter(entry => entry.file.endsWith('.zip')).length, 2);
|
|
assert.equal(manifest.packages.filter(entry => entry.file.endsWith('.tar.gz')).length, 4);
|
|
assert.equal(releaseFiles(output).length, 8);
|
|
assert.equal(readdirSync(output).length, 8);
|
|
const archiver = process.platform === 'win32' ? 'tar.exe' : 'bsdtar';
|
|
for (const entry of manifest.packages) {
|
|
const archive = join(output, entry.file);
|
|
assert.equal(createHash('sha256').update(readFileSync(archive)).digest('hex'), entry.sha256);
|
|
const entries = execFileSync(archiver, ['-tf', archive], { encoding: 'utf8', windowsHide: true });
|
|
assert.doesNotMatch(entries, /(?:^|\/)data\/|\.sqlite|\.netrc|node_modules/);
|
|
const extracted = join(root, `extracted-${entry.os}-${entry.arch}`);
|
|
mkdirSync(extracted);
|
|
execFileSync(archiver, ['-xf', archive, '-C', extracted], { windowsHide: true });
|
|
const folder = join(extracted, `linkport-${metadata.version}-${entry.os}-${entry.arch}`);
|
|
const binary = entry.os === 'windows' ? 'linkport.exe' : 'linkport';
|
|
assert.equal(readFileSync(join(folder, binary), 'utf8'), `fixture ${entry.os}/${entry.arch}`);
|
|
assert.equal(JSON.parse(readFileSync(join(folder, 'BUILDINFO.json'), 'utf8')).commit, metadata.commit);
|
|
assert.equal(readFileSync(join(folder, 'SHA256SUMS.txt'), 'utf8'), `${entry.binarySha256} ${binary}\n`);
|
|
}
|
|
});
|
|
|
|
test('packaging rejects missing platforms and does not overwrite an existing release', context => {
|
|
const { input, output } = fixture(context);
|
|
packageRelease(input, output);
|
|
assert.throws(() => packageRelease(input, output), /EEXIST/);
|
|
rmSync(join(input, 'linux-amd64', 'linkport'));
|
|
assert.throws(() => packageRelease(input, `${output}-missing`), /ENOENT/);
|
|
});
|
|
|
|
test('upload validation rejects modified archives and missing checksums', context => {
|
|
const { input, output } = fixture(context);
|
|
const manifest = packageRelease(input, output);
|
|
const archive = join(output, manifest.packages[0].file);
|
|
const original = readFileSync(archive);
|
|
writeFileSync(archive, 'modified');
|
|
assert.throws(() => releaseFiles(output), /Archive checksum mismatch/);
|
|
writeFileSync(archive, original);
|
|
rmSync(join(output, 'SHA256SUMS.txt'));
|
|
assert.throws(() => releaseFiles(output), /ENOENT/);
|
|
});
|
|
|
|
test('IPFS API root and api/v0 prefix normalize without duplication', () => {
|
|
for (const api of ['https://ipfs.example', 'https://ipfs.example/', 'https://ipfs.example/api/v0/']) {
|
|
const url = ipfsAddUrl(api);
|
|
assert.equal(url.pathname, '/api/v0/add');
|
|
assert.equal(url.searchParams.get('pin'), 'true');
|
|
assert.equal(url.searchParams.get('wrap-with-directory'), 'true');
|
|
assert.equal(url.searchParams.get('cid-version'), '1');
|
|
}
|
|
assert.equal(ipfsAddUrl('https://ipfs.example', false).searchParams.get('wrap-with-directory'), 'false');
|
|
assert.throws(() => ipfsAddUrl(''), /IPFS_API is required/);
|
|
assert.throws(() => ipfsAddUrl('file:///tmp/ipfs'), /HTTP or HTTPS/);
|
|
});
|
|
|
|
function multipartFiles(request) {
|
|
const boundary = request.headers['content-type'].match(/boundary=(.+)$/)[1];
|
|
return request.body.split(`--${boundary}`).flatMap(part => {
|
|
const name = part.match(/filename="([^"]+)"/);
|
|
return name ? [{ file: name[1], body: part.slice(part.indexOf('\r\n\r\n') + 4, -2) }] : [];
|
|
});
|
|
}
|
|
|
|
const archiveCids = 'abcdef'.split('').map(character => directoryCid.slice(0, -3) + character + 'aa');
|
|
|
|
function successfulResponse(request, count) {
|
|
const files = multipartFiles(request);
|
|
const records = files.map(entry => ({ Name: entry.file, Hash: count <= targets.length ? archiveCids[count - 1] : directoryCid }));
|
|
if (count === targets.length + 1) records.push({ Name: '', Hash: directoryCid });
|
|
return { status: 200, body: records.map(record => JSON.stringify(record)).join('\n') };
|
|
}
|
|
|
|
test('seven add requests pin six archives and an index directory without changing originals', async context => {
|
|
const { input, output } = fixture(context);
|
|
const manifest = packageRelease(input, output);
|
|
const files = releaseFiles(output);
|
|
const originals = files.map(file => readFileSync(join(output, file)));
|
|
const server = await apiFixture(context, 200, successfulResponse);
|
|
const token = 'fixture-secret-token';
|
|
const result = await runUpload(output, server.api, token);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.equal(server.requests.length, 7);
|
|
for (const [offset, request] of server.requests.entries()) {
|
|
const url = new URL(request.url, server.api);
|
|
assert.equal(url.pathname, '/prefix/api/v0/add');
|
|
assert.equal(url.searchParams.get('pin'), 'true');
|
|
assert.equal(url.searchParams.get('cid-version'), '1');
|
|
assert.equal(url.searchParams.get('wrap-with-directory'), offset < targets.length ? 'false' : 'true');
|
|
assert.equal(request.method, 'POST');
|
|
assert.equal(request.headers.authorization, `Bearer ${token}`);
|
|
assert.equal(request.headers['user-agent'], 'linkport-ci-release/1');
|
|
const parts = multipartFiles(request);
|
|
assert.equal(parts.length, offset < targets.length ? 1 : 4);
|
|
if (offset < targets.length) assert.equal(parts[0].file, manifest.packages[offset].file);
|
|
}
|
|
const parts = multipartFiles(server.requests[targets.length]);
|
|
assert.deepEqual(parts.map(part => part.file), ['index.html', 'ipfs-index.json', 'release.json', 'SHA256SUMS.txt']);
|
|
const index = JSON.parse(parts[1].body);
|
|
assert.equal(index.version, manifest.version);
|
|
assert.equal(index.commit, manifest.commit);
|
|
assert.equal(index.buildTime, manifest.buildTime);
|
|
assert.equal(index.packages.length, 6);
|
|
for (const [offset, entry] of index.packages.entries()) {
|
|
assert.deepEqual(entry, { os: manifest.packages[offset].os, arch: manifest.packages[offset].arch, file: manifest.packages[offset].file, cid: archiveCids[offset], sha256: manifest.packages[offset].sha256, uri: `ipfs://${archiveCids[offset]}` });
|
|
assert(parts[0].body.includes(`href="/ipfs/${entry.cid}?filename=${encodeURIComponent(entry.file)}"`));
|
|
assert(parts[0].body.includes(entry.sha256));
|
|
}
|
|
assert(parts[0].body.includes('href="release.json"'));
|
|
assert(parts[0].body.includes('href="SHA256SUMS.txt"'));
|
|
assert(!parts[0].body.includes('../'));
|
|
for (const part of parts.slice(2)) assert.equal(part.body, readFileSync(join(output, part.file), 'utf8'));
|
|
assert.equal(result.stdout.includes(token) || result.stderr.includes(token), false);
|
|
assert(result.stdout.includes(`IPFS index directory CID (pin requested): ${directoryCid}`));
|
|
assert(result.stdout.includes(`Download page:\n${new URL(server.api).origin}/ipfs/${directoryCid}/`));
|
|
const summary = result.stdout.split('=== IPFS upload complete: download URLs ===')[1];
|
|
assert(summary && !summary.includes('ipfs://'));
|
|
assert.equal(summary.split('\n').filter(line => /^https?:\/\//.test(line)).length, 11);
|
|
for (const [offset, entry] of manifest.packages.entries()) {
|
|
assert(result.stdout.includes(`Uploading IPFS archive ${offset + 1}/${targets.length}: ${entry.file}`));
|
|
}
|
|
assert(result.stdout.includes('Uploading IPFS download index (4 files)'));
|
|
const receipt = JSON.parse(readFileSync(`${output}.ipfs.json`, 'utf8'));
|
|
assert.equal(receipt.cid, directoryCid);
|
|
assert.deepEqual(receipt.files.slice(0, targets.length), index.packages);
|
|
assert.deepEqual(receipt.files.slice(targets.length), parts.map(part => ({ file: part.file, uri: `ipfs://${directoryCid}/${part.file}` })));
|
|
for (const entry of receipt.files) {
|
|
const download = new URL(`${new URL(server.api).origin}/ipfs/${entry.uri.slice('ipfs://'.length)}`);
|
|
if (entry.cid) download.searchParams.set('filename', entry.file);
|
|
assert(summary.includes(`Download ${entry.file}:\n${download.href}`));
|
|
assert(result.stdout.includes(entry.uri));
|
|
}
|
|
assert(!JSON.stringify(receipt).includes(token));
|
|
assert.deepEqual(releaseFiles(output), files);
|
|
assert.equal(readdirSync(output).length, 8);
|
|
files.forEach((file, offset) => assert.deepEqual(readFileSync(join(output, file)), originals[offset]));
|
|
const retry = await runUpload(output, server.api, token);
|
|
assert.equal(retry.code, 1);
|
|
assert.match(retry.stderr, /receipt already exists/);
|
|
assert.equal(server.requests.length, 7);
|
|
});
|
|
|
|
test('pure index generation validates configured uploads, preserves input and safely encodes HTML links', context => {
|
|
const { input, output } = fixture(context);
|
|
const manifest = packageRelease(input, output);
|
|
const entries = manifest.packages.map((entry, offset) => ({ file: entry.file, sha256: entry.sha256, cid: archiveCids[offset] }));
|
|
const before = JSON.stringify({ manifest, entries });
|
|
const result = createIpfsIndex(manifest, entries);
|
|
assert.equal(JSON.stringify({ manifest, entries }), before);
|
|
assert.deepEqual(createIpfsIndex(manifest, [...entries].reverse()), result);
|
|
assert.throws(() => createIpfsIndex(manifest, entries.slice(1)), /configured uploaded/);
|
|
assert.throws(() => createIpfsIndex(manifest, entries.map(entry => ({ ...entry, file: 'unexpected.tar.gz' }))), /Missing or duplicate/);
|
|
assert.throws(() => createIpfsIndex(manifest, [...entries, entries[0]]), /configured uploaded/);
|
|
assert.throws(() => createIpfsIndex(manifest, entries.map((entry, offset) => offset ? entry : { ...entry, sha256: '0'.repeat(64) })), /checksum mismatch/);
|
|
for (const cid of ['invalid', 'b' + 'a'.repeat(58), directoryCid.slice(0, -1) + 'f', `${directoryCid}/evil`, '\"><script>']) {
|
|
assert.throws(() => createIpfsIndex(manifest, entries.map(entry => ({ ...entry, cid }))), /Invalid IPFS file CID/);
|
|
}
|
|
const version = '0.3.0+test.1';
|
|
const changed = { ...manifest, version, buildTime: '2026-09-24T00:00:00Z' };
|
|
changed.packages = manifest.packages.map(entry => ({ ...entry, file: entry.file.replace(manifest.version, version) }));
|
|
const changedEntries = entries.map(entry => ({ ...entry, file: entry.file.replace(manifest.version, version) }));
|
|
const html = createIpfsIndex(changed, changedEntries).html;
|
|
assert(html.includes('linkport-0.3.0%2Btest.1-'));
|
|
for (const field of ['version', 'commit']) assert.throws(() => createIpfsIndex({ ...manifest, [field]: '<script>alert(1)</script>' }, entries));
|
|
const escapedTime = '2026-09-24 (<script>"&\'</script>)';
|
|
assert(Number.isFinite(Date.parse(escapedTime)));
|
|
const escaped = createIpfsIndex({ ...manifest, buildTime: escapedTime }, entries).html;
|
|
assert(escaped.includes('<script>"&'</script>'));
|
|
assert(!escaped.includes('<script>'));
|
|
});
|
|
|
|
test('checksum failures prevent any network upload', async context => {
|
|
const { input, output } = fixture(context);
|
|
const manifest = packageRelease(input, output);
|
|
const server = await apiFixture(context, 200, successfulResponse);
|
|
writeFileSync(join(output, manifest.packages[0].file), 'corrupt');
|
|
const result = await runUpload(output, server.api);
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, /checksum mismatch/);
|
|
assert.equal(server.requests.length, 0);
|
|
assert(!existsSync(`${output}.ipfs.json`));
|
|
});
|
|
|
|
test('upload fails clearly when API is missing', async context => {
|
|
const { input, output } = fixture(context);
|
|
packageRelease(input, output);
|
|
const result = await runUpload(output, '');
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, /IPFS_API is required/);
|
|
assert(!existsSync(`${output}.ipfs.json`));
|
|
});
|
|
|
|
test('partial archive and index failures never write success receipt or leak response secrets', async context => {
|
|
const { input, output } = fixture(context);
|
|
packageRelease(input, output);
|
|
const originals = releaseFiles(output).map(file => [file, readFileSync(join(output, file))]);
|
|
const token = 'fixture-secret-token';
|
|
const failures = [
|
|
{ at: 1, status: 401, body: token },
|
|
{ at: 1, status: 200, body: token },
|
|
{ at: 1, status: 200, body: JSON.stringify({ Name: token, Hash: directoryCid }) },
|
|
{ at: 1, status: 200, body: JSON.stringify({ Type: 'error', Message: token }) },
|
|
{ at: 1, status: 200, body: 'null' },
|
|
{ at: 3, status: 401, body: token },
|
|
{ at: 7, status: 413, body: token },
|
|
{ at: 7, status: 200, body: JSON.stringify({ Name: '', Hash: directoryCid }) },
|
|
{ at: 1, status: 200, mutate: records => records.map(record => ({ ...record, Hash: token })) },
|
|
{ at: 7, status: 200, mutate: records => records.map(record => record.Name === '' ? { ...record, Hash: token } : record) },
|
|
{ at: 7, status: 200, mutate: records => records.map(record => record.Name === '' ? { ...record, Name: token } : record) },
|
|
{ at: 7, status: 200, mutate: records => [records[0], records[0], ...records.slice(2)] },
|
|
];
|
|
for (const failure of failures) {
|
|
const server = await apiFixture(context, 200, (request, count) => {
|
|
const success = successfulResponse(request, count);
|
|
if (count !== failure.at) return success;
|
|
if (!failure.mutate) return failure;
|
|
const records = success.body.split('\n').map(line => JSON.parse(line));
|
|
return { status: failure.status, body: failure.mutate(records).map(record => JSON.stringify(record)).join('\n') };
|
|
});
|
|
const result = await runUpload(output, server.api, token);
|
|
assert.equal(result.code, 1, result.stdout);
|
|
assert.equal(server.requests.length, failure.at);
|
|
assert.equal(result.stdout.includes('IPFS archive add accepted (pin requested):'), failure.at > 1);
|
|
assert(!result.stdout.includes('IPFS index directory CID'));
|
|
assert(!result.stdout.includes('Download page:'));
|
|
assert(!result.stdout.includes('Download linkport-'));
|
|
assert(!existsSync(`${output}.ipfs.json`));
|
|
assert(!(result.stdout + result.stderr).includes(token));
|
|
originals.forEach(([file, bytes]) => assert.deepEqual(readFileSync(join(output, file)), bytes));
|
|
}
|
|
});
|
|
|
|
test('curl keeps credentials on stdin, disables implicit config and preserves TLS defaults', context => {
|
|
const { input, output } = fixture(context);
|
|
packageRelease(input, output);
|
|
const token = 'fixture-"quoted\\secret';
|
|
let called = false;
|
|
assert.throws(() => uploadRelease(output, 'https://ipfs.example', token, { runCurl(command, args, options) {
|
|
called = true;
|
|
assert(command === 'curl' || command === 'curl.exe');
|
|
assert.equal(args[0], '--disable');
|
|
assert.equal(args[args.indexOf('--config') + 1], '-');
|
|
assert.equal(args[args.indexOf('--user-agent') + 1], 'linkport-ci-release/1');
|
|
assert(!args.join(' ').includes(token));
|
|
assert(!args.includes('--insecure') && !args.includes('-k'));
|
|
assert.equal(options.env, undefined);
|
|
assert.equal(options.input, 'header = "Authorization: Bearer fixture-\\"quoted\\\\secret"\n');
|
|
return { status: 22, stdout: token, stderr: token };
|
|
} }), /IPFS upload failed/);
|
|
assert(called);
|
|
assert(!existsSync(`${output}.ipfs.json`));
|
|
assert.throws(() => uploadRelease(output, 'https://ipfs.example', 'bad\nsecret'), /line breaks/);
|
|
});
|