255 lines
9.2 KiB
JavaScript
255 lines
9.2 KiB
JavaScript
#!/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();
|
|
}
|