diablo2-web/scripts/verify-bundle-no-mpq.ts

562 lines
22 KiB
TypeScript

/**
* CI HARD GATE — the shipped bundle must not be *able* to fetch a game archive.
*
* WHY A STRING GREP IS NOT ENOUGH (and what this does instead)
* ------------------------------------------------------------
* The obvious gate is "grep `dist-game/` for `d2char.mpq`". That gate is a trap.
* It proves only that *one particular spelling* is absent, and every one of
* these evades it while leaving the capability fully intact:
*
* const a = 'd2char' + '.mpq'
* const b = `${name}.mpq`
* const c = ['d2','char','.mpq'].join('')
*
* So the moment someone removes the literal — by an innocent refactor, by a
* minifier transformation, or by deliberately routing around the gate — it goes
* green, convincingly, while the bundle can still stream an archive. A false
* green is worse than no gate, because it terminates scrutiny.
*
* This gate therefore asserts a **capability**, not a spelling:
*
* 1. CAPABILITY_NOT_REACHABLE (primary)
* Reconstruct the chunk dependency graph from the emitted artifact
* itself — follow `import("./x.js")` and `from"./x.js"` out of every
* HTML entry — and assert that no reachable chunk contains the
* *behavioural* fingerprints of the MPQ machinery: the HTTP-Range
* archive reader and the MPQ header decoder. Those fingerprints are
* error-message string literals inside the functions themselves, which
* survive minification and have nothing to do with any archive filename.
* Renaming or concatenating `d2char.mpq` does not move them.
*
* 2. NO_ARCHIVE_LITERALS (secondary, retained)
* The original literal scan. Still useful: it catches a new archive name
* that no fingerprint covers.
*
* 3. FRAGMENT_SCAN (informational)
* Fragmented forms — a bare `.mpq`, a standalone `mpq`/`dll` token —
* listed for human review, since a concatenation gate cannot be made
* reliable automatically.
*
* Both `*.mpq` AND `*.dll` are covered; the acceptance criterion names both.
*
* KNOWN-RED ON `main` (2026-09-21) — the expected, desired result
* ---------------------------------------------------------------
* `src/scene/act-scene.ts` has an unguarded production path:
*
* loadCharacterArt L2384
* -> getMountedCharArchives L2013
* -> getCachedMpqArchive L1968
* -> httpRangeSource(base + '/d2char.mpq') L1974
*
* Measured consequence in the artifact: `acts-*.js` dynamically imports
* `source-*.js` (the HTTP-Range reader) and `archive-*.js` (the MPQ decoder),
* and carries `DATA_ARCHIVES` / `CHARACTER_ARCHIVE` through minification.
*
* ⚠ BINDING NOTE FOR THE MILESTONE THAT FIXES THIS (M4):
* the fix must be **STRUCTURAL** — remove the live-MPQ path from the production
* entry graph, or isolate it behind a dev-only entry point, so the chunks are
* not emitted/reachable at all. It must NOT be renaming the constants, and it
* must NOT be splitting them into concatenation to evade the literal scan.
* Assertion 1 exists precisely so that evasion cannot produce a green gate.
*
* SOURCE MAPS
* -----------
* A `.map` embeds `sourcesContent`, i.e. the entire original source text, so a
* literal survives in the map even after the code using it is perfectly
* dead-code-eliminated. Hard-failing on maps would make the gate unsatisfiable
* without deleting the string from the source tree — exactly the pressure that
* gets a gate weakened instead of a bug fixed. Maps are therefore
* INFORMATIONAL, and are classified **by content** (parsed as a source map with
* `version` + `sources`), not by filename, so renaming an executable asset to
* `*.map` cannot launder it into the exempt bucket.
*
* VACUITY GUARD
* -------------
* A gate that passes because it examined nothing is the same failure class it
* is meant to prevent. The run fails unless it actually scanned at least one
* emitted `.js` asset and resolved at least one HTML entry into a chunk graph.
*
* Usage:
* npm run verify:bundle-no-mpq
* npm run verify:bundle-no-mpq -- --dir=dist-game --report=<path>
*/
import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, extname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(process.cwd())
// ---------------------------------------------------------------------------
// Patterns
// ---------------------------------------------------------------------------
/**
* Whole archive/library filenames.
*
* The named entries mirror `src/scene/act-scene.ts` L80-82 (`DATA_ARCHIVES`,
* `CHARACTER_ARCHIVE`); the two catch-alls mean a newly introduced archive name
* cannot slip past just because this list was not updated.
*/
const FORBIDDEN_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [
{ label: 'd2char.mpq', re: /d2char\.mpq/gi },
{ label: 'd2data.mpq', re: /d2data\.mpq/gi },
{ label: 'd2exp.mpq', re: /d2exp\.mpq/gi },
{ label: 'Patch_D2.mpq', re: /patch_d2\.mpq/gi },
{ label: '<any>.mpq', re: /[\w-]+\.mpq/gi },
{ label: '<any>.dll', re: /[\w-]+\.dll/gi },
]
/**
* Fragmented spellings a literal scan cannot reason about.
*
* Reported, never auto-failed: `'mpq'` appears in plenty of innocent contexts
* (a directory name, a comment that survived, a decoder's own identity string).
* Automatic failure here would produce noise that trains people to ignore the
* gate. Assertion 1 is what actually closes the concatenation hole; this exists
* so a human can see the fragments while reviewing.
*/
const FRAGMENT_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [
{ label: "bare '.mpq'", re: /["'`]\.mpq["'`]/gi },
{ label: "bare '.dll'", re: /["'`]\.dll["'`]/gi },
{ label: "standalone 'mpq' string", re: /["'`]mpq["'`]/gi },
{ label: "standalone 'dll' string", re: /["'`]dll["'`]/gi },
]
/**
* Behavioural fingerprints of the MPQ machinery.
*
* These are error-message literals from inside the functions that implement the
* capability, so they identify *the code being present*, independent of any
* archive filename. Verified to survive Vite/esbuild minification in the
* current build (they live in `source-*.js` and `archive-*.js`).
*/
const CAPABILITY_FINGERPRINTS: readonly {
readonly id: string
readonly needle: string
readonly origin: string
readonly why: string
}[] = [
{
id: 'HTTP_RANGE_ARCHIVE_READER',
needle: 'range requests are required to read an archive this large',
origin: 'src/mpq/source.ts httpRangeSource() L107-114',
why: 'the function that streams an archive over HTTP Range — the actual download capability',
},
{
id: 'MPQ_HEADER_DECODER',
needle: 'not an MPQ archive (magic 0x',
origin: 'src/mpq/archive.ts MpqArchive.open() L136',
why: 'the MPQ container decoder; present only if archive parsing ships',
},
{
id: 'MPQ_HASH_TABLE_DECODER',
needle: 'is not a non-zero power of two',
origin: 'src/mpq/archive.ts L156',
why: 'MPQ hash-table validation; corroborates the decoder fingerprint',
},
]
/** Extensions considered emitted/executed output. */
const EXECUTABLE_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.css', '.json'])
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
interface Occurrence {
readonly file: string
readonly pattern: string
readonly count: number
readonly sample: string
}
interface CapabilityHit {
readonly fingerprintId: string
readonly file: string
readonly reachableFrom: readonly string[]
readonly origin: string
readonly why: string
}
export interface BundleScanResult {
readonly root: string
readonly filesTotal: number
readonly emittedJsScanned: number
readonly entries: readonly string[]
/** entry html -> emitted chunk files reachable from it. */
readonly reachable: Readonly<Record<string, readonly string[]>>
readonly orphanChunks: readonly string[]
readonly capabilityHits: readonly CapabilityHit[]
readonly literalHard: readonly Occurrence[]
readonly literalSourceMapOnly: readonly Occurrence[]
readonly fragments: readonly Occurrence[]
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
/**
* Decide whether a file is a genuine source map, by parsing it.
*
* Extension alone is not enough: a rename must not be able to move an
* executable asset into the informational bucket.
*/
function isRealSourceMap(text: string): boolean {
if (text.length === 0 || !text.trimStart().startsWith('{')) return false
try {
const parsed = JSON.parse(text) as Record<string, unknown>
return (
typeof parsed['version'] === 'number' &&
Array.isArray(parsed['sources']) &&
(parsed['mappings'] !== undefined || parsed['sourcesContent'] !== undefined)
)
} catch {
// Not parseable as JSON, therefore not a source map. This is a
// classification answer, not a swallowed error: the caller treats the file
// as emitted output, which is the conservative direction.
return false
}
}
function sampleAround(text: string, re: RegExp): string {
const probe = new RegExp(re.source, re.flags.replace('g', ''))
const m = probe.exec(text)
if (m === null) return '(no sample)'
const from = Math.max(0, m.index - 90)
const to = Math.min(text.length, m.index + 90)
return `...${text.slice(from, to).replace(/\s+/g, ' ')}...`
}
/**
* Rebuild the chunk dependency graph from the artifact.
*
* Reading the artifact rather than a build-time manifest matters: it is the
* thing that actually ships, and it needs no change to `vite.config.ts` (which
* this milestone does not own). Static `from"./x.js"` / `import"./x.js"` and
* dynamic `import("./x.js")` are all followed, so a lazily-imported chunk — the
* exact shape the MPQ fallback uses — is still counted as reachable.
*/
function buildChunkGraph(root: string, files: readonly string[]): {
entries: string[]
reachable: Record<string, string[]>
orphans: string[]
} {
const rel = (f: string): string => relative(root, f).split('\\').join('/')
const byRel = new Map<string, string>()
for (const f of files) byRel.set(rel(f), f)
const htmlEntries = files.filter(f => f.toLowerCase().endsWith('.html')).map(rel)
const refsOf = (relPath: string): string[] => {
const abs = byRel.get(relPath)
if (abs === undefined) return []
let text: string
try {
text = readFileSync(abs, 'utf8')
} catch {
return []
}
const out = new Set<string>()
const dir = dirname(relPath)
const add = (spec: string): void => {
const joined = spec.startsWith('/')
? spec.replace(/^\/diablo2\//, '').replace(/^\//, '')
: join(dir, spec).split('\\').join('/')
if (byRel.has(joined)) out.add(joined)
}
// HTML: <script src=...> and <link href=...>
for (const m of text.matchAll(/(?:src|href)=["']([^"']+\.(?:js|mjs|css))["']/g)) add(m[1] ?? '')
// JS: static and dynamic module specifiers.
for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+\.(?:js|mjs))["']/g)) add(m[1] ?? '')
return [...out]
}
const reachable: Record<string, string[]> = {}
const everReached = new Set<string>()
for (const entry of htmlEntries) {
const seen = new Set<string>()
const stack = [entry]
while (stack.length > 0) {
const cur = stack.pop()
if (cur === undefined || seen.has(cur)) continue
seen.add(cur)
everReached.add(cur)
for (const next of refsOf(cur)) if (!seen.has(next)) stack.push(next)
}
seen.delete(entry)
reachable[entry] = [...seen].sort()
}
const orphans = files
.map(rel)
.filter(f => /\.(js|mjs)$/i.test(f) && !everReached.has(f))
.sort()
return { entries: htmlEntries.sort(), reachable, orphans }
}
// ---------------------------------------------------------------------------
// Scan
// ---------------------------------------------------------------------------
/** Scan a built output directory. Exported so a unit test can drive it. */
export function scanBundle(root: string): BundleScanResult {
const files = walk(root)
const { entries, reachable, orphans } = buildChunkGraph(root, files)
const literalHard: Occurrence[] = []
const literalSourceMapOnly: Occurrence[] = []
const fragments: Occurrence[] = []
const capabilityHits: CapabilityHit[] = []
let emittedJsScanned = 0
const relOf = (f: string): string => relative(root, f).split('\\').join('/')
const entriesReaching = (chunk: string): string[] =>
entries.filter(e => (reachable[e] ?? []).includes(chunk))
for (const file of files) {
const ext = extname(file).toLowerCase()
const looksLikeMap = file.toLowerCase().endsWith('.map')
if (!looksLikeMap && !EXECUTABLE_EXT.has(ext)) continue
let text: string
try {
text = readFileSync(file, 'utf8')
} catch (err) {
// An unreadable file inside the shipped output must not be skipped, or
// the gate reports green over a directory it never actually read.
throw new Error(`verify-bundle-no-mpq: cannot read shipped asset ${file}: ${String(err)}`)
}
// Classify by content, not by name.
const isMap = looksLikeMap && isRealSourceMap(text)
const relPath = relOf(file)
if (!isMap && /\.(js|mjs|cjs)$/i.test(relPath)) emittedJsScanned += 1
for (const { label, re } of FORBIDDEN_PATTERNS) {
const matches = text.match(new RegExp(re.source, re.flags))
if (matches === null || matches.length === 0) continue
const occ: Occurrence = { file: relPath, pattern: label, count: matches.length, sample: sampleAround(text, re) }
if (isMap) literalSourceMapOnly.push(occ)
else literalHard.push(occ)
}
if (!isMap) {
for (const { label, re } of FRAGMENT_PATTERNS) {
const matches = text.match(new RegExp(re.source, re.flags))
if (matches === null || matches.length === 0) continue
fragments.push({ file: relPath, pattern: label, count: matches.length, sample: sampleAround(text, re) })
}
for (const fp of CAPABILITY_FINGERPRINTS) {
if (!text.includes(fp.needle)) continue
capabilityHits.push({
fingerprintId: fp.id,
file: relPath,
reachableFrom: entriesReaching(relPath),
origin: fp.origin,
why: fp.why,
})
}
}
}
return {
root,
filesTotal: files.length,
emittedJsScanned,
entries,
reachable,
orphanChunks: orphans,
capabilityHits,
literalHard,
literalSourceMapOnly,
fragments,
}
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function assertWritablePath(candidate: string): string {
const full = resolve(candidate)
const rel = relative(ROOT, full)
if (rel.startsWith('..') || resolve(rel) === rel) {
throw new Error(`verify-bundle-no-mpq: refusing to write outside the worktree: ${full}`)
}
return full
}
function main(): void {
let dir = 'dist-game'
let reportPath = join(ROOT, '.agents', 'worker_mv', 'evidence', 'bundle-gate-report.json')
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--dir=')) dir = arg.slice('--dir='.length)
else if (arg.startsWith('--report=')) reportPath = resolve(arg.slice('--report='.length))
else if (!arg.startsWith('--')) dir = arg
}
const root = resolve(ROOT, dir)
console.log('======================================================================')
console.log('GATE: shipped bundle must not be ABLE to fetch a game archive')
console.log(' (capability assertion + literal scan; covers *.mpq AND *.dll)')
console.log('======================================================================')
console.log(`Scanning: ${root}`)
let stat
try {
stat = statSync(root)
} catch {
console.error(`\nFAIL: output directory does not exist: ${root}`)
console.error(' Build it first: npm run build:game')
process.exit(1)
}
if (!stat.isDirectory()) {
console.error(`\nFAIL: not a directory: ${root}`)
process.exit(1)
}
const r = scanBundle(root)
console.log(`Files present : ${r.filesTotal}`)
console.log(`Emitted .js scanned: ${r.emittedJsScanned}`)
console.log(`HTML entries : ${r.entries.join(', ') || '(none)'}`)
for (const e of r.entries) console.log(` ${e} -> ${(r.reachable[e] ?? []).length} reachable chunk(s)`)
if (r.orphanChunks.length > 0) {
console.log(`Orphan chunks (emitted but unreachable from any entry): ${r.orphanChunks.join(', ')}`)
}
const failures: string[] = []
// --- Guard: the scan must have actually examined something ---------------
if (r.emittedJsScanned === 0) {
failures.push('VACUITY: zero emitted .js assets were scanned — the gate examined nothing.')
}
if (r.entries.length === 0) {
failures.push('VACUITY: no HTML entry found — the chunk reachability graph is empty.')
}
const totalReachable = r.entries.reduce((n, e) => n + (r.reachable[e] ?? []).length, 0)
if (r.entries.length > 0 && totalReachable === 0) {
failures.push('VACUITY: entries resolved to zero chunks — module-reference parsing is broken.')
}
// --- 1. Capability ------------------------------------------------------
console.log('\n--- 1. CAPABILITY_NOT_REACHABLE (primary)')
const reachableHits = r.capabilityHits.filter(h => h.reachableFrom.length > 0)
const unreachableHits = r.capabilityHits.filter(h => h.reachableFrom.length === 0)
if (r.capabilityHits.length === 0) {
console.log(' PASS: none of the MPQ capability fingerprints appear in any emitted asset.')
} else {
for (const h of reachableHits) {
console.error(` HIT ${h.fingerprintId} in ${h.file}`)
console.error(` origin : ${h.origin}`)
console.error(` why it matters: ${h.why}`)
console.error(` reachable from: ${h.reachableFrom.join(', ')}`)
}
for (const h of unreachableHits) {
console.log(` INFO ${h.fingerprintId} in ${h.file} — present but unreachable from any entry (dead chunk).`)
}
}
if (reachableHits.length > 0) {
failures.push(
`CAPABILITY: ${reachableHits.length} MPQ fingerprint(s) reachable from a shipped entry — ` +
'the bundle can stream a game archive regardless of how the filename is spelled.',
)
}
// --- 2. Literals --------------------------------------------------------
console.log('\n--- 2. NO_ARCHIVE_LITERALS (secondary)')
if (r.literalSourceMapOnly.length > 0) {
console.log(` INFO: ${r.literalSourceMapOnly.length} occurrence group(s) in genuine source maps.`)
console.log(' A .map embeds the full original source (`sourcesContent`), so a string')
console.log(' survives there even after its code is eliminated. Classified by parsing')
console.log(' the file as a source map, not by its extension. Not a failure.')
for (const o of r.literalSourceMapOnly) console.log(` - ${o.file}: ${o.pattern} x${o.count}`)
}
if (r.literalHard.length === 0) {
console.log(' PASS: zero *.mpq / *.dll literals in emitted executable assets.')
} else {
console.error(` FAIL: ${r.literalHard.length} occurrence group(s) in EMITTED assets:`)
for (const o of r.literalHard) {
console.error(` ${o.file}`)
console.error(` pattern : ${o.pattern}`)
console.error(` count : ${o.count}`)
console.error(` context : ${o.sample}`)
}
failures.push(`LITERALS: ${r.literalHard.length} archive-name literal group(s) in emitted assets.`)
}
// --- 3. Fragments (informational) ---------------------------------------
console.log('\n--- 3. FRAGMENT_SCAN (informational — concatenation cannot be auto-judged)')
if (r.fragments.length === 0) {
console.log(' none')
} else {
for (const o of r.fragments) console.log(` ${o.file}: ${o.pattern} x${o.count}`)
console.log(' Review these by hand: a fragment is how a literal scan gets evaded.')
console.log(' Assertion 1 is what actually closes that hole.')
}
// --- Report -------------------------------------------------------------
const outPath = assertWritablePath(reportPath)
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(
outPath,
JSON.stringify(
{
generated: new Date().toISOString(),
root: relative(ROOT, root),
filesTotal: r.filesTotal,
emittedJsScanned: r.emittedJsScanned,
entries: r.entries,
reachable: r.reachable,
orphanChunks: r.orphanChunks,
capabilityHits: r.capabilityHits,
literalHard: r.literalHard,
// Surfaced in the report, not only the console, so the sourcemap
// exemption is a visible accepted state rather than a silent one.
literalSourceMapOnly: r.literalSourceMapOnly,
fragments: r.fragments,
failures,
verdict: failures.length === 0 ? 'PASS' : 'FAIL',
},
null,
2,
),
)
console.log(`\nReport: ${relative(ROOT, outPath)}`)
console.log('\n======================================================================')
if (failures.length === 0) {
console.log('PASS: the shipped bundle cannot fetch a game archive.')
process.exit(0)
}
console.error(`FAIL (${failures.length}):`)
for (const f of failures) console.error(` - ${f}`)
console.error('')
console.error(' Root cause on main (2026-09-21): unguarded tier-3 fallback')
console.error(' act-scene.ts loadCharacterArt L2384 -> getMountedCharArchives L2013')
console.error(' -> getCachedMpqArchive L1968 -> httpRangeSource(base + "/d2char.mpq") L1974')
console.error('')
console.error(' THE FIX MUST BE STRUCTURAL: remove the live-MPQ path from the production')
console.error(' entry graph (or isolate it behind a dev-only entry) so the chunks are not')
console.error(' reachable. Renaming the constants, or splitting them into concatenation to')
console.error(' evade the literal scan, will NOT satisfy assertion 1 and will be ruled a')
console.error(' failure. Do not weaken this gate.')
process.exit(1)
}
const invokedDirectly =
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) main()