Files
av-tools/scripts/portal-assembly-smoke.mjs

409 lines
13 KiB
JavaScript

import { spawn } from 'node:child_process';
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sha256File, verifyChecksum } from './checksum-release.mjs';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
function consumeValue(argumentsList, index, option) {
const value = argumentsList[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${option} requires a path value.`);
}
return value;
}
export function parsePortalArguments(argumentsList, root = repositoryRoot) {
const packageDefaults = {
artifact: undefined,
portalRoot: path.join(path.dirname(root), 'references', 'toolbox-portal'),
allowUnlicensedDevelopmentSmoke: false,
allowKnownHeaderGap: false,
keepTemp: false,
};
for (let index = 0; index < argumentsList.length; index += 1) {
const argument = argumentsList[index];
if (argument === '--artifact') {
packageDefaults.artifact = path.resolve(
root,
consumeValue(argumentsList, index, argument)
);
index += 1;
} else if (argument === '--portal-root') {
packageDefaults.portalRoot = path.resolve(
root,
consumeValue(argumentsList, index, argument)
);
index += 1;
} else if (argument === '--allow-unlicensed-development-smoke') {
packageDefaults.allowUnlicensedDevelopmentSmoke = true;
} else if (argument === '--allow-known-header-gap') {
packageDefaults.allowKnownHeaderGap = true;
} else if (argument === '--keep-temp') {
packageDefaults.keepTemp = true;
} else {
throw new Error(`Unknown portal-smoke argument: ${argument}`);
}
}
return packageDefaults;
}
function run(command, argumentsList, options = {}) {
console.log(`\n> ${command} ${argumentsList.join(' ')}`);
return new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd: options.cwd,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
stdio: 'inherit',
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else {
reject(
new Error(
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`
)
);
}
});
});
}
async function capture(command, argumentsList, cwd) {
return await new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = [];
const stderr = [];
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8').trim());
} else {
reject(
new Error(
`${command} exited ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`
)
);
}
});
});
}
async function requireRegularFile(file, label) {
const details = await lstat(file);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`${label} is not a regular file: ${file}`);
}
}
function includesDevelopmentMarker(archive) {
return archive.includes(
Buffer.from('DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt', 'utf8')
);
}
function cspDirectives(policy) {
return new Map(
policy
.split(';')
.map((directive) => directive.trim().split(/\s+/))
.filter((parts) => parts[0])
.map(([name, ...values]) => [name, new Set(values)])
);
}
export function inspectPortalHeaders(configuration) {
const errors = [];
const warnings = [];
const requiredHeaders = [
['Cross-Origin-Opener-Policy', 'same-origin'],
['Cross-Origin-Embedder-Policy', 'require-corp'],
['Cross-Origin-Resource-Policy', 'same-origin'],
];
for (const [header, value] of requiredHeaders) {
const expression = new RegExp(
`add_header\\s+${header}\\s+"?${value}"?\\s+always`,
'i'
);
if (!expression.test(configuration)) {
errors.push(`Missing ${header}: ${value}`);
}
}
const cspMatch =
/add_header\s+Content-Security-Policy\s+"([^"]+)"\s+always/i.exec(
configuration
);
if (!cspMatch) {
errors.push('Missing Content-Security-Policy header');
} else {
const directives = cspDirectives(cspMatch[1]);
const requires = (directive, value) =>
directives.get(directive)?.has(value) === true;
if (!requires('script-src', "'wasm-unsafe-eval'")) {
errors.push("CSP script-src lacks 'wasm-unsafe-eval'");
}
if (!requires('worker-src', "'self'") || !requires('worker-src', 'blob:')) {
errors.push("CSP worker-src must contain 'self' and blob:");
}
if (!requires('media-src', "'self'") || !requires('media-src', 'blob:')) {
errors.push("CSP media-src must contain 'self' and blob:");
}
}
if (!/application\/wasm\s+wasm(?:\s|;)/i.test(configuration)) {
warnings.push(
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
);
}
if (
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
!configuration.includes('vendor/ffmpeg/0.12.10')
) {
warnings.push(
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
);
}
return { errors, warnings };
}
async function verifyAssembly(output, appVersion, digest) {
const catalogue = JSON.parse(
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
);
if (
catalogue.apps?.length !== 1 ||
catalogue.apps[0]?.manifest !== './apps/av/toolbox-app.json'
) {
throw new Error('Generated catalogue does not contain the locked av app.');
}
const release = JSON.parse(
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
);
const provenance = release.apps?.[0];
if (
release.apps?.length !== 1 ||
provenance.id !== 'de.add-ideas.av-tools' ||
provenance.version !== appVersion ||
provenance.sha256 !== digest ||
provenance.target !== 'av'
) {
throw new Error('Generated release provenance does not match the AV lock.');
}
const appRoot = path.join(output, 'apps', 'av');
const manifest = JSON.parse(
await readFile(path.join(appRoot, 'toolbox-app.json'), 'utf8')
);
if (
manifest.id !== 'de.add-ideas.av-tools' ||
manifest.version !== appVersion ||
manifest.requirements?.crossOriginIsolated !== false
) {
throw new Error(
'Assembled AV manifest identity/fallback contract changed.'
);
}
for (const reference of [
'./index.html',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
]) {
const relative = reference.replace(/^\.\//, '');
await requireRegularFile(
path.join(appRoot, ...relative.split('/')),
`Assembled ${reference}`
);
}
const indexHtml = await readFile(path.join(appRoot, 'index.html'), 'utf8');
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
throw new Error('Assembled app contains a root-absolute entry asset URL.');
}
}
async function main() {
const options = parsePortalArguments(process.argv.slice(2));
const packageJson = JSON.parse(
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
);
const artifact =
options.artifact ??
path.join(repositoryRoot, 'release', `av-tools-${packageJson.version}.zip`);
const sidecar = `${artifact}.sha256`;
await requireRegularFile(artifact, 'AV release artifact');
await requireRegularFile(sidecar, 'AV release checksum');
const digest = await verifyChecksum(artifact, sidecar);
const artifactBytes = await readFile(artifact);
const markedUnlicensed = includesDevelopmentMarker(artifactBytes);
if (markedUnlicensed && !options.allowUnlicensedDevelopmentSmoke) {
throw new Error(
'The ZIP is marked unlicensed development-only. Pass `--allow-unlicensed-development-smoke` for local assembly testing.'
);
}
const portalRootDetails = await lstat(options.portalRoot);
if (portalRootDetails.isSymbolicLink() || !portalRootDetails.isDirectory()) {
throw new Error(
`Portal reference must be a real Git directory: ${options.portalRoot}`
);
}
const portalRevision = await capture(
'git',
['rev-parse', 'HEAD'],
options.portalRoot
);
const temporaryRoot = await mkdtemp('/tmp/av-tools-portal-smoke-');
if (!temporaryRoot.startsWith('/tmp/av-tools-portal-smoke-')) {
throw new Error(`Unexpected temporary path: ${temporaryRoot}`);
}
const portalClone = path.join(temporaryRoot, 'toolbox-portal');
let successful = false;
try {
await run(
'git',
[
'clone',
'--no-local',
'--quiet',
'--no-checkout',
options.portalRoot,
portalClone,
],
{ cwd: temporaryRoot }
);
await run('git', ['checkout', '--quiet', '--detach', portalRevision], {
cwd: portalClone,
});
const clonedRevision = await capture(
'git',
['rev-parse', 'HEAD'],
portalClone
);
if (clonedRevision !== portalRevision) {
throw new Error('Temporary Portal clone revision changed unexpectedly.');
}
const smokeArtifacts = path.join(portalClone, 'smoke-artifacts');
await mkdir(smokeArtifacts);
const copiedArtifact = path.join(smokeArtifacts, path.basename(artifact));
await copyFile(artifact, copiedArtifact);
await copyFile(sidecar, `${copiedArtifact}.sha256`);
const templatePath = path.join(
portalClone,
'release',
'toolbox.lock.example.json'
);
const lock = JSON.parse(await readFile(templatePath, 'utf8'));
const portalPackage = JSON.parse(
await readFile(path.join(portalClone, 'package.json'), 'utf8')
);
lock.releaseVersion = `${packageJson.version}-smoke.0`;
lock.portalVersion = portalPackage.version;
lock.apps = [
{
id: 'de.add-ideas.av-tools',
version: packageJson.version,
artifact: `../smoke-artifacts/${path.basename(artifact)}`,
sha256: digest,
target: 'av',
},
];
const lockPath = path.join(
portalClone,
'release',
'av-tools.smoke.lock.json'
);
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, {
flag: 'wx',
});
await run('npm', ['ci'], { cwd: portalClone });
await run('npm', ['test'], { cwd: portalClone });
await run('npm', ['run', 'build'], { cwd: portalClone });
const output = path.join(portalClone, 'build', 'toolbox-av-smoke');
await run(
'npm',
[
'run',
'assemble',
'--',
'--lock',
'release/av-tools.smoke.lock.json',
'--portal-dist',
'dist',
'--output',
'build/toolbox-av-smoke',
'--archive',
'build/add-ideas-toolbox-av-smoke.zip',
],
{ cwd: portalClone }
);
await verifyAssembly(output, packageJson.version, digest);
const headerInspection = inspectPortalHeaders(
await readFile(path.join(portalClone, 'deploy', 'nginx.conf'), 'utf8')
);
for (const warning of headerInspection.warnings) {
console.warn(`Portal header warning: ${warning}`);
}
if (headerInspection.errors.length > 0) {
const message = `Portal header gaps: ${headerInspection.errors.join('; ')}`;
if (!options.allowKnownHeaderGap) throw new Error(message);
console.warn(
`KNOWN HEADER GAP ACCEPTED FOR DEVELOPMENT SMOKE: ${message}`
);
}
successful = true;
console.log('\nPortal assembly smoke passed');
console.log(`Portal revision: ${portalRevision}`);
console.log(`AV artifact SHA-256: ${digest}`);
console.log('Assembled target: apps/av/');
console.log(
'Validated: checksum, extraction, manifest identity/version, catalogue, ST/MT assets, relative entry URLs and ST fallback contract.'
);
} finally {
if (options.keepTemp) {
console.log(
`${successful ? 'Kept' : 'Failed; kept'} temporary Portal smoke directory: ${temporaryRoot}`
);
} else {
await rm(temporaryRoot, { force: true, recursive: true });
}
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}