#!/usr/bin/env node import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, extname, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { fileTypeFromFile } from 'file-type'; import { hashFile } from './lib/hash-file.mjs'; import { loadPrivateMapping, loadRecipes, readJson, resolvePrivateSource } from './lib/asset-recipes.mjs'; import { mergeAssetRecords } from './lib/merge-assets.mjs'; import { loadDenylist, scanBinary, scanImage } from './lib/privacy-scan.mjs'; import { encodeVideoDerivatives, extractVideoFrames, probeVideo } from './lib/video-frames.mjs'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const fail = (rule) => { throw new Error(rule); }; const isErrno = (error, code) => typeof error === 'object' && error !== null && 'code' in error && error.code === code; const canonicalizePathRole = async (path) => { let cursor = resolve(path); const suffix = []; while (true) { try { return resolve(await realpath(cursor), ...suffix); } catch (error) { if (!isErrno(error, 'ENOENT')) fail('ASSET-PATH-001'); const parent = dirname(cursor); if (parent === cursor) fail('ASSET-PATH-001'); suffix.unshift(basename(cursor)); cursor = parent; } } }; const pathsOverlap = (left, right) => left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`); const validatePathRoles = async (options) => { const roleNames = [ 'sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir', ...(options.approvalRegisterPath === undefined ? [] : ['approvalRegisterPath', 'publishRoot', 'manifestPath']), ]; const roles = Object.fromEntries(await Promise.all(roleNames.map(async (name) => [ name, await canonicalizePathRole(options[name]), ]))); for (const resetRole of ['stagingRoot', 'reportDir']) { if (!roles[resetRole].startsWith(`/tmp${sep}`)) fail('ASSET-PATH-001'); for (const otherRole of roleNames) { if (resetRole !== otherRole && pathsOverlap(roles[resetRole], roles[otherRole])) fail('ASSET-PATH-001'); } } return roles; }; const assertSafeOutputRoot = (path, rule) => { const resolved = resolve(path); const permittedRepositoryOutput = join(repositoryRoot, 'inputs', 'prepared-assets'); if (resolved !== permittedRepositoryOutput && !resolved.startsWith('/tmp/')) fail(rule); return resolved; }; const resetDirectory = async (path, rule) => { const safe = assertSafeOutputRoot(path, rule); await rm(safe, { recursive: true, force: true }); await mkdir(safe, { recursive: true }); return safe; }; const sorted = (items) => [...items].sort((left, right) => left.fileName.localeCompare(right.fileName, 'en-US')); const candidate = async ({ id, fileName, path, mediaType, width, height, durationMs, recipe }) => ({ id, fileName, sha256: await hashFile(path), mediaType, width, height, durationMs, rightsHolder: recipe.rightsHolder, license: recipe.license, attribution: recipe.attribution, }); const encodeImage = async (source, output, recipe) => { let detected; try { detected = await fileTypeFromFile(source); } catch { fail('ASSET-TYPE-001'); } if (!detected || !['image/jpeg', 'image/png', 'image/webp'].includes(detected.mime)) fail('ASSET-TYPE-001'); try { let pipeline = sharp(source, { failOn: 'error', limitInputPixels: 50_000_000, sequentialRead: true }) .rotate() .resize({ width: recipe.maxWidth, withoutEnlargement: true, fit: 'inside', kernel: sharp.kernel.lanczos3 }); pipeline = recipe.output.endsWith('.png') ? pipeline.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false }) : pipeline.webp({ lossless: true, effort: 6, smartSubsample: false }); await pipeline.toFile(output); } catch { fail('ASSET-DECODE-001'); } const details = await scanImage(output, [], { runOcr: false }); return details; }; const prepareImage = async ({ source, recipe, stagingRoot, denylist }) => { const path = join(stagingRoot, recipe.output); const details = await encodeImage(source, path, recipe); await scanImage(path, denylist, { runOcr: true }); return [await candidate({ id: recipe.id, fileName: recipe.output, path, mediaType: details.mediaType, width: details.width, height: details.height, durationMs: null, recipe, })]; }; const prepareVideo = async ({ source, recipe, stagingRoot, reportDir, denylist }) => { const sourceProbe = await probeVideo(source); if (recipe.startSeconds + recipe.durationSeconds > sourceProbe.duration + 0.05) fail('ASSET-VIDEO-RANGE-001'); const base = recipe.output.slice(0, -'.webm'.length); const webmPath = join(stagingRoot, `${base}.webm`); const mp4Path = join(stagingRoot, `${base}.mp4`); const posterPath = join(stagingRoot, `${base}-poster.webp`); await encodeVideoDerivatives({ source, webmPath, mp4Path, posterPath, startSeconds: recipe.startSeconds, durationSeconds: recipe.durationSeconds, maxWidth: recipe.maxWidth, }); const webmProbe = await probeVideo(webmPath, { allowAudio: false }); const mp4Probe = await probeVideo(mp4Path, { allowAudio: false }); const frames = await extractVideoFrames({ source: webmPath, durationSeconds: Math.min(webmProbe.duration, recipe.durationSeconds), outputDir: join(reportDir, 'video-frames'), prefix: recipe.id, }); await scanBinary(webmPath, denylist); await scanBinary(mp4Path, denylist); await scanImage(posterPath, denylist, { runOcr: true }); for (const frame of frames) await scanImage(frame, denylist, { runOcr: true }); const poster = await sharp(posterPath).metadata(); return [ await candidate({ id: recipe.id, fileName: `${base}.webm`, path: webmPath, mediaType: 'video/webm', width: webmProbe.width, height: webmProbe.height, durationMs: Math.round(webmProbe.duration * 1000), recipe, }), await candidate({ id: `${recipe.id}-mp4`, fileName: `${base}.mp4`, path: mp4Path, mediaType: 'video/mp4', width: mp4Probe.width, height: mp4Probe.height, durationMs: Math.round(mp4Probe.duration * 1000), recipe, }), await candidate({ id: `${recipe.id}-poster`, fileName: `${base}-poster.webp`, path: posterPath, mediaType: 'image/webp', width: poster.width, height: poster.height, durationMs: null, recipe, }), ]; }; const createContactSheet = async (candidates, stagingRoot, reportDir) => { const images = candidates.filter(({ mediaType }) => mediaType.startsWith('image/')).map((image) => ({ id: image.id, path: join(stagingRoot, image.fileName), })); let frameNames = []; try { frameNames = (await readdir(join(reportDir, 'video-frames'))).filter((name) => name.endsWith('.png')).sort(); } catch {} images.push(...frameNames.map((name, index) => ({ id: `private-game-loop-frame-${String(index + 1).padStart(2, '0')}`, path: join(reportDir, 'video-frames', name), }))); if (images.length === 0) return; const imageWidth = 1100; const width = 1200; const composites = []; let y = 30; for (const image of images) { const thumb = await sharp(image.path) .resize({ width: imageWidth, height: 1400, fit: 'inside', withoutEnlargement: false }) .png() .toBuffer(); const metadata = await sharp(thumb).metadata(); const left = Math.round((width - (metadata.width ?? imageWidth)) / 2); composites.push({ input: thumb, left, top: y }); const safeLabel = image.id.replace(/[^a-z0-9-]/gu, ''); const label = Buffer.from(`${safeLabel}`); y += metadata.height ?? 0; composites.push({ input: label, left: 50, top: y + 8 }); y += 86; } await sharp({ create: { width, height: y + 20, channels: 3, background: '#070910' } }) .composite(composites) .webp({ lossless: true, effort: 6 }) .toFile(join(reportDir, 'contact-sheet.webp')); }; const approvalProjection = (recipes, candidates) => ({ schema: 'portfolio-asset-approvals/v1', assets: recipes.map(({ id }) => ({ id, files: sorted(candidates.filter((item) => item.id === id || item.id.startsWith(`${id}-`))) .map(({ fileName, sha256 }) => ({ fileName, sha256 })), })).sort((left, right) => left.id.localeCompare(right.id, 'en-US')), }); const approvalsMatch = async (path, expected) => { const actual = await readJson(path, 'ASSET-APPROVAL-001'); if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-APPROVAL-002'); }; const manifestKeys = [ 'id', 'fileName', 'sha256', 'mediaType', 'width', 'height', 'durationMs', 'rightsHolder', 'license', 'attribution', ]; const mediaExtension = new Map([ ['image/avif', 'avif'], ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/svg+xml', 'svg'], ['image/webp', 'webp'], ['video/mp4', 'mp4'], ['video/webm', 'webm'], ['audio/ogg', 'opus'], ['audio/mpeg', 'mp3'], ]); const isPositiveIntegerOrNull = (value) => value === null || (Number.isInteger(value) && value > 0); const hasUnpairedSurrogate = (value) => { for (let index = 0; index < value.length; index += 1) { const codeUnit = value.charCodeAt(index); if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { const next = value.charCodeAt(index + 1); if (next < 0xdc00 || next > 0xdfff) return true; index += 1; } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { return true; } } return false; }; const isVisibleString = (value) => typeof value === 'string' && value.trim().length > 0 && value === value.normalize('NFC') && !hasUnpairedSurrogate(value) && !/[\u0000-\u001f\u007f]/u.test(value); const validateManifestRecord = (asset) => { if (!asset || typeof asset !== 'object' || Array.isArray(asset) || Object.keys(asset).length !== manifestKeys.length || manifestKeys.some((key) => !(key in asset)) || typeof asset.id !== 'string' || typeof asset.fileName !== 'string' || typeof asset.sha256 !== 'string' || !mediaExtension.has(asset.mediaType) || !asset.fileName.endsWith(`.${mediaExtension.get(asset.mediaType)}`) || !isPositiveIntegerOrNull(asset.width) || !isPositiveIntegerOrNull(asset.height) || !isPositiveIntegerOrNull(asset.durationMs) || !isVisibleString(asset.rightsHolder) || !isVisibleString(asset.license) || !isVisibleString(asset.attribution)) { fail('ASSET-MANIFEST-001'); } const isImage = asset.mediaType.startsWith('image/'); const isVideo = asset.mediaType.startsWith('video/'); const isAudio = asset.mediaType.startsWith('audio/'); if ((isImage && (asset.width === null || asset.height === null || asset.durationMs !== null)) || (isVideo && (asset.width === null || asset.height === null || asset.durationMs === null)) || (isAudio && (asset.width !== null || asset.height !== null || asset.durationMs === null))) { fail('ASSET-MANIFEST-001'); } }; const fileIdentity = (details) => ({ dev: details.dev, ino: details.ino }); const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; const manifestAssetsFromBytes = (bytes) => { let manifest; try { manifest = JSON.parse(bytes.toString('utf8')); } catch { fail('ASSET-MANIFEST-001'); } if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001'); for (const asset of manifest.assets) validateManifestRecord(asset); try { mergeAssetRecords(manifest.assets, [], new Set()); } catch { fail('ASSET-MANIFEST-001'); } return manifest.assets; }; const readVerifiedRegularFile = async (path, expectedHash, rule) => { let handle; try { handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); const details = await handle.stat(); if (!details.isFile() || details.nlink !== 1) fail(rule); const bytes = await handle.readFile(); if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) fail('ASSET-HASH-001'); return { bytes, identity: fileIdentity(details) }; } catch (error) { if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error; fail(rule); } finally { await handle?.close(); } }; const readManifestSnapshot = async (manifestPath) => { let handle; try { handle = await open(manifestPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); const details = await handle.stat(); if (!details.isFile() || details.nlink !== 1) fail('ASSET-MANIFEST-001'); const bytes = await handle.readFile(); return { assets: manifestAssetsFromBytes(bytes), bytes, identity: fileIdentity(details) }; } catch (error) { if (error instanceof Error && error.message === 'ASSET-MANIFEST-001') throw error; fail('ASSET-MANIFEST-001'); } finally { await handle?.close(); } }; const verifyPublicationDirectory = async (publishRoot, assets) => { let rootStats; try { rootStats = await lstat(publishRoot); } catch { fail('ASSET-EXACT-001'); } if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) fail('ASSET-EXACT-001'); const expectedNames = new Set(assets.map(({ fileName }) => fileName)); const entries = await readdir(publishRoot, { withFileTypes: true }); if (entries.length !== expectedNames.size) fail('ASSET-EXACT-001'); for (const entry of entries) { if (!expectedNames.has(entry.name) || entry.isSymbolicLink() || !entry.isFile()) fail('ASSET-EXACT-001'); } const files = new Map(); for (const asset of assets) { const snapshot = await readVerifiedRegularFile(join(publishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001'); files.set(asset.fileName, snapshot); } return { rootIdentity: fileIdentity(rootStats), files }; }; const verifyCurrentPublication = async (publishRoot, assets) => { const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001'); return { safePublishRoot, ...await verifyPublicationDirectory(safePublishRoot, assets) }; }; const managedCoordinatesForRecipes = (recipes) => recipes.flatMap((recipe) => { if (recipe.kind === 'image') return [{ id: recipe.id, fileName: recipe.output }]; if (recipe.kind !== 'video' || !recipe.output.endsWith('.webm')) fail('ASSET-OWNERSHIP-001'); const base = recipe.output.slice(0, -'.webm'.length); return [ { id: recipe.id, fileName: `${base}.webm` }, { id: `${recipe.id}-mp4`, fileName: `${base}.mp4` }, { id: `${recipe.id}-poster`, fileName: `${base}-poster.webp` }, ]; }); const trustedManagedIds = (recipes, candidates) => { const expected = managedCoordinatesForRecipes(recipes); const expectedCoordinates = expected.map(({ id, fileName }) => `${id}\u0000${fileName}`).sort(); const actualCoordinates = candidates.map(({ id, fileName }) => `${id}\u0000${fileName}`).sort(); if (JSON.stringify(actualCoordinates) !== JSON.stringify(expectedCoordinates)) fail('ASSET-OWNERSHIP-001'); return new Set(expected.map(({ id }) => id)); }; export const publishPreparedAssets = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => { if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) { fail('ASSET-RIGHTS-001'); } const manifestSnapshot = await readManifestSnapshot(manifestPath); const baseAssets = manifestSnapshot.assets; const currentPublication = await verifyCurrentPublication(publishRoot, baseAssets); const { safePublishRoot } = currentPublication; const managedIds = trustedManagedIds(recipes, candidates); const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds); const candidateSnapshots = new Map(); for (const item of candidates) { validateManifestRecord(item); const path = join(stagingRoot, item.fileName); candidateSnapshots.set(item.fileName, { path, expectedHash: item.sha256, ...await readVerifiedRegularFile(path, item.sha256, 'ASSET-EXACT-001'), }); } let publicationTransactionRoot; let manifestTransactionRoot; let preserveRecoveryState = false; try { publicationTransactionRoot = await mkdtemp(join(dirname(safePublishRoot), '.asset-publication-')); const nextPublication = join(publicationTransactionRoot, 'next'); const previousPublication = join(publicationTransactionRoot, 'previous'); await mkdir(nextPublication); for (const asset of mergedAssets) { const snapshot = candidateSnapshots.get(asset.fileName) ?? currentPublication.files.get(asset.fileName); if (snapshot === undefined) fail('ASSET-PUBLISH-001'); await writeFile(join(nextPublication, asset.fileName), snapshot.bytes, { flag: 'wx', mode: 0o644 }); } await verifyPublicationDirectory(nextPublication, mergedAssets); manifestTransactionRoot = await mkdtemp(join(dirname(manifestPath), '.asset-manifest-')); const nextManifest = join(manifestTransactionRoot, 'next-manifest.json'); const previousManifest = join(manifestTransactionRoot, 'previous-manifest.json'); await writeFile(nextManifest, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, { flag: 'wx', mode: 0o644 }); for (const snapshot of candidateSnapshots.values()) { const current = await readVerifiedRegularFile(snapshot.path, snapshot.expectedHash, 'ASSET-EXACT-001'); if (!sameIdentity(current.identity, snapshot.identity)) fail('ASSET-EXACT-001'); } const protectedPublication = await verifyCurrentPublication(safePublishRoot, baseAssets); if (!sameIdentity(protectedPublication.rootIdentity, currentPublication.rootIdentity)) fail('ASSET-EXACT-001'); const protectedManifest = await readManifestSnapshot(manifestPath); if (!sameIdentity(protectedManifest.identity, manifestSnapshot.identity) || !protectedManifest.bytes.equals(manifestSnapshot.bytes)) fail('ASSET-MANIFEST-001'); let publicationBackedUp = false; let publicationInstalled = false; let manifestBackedUp = false; let manifestInstalled = false; try { await rename(safePublishRoot, previousPublication); publicationBackedUp = true; await rename(nextPublication, safePublishRoot); publicationInstalled = true; await rename(manifestPath, previousManifest); manifestBackedUp = true; await rename(nextManifest, manifestPath); manifestInstalled = true; const installedManifest = await readManifestSnapshot(manifestPath); if (JSON.stringify(installedManifest.assets) !== JSON.stringify(mergedAssets)) fail('ASSET-PUBLISH-001'); await verifyCurrentPublication(safePublishRoot, mergedAssets); } catch { try { if (manifestInstalled) await rename(manifestPath, nextManifest); if (manifestBackedUp) await rename(previousManifest, manifestPath); if (publicationInstalled) await rename(safePublishRoot, nextPublication); if (publicationBackedUp) await rename(previousPublication, safePublishRoot); } catch { preserveRecoveryState = true; fail('ASSET-PUBLISH-ROLLBACK-001'); } fail('ASSET-PUBLISH-001'); } } finally { if (!preserveRecoveryState) { if (manifestTransactionRoot !== undefined) await rm(manifestTransactionRoot, { recursive: true, force: true }); if (publicationTransactionRoot !== undefined) await rm(publicationTransactionRoot, { recursive: true, force: true }); } } }; export const prepareAssets = async (options) => { const required = ['sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir']; if (!options || required.some((key) => typeof options[key] !== 'string')) fail('ASSET-ARGUMENT-001'); if (options.approvalRegisterPath !== undefined && !['approvalRegisterPath', 'publishRoot', 'manifestPath'].every((key) => typeof options[key] === 'string')) { fail('ASSET-ARGUMENT-001'); } const paths = await validatePathRoles(options); const recipes = await loadRecipes(paths.recipesPath); const mapping = await loadPrivateMapping(paths.mappingPath, recipes); const denylist = await loadDenylist(paths.denylistPath); const stagingRoot = await resetDirectory(paths.stagingRoot, 'ASSET-STAGING-001'); const reportDir = await resetDirectory(paths.reportDir, 'ASSET-REPORT-001'); const candidates = []; for (const recipe of recipes) { const source = await resolvePrivateSource(paths.sourceRoot, mapping[recipe.id]); const prepared = recipe.kind === 'image' ? await prepareImage({ source, recipe, stagingRoot, denylist }) : await prepareVideo({ source, recipe, stagingRoot, reportDir, denylist }); candidates.push(...prepared); } const finalCandidates = sorted(candidates); const approval = approvalProjection(recipes, finalCandidates); await writeFile(join(reportDir, 'candidate-approval.json'), `${JSON.stringify(approval, null, 2)}\n`, 'utf8'); await writeFile(join(reportDir, 'scan-report.json'), `${JSON.stringify({ schema: 'portfolio-private-scan-report/v1', automatedChecks: 'passed', publication: 'pending-manual-approval', assets: finalCandidates.map(({ id, fileName, sha256 }) => ({ id, fileName, sha256 })), }, null, 2)}\n`, 'utf8'); await createContactSheet(finalCandidates, stagingRoot, reportDir); let approvalStatus = 'pending'; if (options.approvalRegisterPath !== undefined) { await approvalsMatch(paths.approvalRegisterPath, approval); await publishPreparedAssets({ candidates: finalCandidates, recipes, stagingRoot, publishRoot: paths.publishRoot, manifestPath: paths.manifestPath, }); approvalStatus = 'approved'; } return { candidates: finalCandidates, approvalStatus, reportDir }; }; export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => { if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001'); const { assets } = await readManifestSnapshot(manifestPath); const { safePublishRoot } = await verifyCurrentPublication(publishRoot, assets); const denylist = await loadDenylist(denylistPath); for (const item of assets) { const path = join(safePublishRoot, item.fileName); if (item.mediaType.startsWith('image/')) await scanImage(path, denylist, { runOcr: true }); else if (item.mediaType.startsWith('video/')) await scanBinary(path, denylist); } return true; }; const parseArgs = (argv) => { const values = {}; let verify = false; for (let index = 0; index < argv.length; index += 1) { const key = argv[index]; if (key === '--verify') { verify = true; continue; } if (!key?.startsWith('--') || index + 1 >= argv.length) fail('ASSET-ARGUMENT-001'); values[key.slice(2)] = argv[index + 1]; index += 1; } return { verify, values }; }; const runCli = async () => { const { verify, values } = parseArgs(process.argv.slice(2)); if (verify) { await verifyPreparedAssets({ publishRoot: values['publish-root'], manifestPath: values.manifest, denylistPath: values.denylist }); return; } await prepareAssets({ sourceRoot: values['source-root'], mappingPath: values.mapping, denylistPath: values.denylist, recipesPath: values.recipes, stagingRoot: values['staging-root'], reportDir: values['report-dir'], approvalRegisterPath: values.approvals, publishRoot: values['publish-root'], manifestPath: values.manifest, }); }; if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { runCli().catch((error) => { const message = error instanceof Error && /^[A-Z]+(?:-[A-Z]+)*-\d{3}(?::[a-z0-9-]+)?$/u.test(error.message) ? error.message : 'ASSET-UNEXPECTED-001'; process.stderr.write(`${message}\n`); process.exitCode = 1; }); }