diff --git a/internal/fix-sri.mjs b/internal/fix-sri.mjs new file mode 100644 index 000000000..7ed08e5d8 --- /dev/null +++ b/internal/fix-sri.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** + * fix-sri.mjs — rewrite every baked SRI hash in an ALREADY-BUILT tree, and nothing else. + * + * ★ WHY THIS EXISTS, and the premise it CORRECTS. + * The dispatch that asked for this said: "`bun run tree` regenerates manifest.json + leaf pages + * but NOT the baked SRI hashes, so `bun run check:sri` fails." MEASURED 2026-09-23, that is + * FALSE — generate-tree.mjs hashes each bundle (its `integrity` local, ~line 1054) and threads + * the value into every page it writes. Proven by poisoning one leaf's integrity + * (check:sri -> exit 1), running `bun run tree`, and re-checking (exit 0). + * + * So the cheap path was never missing. What was missing is the SIGNPOST and the SIZE: + * + * 1. THE SIGNPOST. check:sri's own failure text said "FIX: run a full `bun run build`". An agent + * that trusts the gate it just ran is then sent straight at the destructive path — whose first + * step deletes public/doxa-maps/ and tears the tree out from under every concurrent reader + * (measured 3+ times: a 461-file phantom deletion, several false "element did not mount" + * diagnoses). The gate was TEACHING the failure mode the build-lock guard exists to prevent. + * That message now names this script. A gate that diagnoses correctly and prescribes the + * dangerous remedy is worse than one that says nothing, because it is obeyed. + * + * 2. THE SIZE. `bun run tree` is cheap only next to a full build. MEASURED on this box: 2.7-4.5s, + * and it WRITES 1234 files under public/ (450 under public/doxa-maps) every single run, + * because it re-copies 401 runtime assets and re-emits all 48 pages. That churn is not free + * to your neighbours: internal/settled-tree-gate.mjs fails a tree with ANY write in the last + * 45s, so one `bun run tree` blinds every other agent's check:settled for three quarters of a + * minute, and those agents are told "do NOT measure or publish this tree." + * + * Worse, on a SHARED BRANCH `tree` regenerates pages FROM SOURCE — so it silently bakes a + * neighbour's in-flight .vue edits into leaf pages under YOUR change. Same family as the + * measured hazard of committing a built bundle on a shared branch. + * + * This script writes ONLY the pages whose hash actually disagrees. In the normal case that is + * one file, and when the tree is already consistent it is ZERO files — nothing to churn, + * nothing to un-settle, no source re-read at all. + * + * ★ WHAT IT DOES NOT DO — and this limit is the point, not an omission. + * It makes the HASHES AGREE. It does not make the BUNDLE CORRECT. If public/doxa-maps/x/x.js was + * built from stale source, this bakes a matching hash for stale code and check:sri goes green. + * That is equally true of `bun run tree`, and it is why this is a REPAIR verb, not a build verb: + * build the bundle first (`BUNDLE=x bun x vite build`), then run this to re-agree the leaves. + * Bundle FRESHNESS is a different question with its own gate — `bun run check:composite`. + * It also asserts nothing about how many leaves SHOULD exist; a tree gutted by a partial build + * is still unpublishable after this passes. That is settled-tree-gate.mjs's signal 4. + * The publish runbook still runs BOTH: `bun run check:settled && bun run check:sri`. + * + * WHAT IT REWRITES + * - every `\n') + // (b) a leaf with NO integrity at all — invisible to check:sri, still a real downgrade + writeFileSync(join(dir, 'b', 'q', 'index.html'), + '\n') + // (c) a manifest with a wrong bundle hash AND a wrong paste-verbatim embed snippet + writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ + schema: 'self-test', bundles: [{ + bundle: 'b', scriptUrl: './b/b.js', integrity: WRONG, + profiles: [{ profile: 'p', embed: { script: '' } }], + }], + }, null, 2) + '\n') + + // 1. THE CONTROL MUST BE ABLE TO GO RED. A fixer validated by a check that has only ever + // printed PASS proves nothing at all. + const red = spawnSync(process.execPath, [CHECKER, dir], { encoding: 'utf8' }) + ok(red.status === 1, 'check:sri goes RED on the poisoned tree (exit ' + red.status + ', expected 1)') + ok(/b\/p\/index\.html/.test(red.stderr + red.stdout), + 'it goes red for the RIGHT page (b/p/index.html named in the output)') + + // 2. repair + const st = fixTree(dir) + ok(st.rehashed >= 2, 'rehashed the stale leaf AND the stale manifest bundle (rehashed=' + st.rehashed + ', expected >=2)') + ok(st.added === 1, 'added the ONE missing integrity (added=' + st.added + ')') + ok(st.missing.length === 0, 'no unresolvable script refs (missing=' + st.missing.length + ')') + + // 3. GREEN AFTER — and not vacuously green: it must have had something to check. + const green = spawnSync(process.execPath, [CHECKER, dir], { encoding: 'utf8' }) + ok(green.status === 0, 'check:sri goes GREEN after the repair (exit ' + green.status + ')') + const checked = Number((green.stdout.match(/checked (\d+) script reference/) || [])[1] || 0) + ok(checked >= 2, 'the green run actually MEASURED both leaves (checked=' + checked + ', expected >=2) — a tree with nothing to compare also prints PASS') + + // 4. the manifest, which check:sri never reads, is repaired too + const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')) + ok(m.bundles[0].integrity === REAL, 'manifest bundles[].integrity now matches the bytes') + ok(m.bundles[0].profiles[0].embed.script.includes(REAL), 'the paste-verbatim embed snippet now matches the bytes') + ok(readFileSync(join(dir, 'b', 'q', 'index.html'), 'utf8').includes('integrity="' + REAL + '" crossorigin="anonymous"'), + 'the integrity-less leaf gained one, in generate-tree\'s emitted shape') + + // 5. IDEMPOTENT — the whole point is that a consistent tree costs ZERO writes, so running + // this never un-settles the tree for a neighbouring agent. + const again = fixTree(dir) + ok(again.filesWritten.length === 0, 'a second run writes 0 file(s) (wrote ' + again.filesWritten.length + ')') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + + if (fails.length) { + console.error('\nFIX-SRI SELF-TEST: FAIL — ' + fails.length + ' assertion(s):') + for (const f of fails) console.error(' ✗ ' + f) + process.exit(1) + } + console.log('\nFIX-SRI SELF-TEST: PASS — the repair is proven against a check that was first proven to fail.') +} + +if (SELF_TEST) { + selfTest() +} else { + let code = 0 + for (const root of (TREES.length ? TREES : ['public/doxa-maps'])) { + code = report(root, fixTree(root)) || code + } + process.exit(code) +} diff --git a/internal/sri-consistency-check.mjs b/internal/sri-consistency-check.mjs index f75807ce7..b52a03fdb 100644 --- a/internal/sri-consistency-check.mjs +++ b/internal/sri-consistency-check.mjs @@ -10,8 +10,12 @@ * THE MECHANISM (know this before you "just rebuild one bundle"): * `BUNDLE=x bun x vite build` rewrites public/doxa-maps/x/x.js * but the leaf page public/doxa-maps/x/x/index.html carries a BAKED - * integrity="sha384-..." that is only regenerated by `bun run tree` - * (internal/generate-tree.mjs), which runs ONLY in the full build. + * integrity="sha384-..." that the per-bundle build does not touch. + * Two things re-bake it. `bun run fix:sri` (internal/fix-sri.mjs) rewrites the hashes and + * NOTHING else. `bun run tree` (internal/generate-tree.mjs) re-bakes them as a side effect of + * regenerating every page from source. NEITHER needs the full build — an earlier version of + * this comment said generate-tree "runs ONLY in the full build", which is false (it is its own + * script) and is exactly why agents kept reaching for the destructive rebuild. * So a single-bundle build leaves the bundle NEWER than its leaf. Publish that pair and * the browser refuses to execute the script: * "Failed to find a valid digest in the 'integrity' attribute for resource ..." @@ -109,8 +113,22 @@ if (bad.length) { console.error(' bundle is : ' + b.got + '...') } console.error('\n CAUSE: a single-bundle `BUNDLE=x bun x vite build` rewrote the .js without') - console.error(' regenerating the leaf (only the FULL `bun run build` runs `bun run tree`).') - console.error(' FIX : run a full `bun run build` on a SETTLED tree, then re-check. Do NOT publish.') + console.error(' re-baking the hash into the leaf that loads it.') + console.error(' FIX : bun run fix:sri # rewrites ONLY the disagreeing hashes (~0.4s, writes') + console.error(' # only the offending pages, leaves the tree settled)') + console.error(' …then re-run this check. Do NOT publish until it passes.') + console.error('') + console.error(' ★ DO NOT reach for `bun run build` here. This message used to prescribe exactly') + console.error(' that, and it is the destructive remedy: its first step DELETES public/doxa-maps/,') + console.error(' tearing the tree out from under every other agent reading it (measured 3+ times,') + console.error(' incl. a 461-file phantom deletion and several false "element did not mount"') + console.error(' diagnoses). A full rebuild cannot fix anything here that rehashing does not —') + console.error(' the bundles are already built; only the recorded hashes disagree.') + console.error(' `bun run tree` also re-bakes them correctly, but it rewrites 1234 files under') + console.error(' public/ every run (measured), which fails your neighbours\' check:settled for 45s') + console.error(' and re-reads .vue SOURCE — so on a shared branch it bakes in whatever another') + console.error(' agent has half-finished. Prefer fix:sri; use tree when you actually want the') + console.error(' pages regenerated from source.') process.exit(1) } diff --git a/package.json b/package.json index af4578fec..161082c25 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", + "fix:sri": "node internal/fix-sri.mjs", "check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.", "check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs", "check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle — this generator scans library/ directly, with no build step between). Fix: bun run library-index",