morphit/apps/web/scripts/build-manifest-release-json-smoke.ts

206 lines
9.3 KiB
TypeScript

/**
* build-manifest-release-json-smoke (cp319)
*
* Guards the launch-critical release-op hash_manifest pipeline that
* had silently diverged before cp319 (the release op has never been
* broadcast, so nobody had run generator → builder → validator as a
* pipeline). The two artifacts build-manifest.mjs emits are DISTINCT:
*
* • default → reproducible-build fingerprint
* (`<hex> ./<rel>` per line, brag #222).
* • `--release-json` → the on-chain morphit_release_v1 manifest
* (JSON object `/<served-path>: sha256-<b64>`).
*
* This smoke asserts:
* 1. computeManifest derives BOTH encodings from one digest, sorted.
* 2. renderSha256sumText keeps the EXACT legacy reproducibility
* format (a regression here would break clone-vs-ship diffs).
* 3. buildReleaseManifest emits SRI base64 under SERVED-PATH keys
* (`/<rel>`, never `./` — the frontend fetches each key as a
* same-origin URL), and --prefix scoping filters correctly.
* 4. Every SRI value matches the schema's SHA256_RE, and a payload
* built from the manifest passes the REAL validateReleasePayload.
* 5. No SRI value (and no serialized manifest) contains a bare
* 64-hex run, so the broadcaster's assertNoSecretHex never
* false-positives on a legitimate release manifest.
* 6. manifestSerializedBytes measures the compact serialization the
* schema's byteLengthOfJson measures, and an over-cap manifest is
* detectable (the CLI refuses it).
* 7. Static wiring: build-manifest.mjs exposes the --release-json
* mode + size guard, and PRE-LAUNCH-CHECKLIST §B drives the
* builder via the MORPHIT_BUILD_HASH_MANIFEST_FILE env var (NOT a
* nonexistent --hash-manifest flag).
*/
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateReleasePayload } from '@morphit/release-schema';
import {
computeManifest,
renderSha256sumText,
buildReleaseManifest,
manifestSerializedBytes
} from './build-manifest.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..', '..', '..');
let failures = 0;
let scenarios = 0;
const ok = (m: string) => {
console.log(`${m}`);
scenarios++;
};
const bad = (m: string, d: string) => {
console.error(`${m}\n ${d}`);
failures++;
scenarios++;
};
const SRI_RE = /^sha256-[A-Za-z0-9+/]{43}=$/; // mirror of release-schema SHA256_RE
const SECRET_HEX_RE = /\b[0-9a-f]{64}\b/; // mirror of releaseBroadcastOp assertNoSecretHex
const SCHEMA_CAP = 64 * 1024; // mirror of MANIFEST_MAX_SERIALIZED_BYTES
async function main(): Promise<void> {
// ── fixture build dir ──────────────────────────────────────────
const dir = await mkdtemp(join(tmpdir(), 'bm-smoke-'));
try {
await writeFile(join(dir, 'index.html'), '<html>shell</html>');
await mkdir(join(dir, '_app', 'immutable'), { recursive: true });
await writeFile(join(dir, '_app', 'immutable', 'app.js'), 'console.log("morphit")');
await writeFile(join(dir, 'service-worker.js'), '// sw');
await mkdir(join(dir, 'en'), { recursive: true });
await writeFile(join(dir, 'en', 'faq.html'), '<html>faq</html>');
// 1. computeManifest — both encodings, sorted
const entries = await computeManifest(dir);
const sorted = entries.every(
(e, i) => i === 0 || entries[i - 1]!.rel <= e.rel
);
const everyHasBoth = entries.every(
(e) => /^[0-9a-f]{64}$/.test(e.hex) && SRI_RE.test(e.sri)
);
if (entries.length === 4 && sorted && everyHasBoth)
ok('computeManifest: 4 entries, codepoint-sorted, each carries hex + SRI');
else
bad('computeManifest shape wrong', `n=${entries.length} sorted=${sorted} both=${everyHasBoth}`);
// digest parity: hex and sri are two encodings of the SAME digest
const sameDigest = entries.every((e) => {
const fromHex = Buffer.from(e.hex, 'hex').toString('base64');
return `sha256-${fromHex}` === e.sri;
});
if (sameDigest) ok('computeManifest: hex and SRI are the same digest in two encodings');
else bad('hex/SRI digest mismatch', 'a file would hash differently in the two outputs');
// 2. reproducibility text format unchanged
const text = renderSha256sumText(entries);
const lines = text.replace(/\n$/, '').split('\n');
const textOk =
text.endsWith('\n') &&
lines.length === 4 &&
lines.every((l) => /^[0-9a-f]{64} {2}\.\/.+$/.test(l));
if (textOk) ok("renderSha256sumText: legacy '<hex> ./<rel>' reproducibility format intact");
else bad('reproducibility text format changed', `would break clone-vs-ship diffs:\n${lines[0]}`);
// 3. release JSON — served-path keys + SRI values + scoping
const all = buildReleaseManifest(entries);
const keysServed = Object.keys(all).every((k) => k.startsWith('/') && !k.startsWith('/./'));
const valuesSri = Object.values(all).every((v) => SRI_RE.test(v as string));
if (Object.keys(all).length === 4 && keysServed && valuesSri)
ok('buildReleaseManifest: served-path keys (/<rel>) + SRI base64 values, all 4 files');
else
bad('release manifest shape wrong', `keys=${keysServed} values=${valuesSri}`);
const scoped = buildReleaseManifest(entries, { prefixes: ['_app/', 'index.html'] });
const scopedKeys = Object.keys(scoped).sort();
if (
scopedKeys.length === 2 &&
scopedKeys[0] === '/_app/immutable/app.js' &&
scopedKeys[1] === '/index.html'
)
ok('buildReleaseManifest --prefix scopes to the requested subtree(s)');
else bad('--prefix scoping wrong', `got ${JSON.stringify(scopedKeys)}`);
// normalized prefix: '/_app/', '_app/', '_app' all match
const p1 = Object.keys(buildReleaseManifest(entries, { prefixes: ['/_app/'] }));
const p2 = Object.keys(buildReleaseManifest(entries, { prefixes: ['_app'] }));
if (p1.length === 1 && p1[0] === '/_app/immutable/app.js' && p2.length === 1)
ok('--prefix normalizes leading/trailing slashes');
else bad('--prefix normalization wrong', `p1=${JSON.stringify(p1)} p2=${JSON.stringify(p2)}`);
// 4. REAL schema validation of a payload built from the manifest
const payload = {
version: '1.0.0',
hash_manifest: all,
endpoints: { rpc: ['https://rpc.blurt.world'] }
};
const res = validateReleasePayload(payload);
if (res.ok) ok('validateReleasePayload accepts a payload built from --release-json output');
else bad('release payload rejected by the real validator', `reason=${res.reason}`);
// 5. assertNoSecretHex never false-positives on a real manifest
const serialized = JSON.stringify(all);
if (!SECRET_HEX_RE.test(serialized) && Object.values(all).every((v) => !SECRET_HEX_RE.test(v as string)))
ok('no SRI value (or the serialized manifest) is a bare 64-hex run → broadcaster will not refuse it');
else bad('manifest contains a 64-hex run', 'assertNoSecretHex would block the launch broadcast');
// 6. size measurement matches the schema, and over-cap is detectable
const measured = manifestSerializedBytes(all);
const schemaMeasure = new TextEncoder().encode(JSON.stringify(all)).length;
if (measured === schemaMeasure)
ok('manifestSerializedBytes matches the schema byteLengthOfJson (compact UTF-8)');
else bad('size measurement diverged from the schema', `${measured} vs ${schemaMeasure}`);
const big: Record<string, string> = {};
for (let i = 0; i < 1000; i++) big[`/_app/immutable/chunk-${i}.js`] = 'sha256-' + 'A'.repeat(43) + '=';
const bigBytes = manifestSerializedBytes(big);
const bigValid = validateReleasePayload({
version: '1.0.0',
hash_manifest: big,
endpoints: { rpc: ['https://x.example'] }
});
if (bigBytes > SCHEMA_CAP && !bigValid.ok && bigValid.reason === 'hash_manifest_too_large')
ok(`over-cap manifest (${bigBytes} B) exceeds the ${SCHEMA_CAP}-byte cap and the schema rejects it`);
else bad('over-cap manifest not flagged', `bytes=${bigBytes} valid=${JSON.stringify(bigValid)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
// 7. static wiring guards
const genSrc = await readFile(join(REPO, 'apps/web/scripts/build-manifest.mjs'), 'utf-8');
if (
genSrc.includes('--release-json') &&
genSrc.includes('export function buildReleaseManifest') &&
genSrc.includes('MANIFEST_MAX_SERIALIZED_BYTES') &&
genSrc.includes('over the')
)
ok('build-manifest.mjs exposes --release-json mode + the 64 KB size guard');
else bad('build-manifest.mjs lost the release-json mode/guard', 'launch generator regressed');
const checklist = await readFile(join(REPO, 'docs/PRE-LAUNCH-CHECKLIST.md'), 'utf-8');
if (
checklist.includes('--release-json') &&
checklist.includes('MORPHIT_BUILD_HASH_MANIFEST_FILE') &&
!checklist.includes('--hash-manifest')
)
ok('PRE-LAUNCH-CHECKLIST §B drives the builder via the real env var (no dead --hash-manifest flag)');
else
bad(
'PRE-LAUNCH-CHECKLIST release-manifest instructions stale',
'must use --release-json + MORPHIT_BUILD_HASH_MANIFEST_FILE, not --hash-manifest'
);
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} build-manifest-release-json scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}
}
void main();