From 752ee40edda5754791610a10fae7ccd776fa1328 Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 07:48:40 -0500 Subject: [PATCH] preflight: retire the .next generated-state certification (#1444) CI's test step caught the gap: sourceFingerprint resolved next/package.json from apps/web, which P5 removed. The certification (fingerprint incl. @next/env expansion, symlink manifest, foreign-uid scan, build lock) defended typecheck against stale apps/web/.next output; the Vite SPA has no generated tree later gates consume, so the class it defended against is gone. Preflight keeps its other job, the missing-binaries check with exit 42. clean-generated.mjs (a .next quarantine helper) goes with it. --- package.json | 1 - scripts/clean-generated.mjs | 34 ------ scripts/preflight.mjs | 221 +----------------------------------- scripts/preflight.test.mjs | 213 +--------------------------------- 4 files changed, 11 insertions(+), 458 deletions(-) delete mode 100644 scripts/clean-generated.mjs diff --git a/package.json b/package.json index 3ba7a059..5910f982 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "dev": "turbo run dev", "lint": "turbo run lint", "preflight": "node scripts/preflight.mjs", - "clean:generated": "node scripts/clean-generated.mjs", "typecheck": "pnpm preflight && turbo run typecheck", "verify:release": "node scripts/verify-release.mjs", "test:checkout": "node --test scripts/*.test.mjs", diff --git a/scripts/clean-generated.mjs b/scripts/clean-generated.mjs deleted file mode 100644 index 54e42c84..00000000 --- a/scripts/clean-generated.mjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node - -import { access, mkdir, rename, rm } from 'node:fs/promises'; -import path from 'node:path'; - -const root = process.cwd(); -const generated = path.join(root, 'apps', 'web', '.next'); -const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine'); - -try { - await access(generated); -} catch (error) { - if (error.code === 'ENOENT') process.exit(0); - throw error; -} - -await mkdir(quarantineRoot, { recursive: true }); -const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`); -try { - await rename(generated, quarantine); -} catch (error) { - console.error( - `MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`, - ); - throw error; -} - -try { - await rm(quarantine, { recursive: true, force: true }); -} catch { - console.warn( - `Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`, - ); -} diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index c1f5c99a..58cbfa52 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -1,137 +1,18 @@ #!/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 { access } from 'node:fs/promises'; 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)), '..'); +// The generated-state certification that used to live here (fingerprinting the +// web source tree and certifying apps/web/.next) retired with the Next.js build +// in Phase P5 (#1444): the Vite SPA has no generated tree that later gates +// consume, so there is no stale-output class left to defend against. -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?.() } = {}) { +export async function runPreflight({ root = process.cwd() } = {}) { const binDir = path.join(root, 'node_modules', '.bin'); const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; const missingBinaries = []; @@ -149,96 +30,6 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? }; } - 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' }; } diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs index 1e351912..a510d91a 100644 --- a/scripts/preflight.test.mjs +++ b/scripts/preflight.test.mjs @@ -1,10 +1,9 @@ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { runPreflight, sourceFingerprint } from './preflight.mjs'; +import { runPreflight } from './preflight.mjs'; const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`); @@ -12,8 +11,8 @@ const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; async function fixture(name) { const root = path.join(fixtureRoot, name); - await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true }); - await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n'); + await mkdir(path.join(root, 'apps', 'web', 'src'), { recursive: true }); + await writeFile(path.join(root, 'apps', 'web', 'src', 'main.tsx'), 'export default 1;\n'); return root; } @@ -29,22 +28,6 @@ async function installRequiredBins(root) { ); } -async function certifyGeneratedState(root, links = []) { - const nextDir = path.join(root, 'apps', 'web', '.next'); - await mkdir(nextDir, { recursive: true }); - const manifest = `${JSON.stringify({ version: 1, links })}\n`; - const manifestHash = createHash('sha256').update(manifest).digest('hex'); - await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest); - await writeFile( - path.join(nextDir, '.mosaic-source-hash'), - `${JSON.stringify({ - version: 1, - sourceFingerprint: await sourceFingerprint(root), - symlinkManifestHash: manifestHash, - })}\n`, - ); -} - test.after(async () => { await rm(fixtureRoot, { recursive: true, force: true }); }); @@ -80,195 +63,9 @@ test('a dangling required dependency shim keeps the dedicated missing-deps resul assert.match(result.message, /turbo/); }); -test('installed dependencies pass when generated state is absent', async () => { +test('installed dependencies pass', async () => { const root = await fixture('clean'); await installRequiredBins(root); assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); }); - -test('foreign-owned generated Next state is identified separately from source errors', async () => { - const root = await fixture('foreign-next'); - await installRequiredBins(root); - const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); - await mkdir(path.dirname(generated), { recursive: true }); - await writeFile(generated, 'generated output'); - - const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 }); - assert.equal(result.code, 43); - assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); - assert.match(result.message, /foreign-owned/); -}); - -test('a generated marker mismatch is identified separately from source errors', async () => { - const root = await fixture('stale-next'); - await installRequiredBins(root); - const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); - await mkdir(path.dirname(generated), { recursive: true }); - await writeFile(generated, 'stale generated output'); - await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source'); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); - assert.match(result.message, /apps\/web\/\.next/); - assert.match(result.message, /pnpm clean:generated/); -}); - -test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => { - await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => { - const root = await fixture('symbolic-next-root'); - await installRequiredBins(root); - await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n'); - await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next')); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); - assert.match(result.message, /symbolic link/); - }); - - await t.test('apps/web/.next is rejected when it is not a directory', async () => { - const root = await fixture('non-directory-next-root'); - await installRequiredBins(root); - await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n'); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); - assert.match(result.message, /real directory/); - }); - - await t.test('an added descendant symlink is rejected', async () => { - const root = await fixture('symbolic-next-added'); - await installRequiredBins(root); - await certifyGeneratedState(root); - await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink')); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /symbolic-link manifest/); - }); - - await t.test('a removed certified descendant symlink is rejected', async () => { - const root = await fixture('symbolic-next-removed'); - await installRequiredBins(root); - const link = path.join(root, 'apps', 'web', '.next', 'dependency-link'); - await mkdir(path.dirname(link), { recursive: true }); - await symlink('../dependency-one', link); - await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]); - await rm(link); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /symbolic-link manifest/); - }); - - await t.test('a retargeted certified descendant symlink is rejected', async () => { - const root = await fixture('symbolic-next-retargeted'); - await installRequiredBins(root); - const link = path.join(root, 'apps', 'web', '.next', 'dependency-link'); - await mkdir(path.dirname(link), { recursive: true }); - await symlink('../dependency-one', link); - await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]); - await rm(link); - await symlink('../dependency-two', link); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /symbolic-link manifest/); - }); - - await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => { - const root = await fixture('symbolic-next-tampered-manifest'); - await installRequiredBins(root); - await certifyGeneratedState(root); - const nextDir = path.join(root, 'apps', 'web', '.next'); - await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink')); - await writeFile( - path.join(nextDir, '.mosaic-symlink-manifest'), - `${JSON.stringify({ - version: 1, - links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }], - })}\n`, - ); - - const result = await runPreflight({ root }); - assert.equal(result.code, 43); - assert.match(result.message, /symbolic-link manifest/); - }); - - await t.test('unchanged canonical-style descendant symlinks are accepted', async () => { - const root = await fixture('symbolic-next-certified'); - await installRequiredBins(root); - const link = path.join( - root, - 'apps', - 'web', - '.next', - 'standalone', - 'node_modules', - 'dependency', - ); - await mkdir(path.dirname(link), { recursive: true }); - await symlink('../.pnpm/dependency', link); - await certifyGeneratedState(root, [ - { path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' }, - ]); - - assert.deepEqual(await runPreflight({ root }), { - code: 0, - message: 'checkout preflight passed', - }); - }); -}); - -test('the source fingerprint includes inherited TypeScript configuration', async () => { - const root = await fixture('inherited-typescript-config'); - const config = path.join(root, 'tsconfig.base.json'); - await writeFile(config, '{"compilerOptions":{"strict":true}}\n'); - const first = await sourceFingerprint(root); - await writeFile(config, '{"compilerOptions":{"strict":false}}\n'); - const second = await sourceFingerprint(root); - - assert.notEqual(first, second); -}); - -test('the source fingerprint rejects symbolic-link build inputs', async () => { - const root = await fixture('symbolic-source'); - await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n'); - await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts')); - - await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/); -}); - -test('the source fingerprint includes expanded public web build environment', async () => { - const root = await fixture('public-build-environment'); - const envFile = path.join(root, 'apps', 'web', '.env.production'); - await writeFile( - envFile, - 'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n', - ); - const first = await sourceFingerprint(root); - await writeFile( - envFile, - 'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n', - ); - const second = await sourceFingerprint(root); - - assert.notEqual(first, second); -}); - -test('a matching generation marker accepts incremental output with mixed mtimes', async () => { - const root = await fixture('incremental-next'); - await installRequiredBins(root); - const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); - await mkdir(path.dirname(generated), { recursive: true }); - await writeFile(generated, 'unchanged generated output'); - await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z')); - const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); - await writeFile(fresh, 'fresh generated output'); - await certifyGeneratedState(root); - - assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); -});