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(); }