409 lines
13 KiB
JavaScript
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();
|
|
}
|