diff --git a/internal/check-poster-render-pinned.mjs b/internal/check-poster-render-pinned.mjs index af320520e..d1018fb63 100644 --- a/internal/check-poster-render-pinned.mjs +++ b/internal/check-poster-render-pinned.mjs @@ -25,14 +25,21 @@ const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} ${m}`); if (!c) fai const source = readFileSync(join(REPO, 'library/packet/poster-render.js'), 'utf8') const served = readFileSync(join(REPO, 'public/dx-maps/assets/lib/poster-render.js'), 'utf8') -const { POSTER_RENDER_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js')) +const { POSTER_RENDER_SHA384, NE_PLACES_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js')) ok(POSTER_RENDER_SHA384 === pinOf(source), `the pin matches library/packet/poster-render.js${POSTER_RENDER_SHA384 === pinOf(source) ? '' : ' — run: node library/scripts/posters/pin-poster-render.mjs'}`) ok(pinOf(served) === pinOf(source), 'the served copy (assets/lib/poster-render.js) is the pinned file') +const placesSrc = readFileSync(join(REPO, 'library/scripts/posters/data/ne-places.json'), 'utf8') +const placesServed = readFileSync(join(REPO, 'public/dx-maps/assets/boundaries/ne-places.json'), 'utf8') +ok(NE_PLACES_SHA384 === pinOf(placesSrc) && pinOf(placesServed) === pinOf(placesSrc), + 'the capitals-and-cities pin matches its source and the served copy (boundaries/ne-places.json)') const { generateSheet, countryZone } = await import(join(REPO, 'library/packet/posterSheets.js')) const zone = countryZone({ id: 'country-tcd', iso3: 'TCD', label: 'Chad', count: 1 }) -const pins = [{ name: 'Probe', lat: 15, lng: 19, slug: 'probe', country_code: 'TCD', population: 1 }] +// Probe pins spread across Chad, so the sheet's view holds real capitals and cities (one pin frames too +// small an area for any place to fall inside it, and the places check below would see nothing). +const pins = [[12.1, 15.0], [13.8, 20.8], [9.1, 18.4], [17.9, 19.1], [8.6, 16.1]] + .map(([lat, lng], i) => ({ name: `Probe ${i + 1}`, lat, lng, slug: `probe-${i}`, country_code: 'TCD', population: 1000 })) // TAMPERED: the pinned file plus one line that would run on import. const tmp = mkdtempSync(join(tmpdir(), 'poster-pin-')) @@ -52,5 +59,22 @@ const html = await generateSheet({ zone, pins, format: '3x3', seriesId: 'country assetsBase: pathToFileURL(join(REPO, 'public/dx-maps/assets')).href, date: '2026-09-29' }) ok(typeof html === 'string' && html.length > 10000 && / ({ ...q, name: 'Forged' })) + writeFileSync(join(tmp2, 'boundaries/ne-places.json'), JSON.stringify(doctored)) + const tampered = await generateSheet({ ...base, assetsBase: pathToFileURL(tmp2).href }) + ok(tampered === none && !tampered.includes('Forged'), 'a tampered capitals-and-cities table is not used — the sheet draws no city dots') + } finally { rmSync(tmp2, { recursive: true, force: true }) } + ok(html !== none, 'the genuine table IS used (its sheet differs from one with no places)') +} + if (fails) { console.log(`POSTER-RENDER-PINNED: FAIL (${fails})`); process.exit(1) } -console.log('POSTER-RENDER-PINNED: PASS — the served generator is the pinned one, a tampered copy is refused unrun, and the genuine copy draws') +console.log('POSTER-RENDER-PINNED: PASS — the served generator and places table are the pinned ones, a tampered generator is refused unrun, a tampered table is not used, and the genuine copies draw') diff --git a/library/packet/poster-render.integrity.js b/library/packet/poster-render.integrity.js index cb6fddb8f..d9da2de2f 100644 --- a/library/packet/poster-render.integrity.js +++ b/library/packet/poster-render.integrity.js @@ -3,3 +3,6 @@ // (/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at // print time is integrity-pinned like every embed, because it executes on partner pages. export const POSTER_RENDER_SHA384 = 'sha384-8uzpgX1i2IEQNEh7WIxVxanacbBvX2r11/VeqSxYHzInWdV5XlLDoMCONlYI2pFA' +// The sha384 of library/scripts/posters/data/ne-places.json, served at /boundaries/ne-places.json. +// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones. +export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8rHTX++mgSCih4LO4Rdrb2MIQy' diff --git a/library/packet/posterSheets.js b/library/packet/posterSheets.js index cac3125e1..2746480be 100644 --- a/library/packet/posterSheets.js +++ b/library/packet/posterSheets.js @@ -15,7 +15,7 @@ * const html = await generateSheet({ zone, pins, zoneFC, format: 'packet', seriesId: 'upg100', * ISO3_NAME, series: { totalGroups, totalLists, index } }) */ -import { POSTER_RENDER_SHA384 } from './poster-render.integrity.js' +import { POSTER_RENDER_SHA384, NE_PLACES_SHA384 } from './poster-render.integrity.js' // ★ THE GENERATOR IS SERVED, NOT BUNDLED, AND IT IS INTEGRITY-PINNED. poster-render.js is served verbatim // at /lib/poster-render.js (generate-tree) and loaded at the FIRST print: a single-file build @@ -68,9 +68,27 @@ function loadShared() { // The Natural Earth capitals and cities, FETCHED at print time from the served tree (boundaries/ // ne-places.json) rather than bundled: 170 KB that only a reader who prints should pay for. Without an // asset base, or on any failure, the sheet draws no city dots — the generator's own rule for absent data. +// PINNED like the generator (NE_PLACES_SHA384): the text is hashed before it is parsed, and a table that is +// not the pinned one is not used — the sheet then draws no city dots and says so in the console, rather +// than plotting capitals it cannot vouch for. +const placesCache = new Map() function loadPlaces(assetsBase) { if (!assetsBase) return Promise.resolve([]) - return getJSON(`${assetsBase}/boundaries/ne-places.json`).then((j) => (j && j.places) || []).catch(() => []) + const url = abs(`${assetsBase}/boundaries/ne-places.json`) + if (!placesCache.has(url)) { + const p = (async () => { + const res = await fetch(url, { cache: 'force-cache' }) + if (!res.ok) return [] + const text = await res.text() + if ((await sha384(text)) !== NE_PLACES_SHA384) { + console.warn(`[posterSheets] ${url} failed its integrity check — drawing no city dots`) + return [] + } + return JSON.parse(text).places || [] + })().catch(() => []) + placesCache.set(url, p) + } + return placesCache.get(url) } /** diff --git a/library/scripts/posters/pin-poster-render.mjs b/library/scripts/posters/pin-poster-render.mjs index 3022c756d..3680e31a1 100644 --- a/library/scripts/posters/pin-poster-render.mjs +++ b/library/scripts/posters/pin-poster-render.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -// pin-poster-render.mjs — write library/packet/poster-render.integrity.js, the sha384 the browser checks -// before it runs the served map-sheet generator. Run it whenever library/packet/poster-render.js changes; -// check:poster-render-pinned fails (and names this command) until the pin matches the file again. +// pin-poster-render.mjs — write library/packet/poster-render.integrity.js, the sha384s the browser checks +// before it runs the served map-sheet generator or uses the served capitals-and-cities table. Run it +// whenever library/packet/poster-render.js or library/scripts/posters/data/ne-places.json changes; +// check:poster-render-pinned fails (and names this command) until the pins match the files again. // // node library/scripts/posters/pin-poster-render.mjs import { readFileSync, writeFileSync } from 'node:fs' @@ -15,11 +16,15 @@ export const pinOf = (text) => `sha384-${createHash('sha384').update(text, 'utf8 const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] if (isMain) { const pin = pinOf(readFileSync(join(LIB, 'poster-render.js'), 'utf8')) + const places = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'data', 'ne-places.json'), 'utf8')) writeFileSync(join(LIB, 'poster-render.integrity.js'), `// GENERATED by library/scripts/posters/pin-poster-render.mjs — do not edit by hand. // The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file // (/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at // print time is integrity-pinned like every embed, because it executes on partner pages. export const POSTER_RENDER_SHA384 = '${pin}' +// The sha384 of library/scripts/posters/data/ne-places.json, served at /boundaries/ne-places.json. +// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones. +export const NE_PLACES_SHA384 = '${places}' `) - console.log(`[pin-poster-render] ${pin}`) + console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}`) }