#!/usr/bin/env node import { constants } from 'node:fs'; import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; export const MISSING_DEPS_EXIT = 42; export const GENERATED_STATE_EXIT = 43; const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); async function entries(root) { const result = []; async function walk(current) { let children; try { children = await readdir(current, { withFileTypes: true }); } catch (error) { if (error.code === 'ENOENT') return; throw error; } for (const child of children) { const target = path.join(current, child.name); result.push(target); if (child.isDirectory() && !child.isSymbolicLink()) await walk(target); } } await walk(root); return result; } export async function generatedSymlinkManifest(nextDir) { const links = []; for (const target of (await entries(nextDir)).sort()) { const stats = await lstat(target); if (!stats.isSymbolicLink()) continue; links.push({ path: path.relative(nextDir, target).split(path.sep).join('/'), target: await readlink(target), }); } return `${JSON.stringify({ version: 1, links })}\n`; } const webSourceRoots = (root) => [ path.join(root, 'apps', 'web', 'src'), path.join(root, 'apps', 'web', 'public'), path.join(root, 'apps', 'web', 'next-env.d.ts'), path.join(root, 'apps', 'web', 'next.config.ts'), path.join(root, 'apps', 'web', 'postcss.config.mjs'), path.join(root, 'apps', 'web', 'package.json'), path.join(root, 'apps', 'web', 'tsconfig.json'), path.join(root, 'packages', 'design-tokens', 'src'), path.join(root, 'packages', 'design-tokens', 'package.json'), path.join(root, 'packages', 'design-tokens', 'tsconfig.json'), path.join(root, 'package.json'), path.join(root, 'tsconfig.base.json'), path.join(root, 'pnpm-lock.yaml'), path.join(root, 'pnpm-workspace.yaml'), path.join(root, 'turbo.json'), ]; // next.config.ts currently reads no server-only environment. Add any future // server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic. const serverBuildEnvironmentKeys = []; function publicBuildEnvironment(root) { const webDir = path.join(root, 'apps', 'web'); const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json')); const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json')); const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env'); const originalEnvironment = { ...process.env }; updateInitialEnv(originalEnvironment); try { const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true); return Object.fromEntries( Object.entries(combinedEnv).filter( ([key, value]) => value !== undefined && (key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)), ), ); } finally { resetEnv(); } } export async function sourceFingerprint(root = process.cwd()) { const files = []; for (const sourceRoot of webSourceRoots(root)) { try { const stats = await lstat(sourceRoot); if (stats.isSymbolicLink()) { throw new Error( `Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`, ); } if (stats.isFile()) files.push(sourceRoot); if (stats.isDirectory()) { for (const target of await entries(sourceRoot)) { const targetStats = await lstat(target); if (targetStats.isSymbolicLink()) { throw new Error( `Web build input must not be a symbolic link: ${path.relative(root, target)}`, ); } if (targetStats.isFile()) files.push(target); } } } catch (error) { if (error.code !== 'ENOENT') throw error; } } const digest = createHash('sha256'); for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) { digest.update(`env:${key}\0${value.length}\0${value}\0`); } for (const target of files.sort()) { const contents = await readFile(target); digest.update(path.relative(root, target).split(path.sep).join('/')); digest.update('\0'); digest.update(String(contents.length)); digest.update('\0'); digest.update(contents); digest.update('\0'); } return digest.digest('hex'); } export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) { const binDir = path.join(root, 'node_modules', '.bin'); const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; const missingBinaries = []; for (const binary of requiredBinaries) { try { await access(path.join(binDir, binary), constants.X_OK); } catch { missingBinaries.push(binary); } } if (missingBinaries.length > 0) { return { code: MISSING_DEPS_EXIT, message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`, }; } const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock'); try { await lstat(buildLock); return { code: GENERATED_STATE_EXIT, message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`, }; } catch (error) { if (error.code !== 'ENOENT') throw error; } const nextDir = path.join(root, 'apps', 'web', '.next'); let generated = []; try { const nextStats = await lstat(nextDir); if (!nextStats.isDirectory() || nextStats.isSymbolicLink()) { return { code: GENERATED_STATE_EXIT, message: 'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next must be a real directory, not a symbolic link, and is not trustworthy; run pnpm clean:generated, then rerun the gate', }; } generated = [nextDir, ...(await entries(nextDir))]; } catch (error) { if (error.code !== 'ENOENT') throw error; } if (generated.length > 0) { const foreign = []; for (const target of generated) { const stats = await lstat(target); if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target)); } // Detects accidental, independent, stale, and foreign-residue mutation of // generated state: the class this check was born from was a five-month-stale // .next whose validator referenced deleted pages and produced 19 phantom TS2307 // errors indistinguishable from real type errors. // // Does NOT defend against an actor with same-UID write access to the generated // tree, which can regenerate both the manifest and marker consistently // (CWE-345). No local construction can, absent a trust anchor outside that // actor's authority. RM-59 tracks executor/spine-side attestation. let certification = null; let certifiedManifest = null; try { const [certificationContents, manifestContents] = await Promise.all([ readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'), readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'), ]); try { const parsed = JSON.parse(certificationContents); if ( parsed.version === 1 && typeof parsed.sourceFingerprint === 'string' && typeof parsed.symlinkManifestHash === 'string' ) { certification = parsed; certifiedManifest = manifestContents; } } catch { // Invalid certification is handled as untrusted generated state below. } } catch (error) { if (error.code !== 'ENOENT') throw error; } const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root)); const actualManifest = await generatedSymlinkManifest(nextDir); const certifiedManifestHash = certifiedManifest === null ? null : createHash('sha256').update(certifiedManifest).digest('hex'); const changedSymlinks = certification?.symlinkManifestHash !== certifiedManifestHash || certifiedManifest !== actualManifest; if (foreign.length > 0 || stale || changedSymlinks) { const reasons = [ foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '', stale ? 'generated source fingerprint does not match web source/configuration' : '', changedSymlinks ? 'generated symbolic-link manifest does not match the certified build' : '', ].filter(Boolean); return { code: GENERATED_STATE_EXIT, message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`, }; } } return { code: 0, message: 'checkout preflight passed' }; } async function main() { const result = await runPreflight(); const stream = result.code === 0 ? process.stdout : process.stderr; stream.write(`${result.message}\n`); process.exitCode = result.code; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await main(); }