252 lines
7.7 KiB
JavaScript
252 lines
7.7 KiB
JavaScript
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
assertLicenseReady,
|
|
collectDirectoryEntries,
|
|
createDeterministicZip,
|
|
parseReleaseArguments,
|
|
safeArchivePath,
|
|
} from '../../scripts/package-release.mjs';
|
|
import {
|
|
checksumLine,
|
|
parseChecksumLine,
|
|
sha256,
|
|
verifyChecksum,
|
|
writeChecksum,
|
|
} from '../../scripts/checksum-release.mjs';
|
|
import {
|
|
inspectPortalHeaders,
|
|
parsePortalArguments,
|
|
} from '../../scripts/portal-assembly-smoke.mjs';
|
|
|
|
const temporaryDirectories = [];
|
|
|
|
async function temporaryDirectory() {
|
|
const directory = await mkdtemp(
|
|
path.join(tmpdir(), 'av-tools-release-test-')
|
|
);
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { force: true, recursive: true }))
|
|
);
|
|
});
|
|
|
|
describe('release path safety', () => {
|
|
it.each([
|
|
'',
|
|
'/index.html',
|
|
'../index.html',
|
|
'assets/../index.html',
|
|
'assets//main.js',
|
|
'C:/index.html',
|
|
'assets\\main.js',
|
|
'assets/\0main.js',
|
|
])('rejects unsafe archive path %j', (unsafePath) => {
|
|
expect(() => safeArchivePath(unsafePath)).toThrow();
|
|
});
|
|
|
|
it('accepts a canonical nested path', () => {
|
|
expect(safeArchivePath('vendor/ffmpeg/core.wasm')).toBe(
|
|
'vendor/ffmpeg/core.wasm'
|
|
);
|
|
});
|
|
|
|
it('rejects symlinks while collecting a directory', async () => {
|
|
const directory = await temporaryDirectory();
|
|
await writeFile(path.join(directory, 'target.txt'), 'target');
|
|
await symlink('target.txt', path.join(directory, 'alias.txt'));
|
|
await expect(collectDirectoryEntries(directory)).rejects.toThrow(
|
|
/symlink/i
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deterministic release ZIP', () => {
|
|
const entries = [
|
|
{ name: 'z.txt', data: Buffer.from('last') },
|
|
{ name: 'a.txt', data: Buffer.from('first') },
|
|
];
|
|
|
|
it('is byte-identical regardless of input order', () => {
|
|
const first = createDeterministicZip(entries);
|
|
const second = createDeterministicZip([...entries].reverse());
|
|
expect(first.equals(second)).toBe(true);
|
|
expect(sha256(first)).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it('stores sorted UTF-8 entries with the normalized epoch and file mode', () => {
|
|
const archive = createDeterministicZip(entries);
|
|
expect(archive.readUInt32LE(0)).toBe(0x04034b50);
|
|
expect(archive.readUInt16LE(10)).toBe(0);
|
|
expect(archive.readUInt16LE(12)).toBe(33);
|
|
expect(archive.subarray(30, 35).toString('utf8')).toBe('a.txt');
|
|
|
|
const centralOffset = archive.indexOf(
|
|
Buffer.from([0x50, 0x4b, 0x01, 0x02])
|
|
);
|
|
expect(centralOffset).toBeGreaterThan(0);
|
|
expect(archive.readUInt16LE(centralOffset + 4)).toBe(0x0314);
|
|
expect(archive.readUInt32LE(centralOffset + 38) >>> 16).toBe(0o100644);
|
|
});
|
|
|
|
it('rejects duplicate paths and local absolute paths in text', () => {
|
|
expect(() =>
|
|
createDeterministicZip([
|
|
{ name: 'same.txt', data: Buffer.from('one') },
|
|
{ name: 'same.txt', data: Buffer.from('two') },
|
|
])
|
|
).toThrow(/duplicate/i);
|
|
expect(() =>
|
|
createDeterministicZip([
|
|
{ name: 'bundle.js', data: Buffer.from('source: "/home/user/file"') },
|
|
])
|
|
).toThrow(/local absolute path/i);
|
|
});
|
|
});
|
|
|
|
describe('licence release gate', () => {
|
|
it('blocks a normal release without an application licence', async () => {
|
|
const directory = await temporaryDirectory();
|
|
await expect(assertLicenseReady(directory)).rejects.toThrow(
|
|
/RELEASE BLOCKED/
|
|
);
|
|
});
|
|
|
|
it('allows only the explicit development-smoke escape hatch', async () => {
|
|
const directory = await temporaryDirectory();
|
|
await expect(assertLicenseReady(directory, true)).resolves.toBeNull();
|
|
expect(
|
|
parseReleaseArguments(['--allow-unlicensed-development-smoke'])
|
|
).toEqual({
|
|
allowUnlicensedDevelopmentSmoke: true,
|
|
force: false,
|
|
});
|
|
expect(() => parseReleaseArguments(['--publish'])).toThrow(/unknown/i);
|
|
});
|
|
|
|
it('recognizes a complete GPL version 3 root application licence', async () => {
|
|
const directory = await temporaryDirectory();
|
|
const licence = path.join(directory, 'LICENSE');
|
|
await writeFile(
|
|
licence,
|
|
[
|
|
'GNU GENERAL PUBLIC LICENSE',
|
|
'Version 3, 29 June 2007',
|
|
'END OF TERMS AND CONDITIONS',
|
|
].join('\n')
|
|
);
|
|
await expect(assertLicenseReady(directory)).resolves.toBe(licence);
|
|
});
|
|
|
|
it('rejects an arbitrary file named LICENSE', async () => {
|
|
const directory = await temporaryDirectory();
|
|
await writeFile(path.join(directory, 'LICENSE'), 'example only');
|
|
await expect(assertLicenseReady(directory)).rejects.toThrow(
|
|
/complete GNU GPL version 3/i
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('release checksum', () => {
|
|
it('formats and parses the portable sidecar form', () => {
|
|
const digest = 'a'.repeat(64);
|
|
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
|
|
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
|
|
expect(parseChecksumLine(line)).toEqual({
|
|
archiveName: 'av-tools-0.1.0.zip',
|
|
digest,
|
|
});
|
|
});
|
|
|
|
it('writes and verifies a checksum sidecar', async () => {
|
|
const directory = await temporaryDirectory();
|
|
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
|
await writeFile(archive, 'deterministic bytes');
|
|
const result = await writeChecksum(archive);
|
|
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
|
result.digest
|
|
);
|
|
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
|
' av-tools-0.1.0.zip'
|
|
);
|
|
});
|
|
|
|
it('rejects checksum mismatches', async () => {
|
|
const directory = await temporaryDirectory();
|
|
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
|
const sidecar = `${archive}.sha256`;
|
|
await writeFile(archive, 'changed');
|
|
await mkdir(path.dirname(sidecar), { recursive: true });
|
|
await writeFile(
|
|
sidecar,
|
|
checksumLine('0'.repeat(64), path.basename(archive))
|
|
);
|
|
await expect(verifyChecksum(archive, sidecar)).rejects.toThrow(/mismatch/i);
|
|
});
|
|
});
|
|
|
|
describe('portal assembly smoke validation', () => {
|
|
const isolatedHeaders = `
|
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
|
|
types { application/wasm wasm; }
|
|
# vendor/ffmpeg/0.12.10
|
|
`;
|
|
|
|
it('accepts the required isolation, CSP and MIME profile', () => {
|
|
expect(inspectPortalHeaders(isolatedHeaders)).toEqual({
|
|
errors: [],
|
|
warnings: [],
|
|
});
|
|
});
|
|
|
|
it('detects the current media-preview CSP gap', () => {
|
|
const inspection = inspectPortalHeaders(
|
|
isolatedHeaders.replace("media-src 'self' blob:;", '')
|
|
);
|
|
expect(inspection.errors).toContain(
|
|
"CSP media-src must contain 'self' and blob:"
|
|
);
|
|
});
|
|
|
|
it('parses only explicit smoke overrides and paths', () => {
|
|
const parsed = parsePortalArguments(
|
|
[
|
|
'--artifact',
|
|
'release/example.zip',
|
|
'--allow-unlicensed-development-smoke',
|
|
'--allow-known-header-gap',
|
|
'--keep-temp',
|
|
],
|
|
'/work/av-tools'
|
|
);
|
|
expect(parsed).toEqual({
|
|
allowKnownHeaderGap: true,
|
|
allowUnlicensedDevelopmentSmoke: true,
|
|
artifact: '/work/av-tools/release/example.zip',
|
|
keepTemp: true,
|
|
portalRoot: '/work/references/toolbox-portal',
|
|
});
|
|
expect(() => parsePortalArguments(['--publish'], '/work/av-tools')).toThrow(
|
|
/unknown/i
|
|
);
|
|
});
|
|
});
|