diff --git a/.gitignore b/.gitignore index dd50108f..e86bc179 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage .env.local *.tsbuildinfo .pnpm-store +__pycache__/ docs/reports/ # Step-CA dev password — real file is gitignored; commit only the .example diff --git a/.husky/pre-push b/.husky/pre-push index 4a0f0e8a..aae2e7e1 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm typecheck && pnpm lint && pnpm format:check +pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check diff --git a/.npmrc b/.npmrc index e72177a2..17a95ce4 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ -# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci), -# so the pipeline `pnpm install --prefer-offline` consumes the baked store -# instead of repopulating a fresh one. -store-dir=/root/.local/share/pnpm/store +# HOME resolves to /root in the ci-base image, preserving its warmed-store path. +# Non-root checkouts use their own HOME. Override without editing this file via +# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting). +store-dir=${HOME}/.local/share/pnpm/store diff --git a/README.md b/README.md index f095ee62..9bccfc60 100644 --- a/README.md +++ b/README.md @@ -201,8 +201,21 @@ git clone git@git.mosaicstack.dev:mosaicstack/stack.git cd stack # Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset. +# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without +# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed. pnpm install +# Verify dependencies and generated state before running source-quality gates. +# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. +# The web build certifies its exact standalone symlink manifest; added, removed, +# retargeted, or manifest-only-tampered generated links also exit 43. This detects +# accidental, independent, stale, and foreign-residue mutation—the class exposed by +# a five-month-stale .next that produced 19 phantom TS2307 errors. +# It does NOT defend against a same-UID actor that can rewrite both manifest and +# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side +# trust anchor outside worktree authority. +pnpm preflight + # Optional local queue service only. This does not start PostgreSQL. docker compose up -d valkey @@ -230,6 +243,7 @@ Gateway start command until KBN-101-02 makes that state fail closed. ### Quality Gates ```bash +pnpm preflight # Checkout/dependency/generated-state validation pnpm typecheck # TypeScript type checking (all packages) pnpm lint # ESLint (all packages) pnpm test # Vitest (all packages) diff --git a/apps/web/package.json b/apps/web/package.json index 519f64af..51ed3daa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.0.2", "private": true, "scripts": { - "build": "next build", + "build": "node ../../scripts/build-web.mjs", "dev": "next dev", "lint": "eslint src", "typecheck": "tsc --noEmit", diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md new file mode 100644 index 00000000..3c6feac0 --- /dev/null +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -0,0 +1,58 @@ +# RM-01 — Reproducible checkout + +- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking) +- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats. +- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation. +- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge. +- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum. +- Plan: + 1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation. + 2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation. + 3. Run focused tests, install/build/baseline gates, and explicit AC negative controls. + 4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`. +- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup. +- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification. + +## Progress / evidence + +- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`. +- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`. +- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness. + +## Checkpoint evidence (c45e5e19) + +- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed. +- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0. +- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0. +- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed. +- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted. +- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint. +- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially. +- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree. + +## Continuation evidence + +- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression. +- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs. +- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result. +- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974. +- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence. +- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0. +- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`. + +## Review remediation — restated AC2 + +- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation. +- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest. +- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted. +- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0. +- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25. +- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence. + +## Handoff + +1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation. +2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes. +3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config. +4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree. +5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate. diff --git a/package.json b/package.json index fb75bde1..f52dd9d6 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", - "typecheck": "turbo run typecheck", - "test": "turbo run test", + "preflight": "node scripts/preflight.mjs", + "clean:generated": "node scripts/clean-generated.mjs", + "typecheck": "pnpm preflight && turbo run typecheck", + "test:checkout": "node --test scripts/*.test.mjs", + "test": "pnpm test:checkout && turbo run test", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", - "prepare": "husky" + "prepare": "node scripts/install-hooks.mjs" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs new file mode 100644 index 00000000..dde9d60c --- /dev/null +++ b/scripts/build-web.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs'; + +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else + reject( + new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`), + ); + }); + }); +} + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function requireRealDirectory(target, { allowMissing = false } = {}) { + try { + const stats = await lstat(target); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error(`${target} must be a real directory, not a symbolic link.`); + } + } catch (error) { + if (allowMissing && error.code === 'ENOENT') return; + throw error; + } +} + +async function acquireBuildLock(root) { + const workRoot = path.join(root, '.mosaic-test-work'); + const lock = path.join(workRoot, 'web-build.lock'); + const nonce = randomUUID(); + const owner = JSON.stringify({ pid: process.pid, nonce }); + const deadline = Date.now() + 120_000; + await mkdir(workRoot, { recursive: true }); + + while (Date.now() < deadline) { + try { + await mkdir(lock); + await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 }); + return async () => { + const current = await readFile(path.join(lock, 'owner.json'), 'utf8'); + if (current !== owner) throw new Error('Web build lock ownership changed before release.'); + const released = `${lock}.released-${nonce}`; + await rename(lock, released); + await rm(released, { recursive: true, force: true }); + }; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + let lockOwner; + try { + lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); + } catch (ownerError) { + if (ownerError.code === 'ENOENT') { + await delay(25); + continue; + } + throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError }); + } + try { + process.kill(lockOwner.pid, 0); + } catch (processError) { + if (processError.code !== 'ESRCH') throw processError; + const stale = `${lock}.stale-${nonce}`; + try { + await rename(lock, stale); + await rm(stale, { recursive: true, force: true }); + } catch (renameError) { + if (renameError.code !== 'ENOENT') throw renameError; + } + continue; + } + await delay(25); + } + } + throw new Error(`Timed out waiting for the web build lock at ${lock}.`); +} + +export async function buildWeb({ + root = scriptRoot, + fingerprint = sourceFingerprint, + runBuild = async (webDir) => + run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], { + cwd: webDir, + stdio: 'inherit', + }), +} = {}) { + const releaseLock = await acquireBuildLock(root); + try { + const webDir = path.join(root, 'apps', 'web'); + const nextDir = path.join(webDir, '.next'); + const certificationMarker = path.join(nextDir, '.mosaic-source-hash'); + const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest'); + const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`; + const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`; + const before = await fingerprint(root); + + await requireRealDirectory(nextDir, { allowMissing: true }); + await Promise.all([ + rm(certificationMarker, { force: true }), + rm(symlinkManifest, { force: true }), + ]); + await runBuild(webDir); + await requireRealDirectory(nextDir); + + const after = await fingerprint(root); + if (after !== before) { + throw new Error( + 'Web build inputs changed during next build; generated output was not certified.', + ); + } + + const manifestContents = await generatedSymlinkManifest(nextDir); + const certificationContents = `${JSON.stringify({ + version: 1, + sourceFingerprint: before, + symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'), + })}\n`; + await Promise.all([ + writeFile(certificationTemporary, certificationContents, { mode: 0o600 }), + writeFile(manifestTemporary, manifestContents, { mode: 0o600 }), + ]); + // The certification marker is the commit point. Publishing the manifest first + // leaves interrupted builds untrusted because the marker remains absent. + await rename(manifestTemporary, symlinkManifest); + await rename(certificationTemporary, certificationMarker); + } finally { + await releaseLock(); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await buildWeb(); +} diff --git a/scripts/build-web.test.mjs b/scripts/build-web.test.mjs new file mode 100644 index 00000000..2a53a107 --- /dev/null +++ b/scripts/build-web.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildWeb } from './build-web.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`); + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true }); + return root; +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('a successful web build atomically publishes its source and symlink certification', async () => { + const root = await fixture('success'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); + + await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} }); + + assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), { + version: 1, + sourceFingerprint: 'certified', + symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11', + }); + assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n'); +}); + +test('a failed web build leaves no certification marker', async () => { + const root = await fixture('failure'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); + await writeFile(marker, 'stale\n'); + await writeFile(manifest, 'stale\n'); + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => 'before', + runBuild: async () => { + throw new Error('build failed'); + }, + }), + /build failed/, + ); + + assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); +}); + +test('overlapping web builds are serialized while the marker remains absent', async () => { + const root = await fixture('overlap'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); + await writeFile(marker, 'stale\n'); + await writeFile(manifest, 'stale\n'); + let releaseFirst; + let secondEntered = false; + const firstEntered = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstEntered; + const firstStarted = new Promise((resolve) => { + markFirstEntered = resolve; + }); + + const first = buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + markFirstEntered(); + await firstEntered; + }, + }); + await firstStarted; + const second = buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + secondEntered = true; + }, + }); + await new Promise((resolve) => setTimeout(resolve, 75)); + assert.equal(secondEntered, false); + assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); + + releaseFirst(); + await Promise.all([first, second]); + assert.equal(secondEntered, true); + assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified'); + assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n'); +}); + +test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => { + const root = await fixture('symbolic-next'); + const nextDir = path.join(root, 'apps', 'web', '.next'); + const outside = path.join(root, 'outside-generated'); + await mkdir(outside); + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + await rm(nextDir, { recursive: true }); + await symlink(outside, nextDir); + }, + }), + /must be a real directory/, + ); + + assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false); + assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false); +}); + +test('inputs changed during a web build are not certified', async () => { + const root = await fixture('changed-inputs'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); + const fingerprints = ['before', 'after']; + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => fingerprints.shift(), + runBuild: async () => {}, + }), + /inputs changed during next build/, + ); + + assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); +}); diff --git a/scripts/clean-generated.mjs b/scripts/clean-generated.mjs new file mode 100644 index 00000000..54e42c84 --- /dev/null +++ b/scripts/clean-generated.mjs @@ -0,0 +1,34 @@ +#!/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/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 00000000..becf0511 --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const execFileAsync = promisify(execFile); + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`)); + }); + }); +} + +async function pathExists(target) { + try { + await access(target); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +async function directorySnapshot(root) { + const snapshot = []; + async function walk(current) { + const children = await readdir(current, { withFileTypes: true }); + for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) { + const target = path.join(current, child.name); + const relative = path.relative(root, target); + const stats = await lstat(target); + if (child.isDirectory()) { + snapshot.push([relative, 'directory', stats.mode & 0o777]); + await walk(target); + } else { + snapshot.push([ + relative, + 'file', + stats.mode & 0o777, + (await readFile(target)).toString('base64'), + ]); + } + } + } + await walk(root); + return JSON.stringify(snapshot); +} + +async function directoriesMatch(left, right) { + return (await directorySnapshot(left)) === (await directorySnapshot(right)); +} + +export async function installHooks({ + root = process.cwd(), + disabled = process.env.HUSKY === '0', + quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'), + runHusky = async (_stagingHooks, stagingRepo) => { + await execFileAsync('git', ['init', '--quiet', stagingRepo]); + await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], { + cwd: stagingRepo, + stdio: 'inherit', + }); + }, + activateHooks = async () => { + await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' }); + }, +} = {}) { + if (disabled) return; + + const huskyDir = path.join(root, '.husky'); + const active = path.join(huskyDir, '_'); + const nonce = `${Date.now()}-${process.pid}`; + const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`); + const stagingHooks = path.join(stagingRepo, '.husky'); + const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`); + await mkdir(huskyDir, { recursive: true }); + await mkdir(quarantineRoot, { recursive: true }); + + const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h'))); + let previousQuarantined = false; + try { + if ((await pathExists(active)) && !previousComplete) { + await rename(active, quarantined); + previousQuarantined = true; + } + await mkdir(stagingRepo, { recursive: true }); + await runHusky(stagingHooks, stagingRepo); + const staged = path.join(stagingHooks, '_'); + if (!(await pathExists(path.join(staged, 'h')))) { + throw new Error('husky did not produce its required h shim'); + } + if (previousComplete) { + if (!(await directoriesMatch(active, staged))) { + throw new Error('existing complete hook set differs from the installed Husky version'); + } + await rm(stagingRepo, { recursive: true, force: true }); + } else { + await rename(staged, active); + await rm(stagingRepo, { recursive: true, force: true }); + } + await activateHooks(); + if (previousQuarantined) { + try { + await rm(quarantined, { recursive: true, force: true }); + } catch { + console.warn( + `Previous hook state was deactivated but remains quarantined at ${quarantined}`, + ); + } + } + } catch (error) { + const cleanupFailures = []; + try { + if (await pathExists(stagingRepo)) { + await rename(stagingRepo, `${quarantined}-staging`); + } + } catch (cleanupError) { + cleanupFailures.push(`staging hooks: ${cleanupError.message}`); + } + const cleanup = + cleanupFailures.length === 0 + ? 'No partial hook set was activated.' + : `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`; + throw new Error( + `Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`, + { cause: error }, + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await installHooks(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/install-hooks.test.mjs b/scripts/install-hooks.test.mjs new file mode 100644 index 00000000..512307b4 --- /dev/null +++ b/scripts/install-hooks.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { installHooks } from './install-hooks.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`); +const quarantineRoot = path.join(fixtureRoot, 'quarantine'); + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, '.husky'), { recursive: true }); + return root; +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('an interrupted install quarantines the partial active hook set and fails loudly', async () => { + const root = await fixture('interrupted'); + let restoredHooksPath = 'not-called'; + + await assert.rejects( + installHooks({ + root, + quarantineRoot, + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'partial'); + throw new Error('simulated interruption'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async (value) => { + restoredHooksPath = value; + }, + }), + (error) => { + assert.match(error.message, /Hook installation failed/); + assert.match(error.message, /pnpm install --frozen-lockfile/); + return true; + }, + ); + + assert.equal(await exists(path.join(root, '.husky', '_')), false); + assert.equal(restoredHooksPath, 'not-called'); + const quarantined = await readdir(quarantineRoot); + assert.equal(quarantined.length, 1); +}); + +test('a failed replacement restores a previously complete active hook set', async () => { + const root = await fixture('rollback'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'previous-complete'); + let previousRemainedActiveDuringStaging = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'), + runHusky: async () => { + previousRemainedActiveDuringStaging = + (await readFile(activeShim, 'utf8')) === 'previous-complete'; + throw new Error('simulated replacement failure'); + }, + activateHooks: async () => {}, + readHooksPath: async () => '.husky/_', + restoreHooksPath: async () => {}, + }), + /Hook installation failed/, + ); + + assert.equal(previousRemainedActiveDuringStaging, true); + assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete'); +}); + +test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => { + const root = await fixture('mismatch'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'old-complete'); + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete'); + }, + activateHooks: async () => {}, + readHooksPath: async () => '.husky/_', + restoreHooksPath: async () => {}, + }), + /Hook installation failed.*pnpm install --frozen-lockfile/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'old-complete'); +}); + +test('a competing successful installer is not removed by the losing process', async () => { + const root = await fixture('concurrent'); + const activeShim = path.join(root, '.husky', '_', 'h'); + let restored = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'ours'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'peer'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async () => { + restored = true; + }, + }), + /Hook installation failed/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'peer'); + assert.equal(restored, false); +}); + +test("a competing installer that replaces this installer's active set is preserved", async () => { + const root = await fixture('concurrent-after-rename'); + const active = path.join(root, '.husky', '_'); + const activeShim = path.join(active, 'h'); + let restored = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'ours'); + }, + activateHooks: async () => { + await rm(active, { recursive: true, force: true }); + await mkdir(active, { recursive: true }); + await writeFile(activeShim, 'peer'); + throw new Error('our activation lost to peer'); + }, + readHooksPath: async () => null, + restoreHooksPath: async () => { + restored = true; + }, + }), + /Hook installation failed/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'peer'); + assert.equal(restored, false); +}); + +test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => { + const root = await fixture('disabled'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'preserved'); + let ran = false; + + await installHooks({ + root, + disabled: true, + runHusky: async () => { + ran = true; + }, + }); + + assert.equal(ran, false); + assert.equal(await readFile(activeShim, 'utf8'), 'preserved'); +}); + +test('a successful install leaves a complete active hook set', async () => { + const root = await fixture('success'); + + await installHooks({ + root, + quarantineRoot, + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'complete'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async () => {}, + }); + + assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true); +}); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs new file mode 100644 index 00000000..c1f5c99a --- /dev/null +++ b/scripts/preflight.mjs @@ -0,0 +1,254 @@ +#!/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(); +} diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs new file mode 100644 index 00000000..1e351912 --- /dev/null +++ b/scripts/preflight.test.mjs @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { runPreflight, sourceFingerprint } from './preflight.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`); + +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'); + return root; +} + +async function installRequiredBins(root) { + const binDir = path.join(root, 'node_modules', '.bin'); + await mkdir(binDir, { recursive: true }); + await Promise.all( + requiredBins.map(async (name) => { + const target = path.join(binDir, name); + await writeFile(target, ''); + await chmod(target, 0o755); + }), + ); +} + +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 }); +}); + +test('missing dependencies have a dedicated exit code and install remediation', async () => { + const root = await fixture('missing-deps'); + const result = await runPreflight({ root }); + + assert.equal(result.code, 42); + assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/); + assert.match(result.message, /run pnpm install/i); +}); + +test('a partial dependency install keeps the dedicated missing-deps result', async () => { + const root = await fixture('partial-deps'); + await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true }); + await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 }); + + const result = await runPreflight({ root }); + assert.equal(result.code, 42); + assert.match(result.message, /turbo/); +}); + +test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => { + const root = await fixture('dangling-deps'); + await installRequiredBins(root); + const turbo = path.join(root, 'node_modules', '.bin', 'turbo'); + await rm(turbo); + await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo); + + const result = await runPreflight({ root }); + assert.equal(result.code, 42); + assert.match(result.message, /turbo/); +}); + +test('installed dependencies pass when generated state is absent', 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' }); +});