diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs index 452be24395..878d0ad5ec 100644 --- a/apps/desktop/scripts/bundle-electron-main.mjs +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -36,10 +36,14 @@ export async function bundleElectronMain({ source, out, stamp, dev = false }) { // Dev bundles leave the environment alone so source-tree resolution keeps working. const envBanner = dev ? '' : environmentDefaultsBanner(process.env.HERMES_BUNDLE_ENV_JSON || '{}') const define = {} + let bakedStamp = null if (!dev) { if (!stamp) throw new Error('A prepared install stamp is required') const raw = readFileSync(stamp, 'utf8') const metadata = JSON.parse(raw) + // The baked bytes, and the clock the caller must record: re-reading the stamp + // after this point would report a restamp the output never saw. + bakedStamp = metadata.builtAt ?? null define['process.env.HERMES_DESKTOP_IS_PACKAGED'] = JSON.stringify(true) define.__HERMES_INSTALL_STAMP__ = raw define.__HERMES_PRODUCT_IDENTITY__ = productIdentity(source, metadata) @@ -77,6 +81,7 @@ export async function bundleElectronMain({ source, out, stamp, dev = false }) { format: 'cjs', outfile: join(out, 'preview-guest-preload.js'), }) + return { stampClock: bakedStamp } } if (isMain(import.meta.url)) { diff --git a/scripts/build/README.md b/scripts/build/README.md index 7362d7b654..7a7da510f5 100644 --- a/scripts/build/README.md +++ b/scripts/build/README.md @@ -128,10 +128,17 @@ It records product/host identity, content hashes of workspace/shared sources and build inputs, and the exact supplied icon directory, desktop install stamp, and native-dependency tree. The desktop stamp is hashed as its provenance identity rather than its raw bytes: `write-build-stamp.mjs` rewrites the `builtAt` clock on -every build, so hashing it byte-for-byte let the build's own first step invalidate -the receipt it was about to write (#123308). Inputs are checked again before -publication: a concurrent input change fails the build and preserves the previous -output. Output validation +every build, so hashing it byte-for-byte let a second build racing this one kill +it with "inputs changed" for a difference the build machinery itself made +(#123308). The clock is not dropped, only excluded from that comparison: the +receipt records it as `stampClock`, the value the bake actually embedded, because +packaging copies the live stamp file into the bundle *after* the receipt is +written. A product whose packaged clock disagrees with the clock baked into its +main process must not be reported current — `detectBundleSwap` would otherwise +offer a relaunch for a bundle that was never replaced. A receipt predating the +field is compared by hash alone until the next build rewrites it. Inputs are +checked again before publication: a concurrent provenance change fails the build +and preserves the previous output. Output validation checks renderer/main/preload/public bytes and the native file inventory; native bytes may change through signing after compilation. Native ABI verification remains with the native provider and desktop compiler. diff --git a/scripts/build/desktop.mjs b/scripts/build/desktop.mjs index 96a0b5251b..b8d9ab5185 100644 --- a/scripts/build/desktop.mjs +++ b/scripts/build/desktop.mjs @@ -59,11 +59,15 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type cacheDir: join(scratch, 'vite-cache'), build: { outDir: product, emptyOutDir: true }, }) - await bundleElectronMain({ source, out: product, stamp }) + // The receipt must carry the clock this output BAKED, not whatever the live + // stamp says by now: electron-builder copies that live file into the bundle + // after the receipt is written, and productCurrent must be able to tell that + // the two disagree (#123308). + const { stampClock } = await bundleElectronMain({ source, out: product, stamp }) copyNativeTree({ nativeDeps, out: join(product, 'node_modules') }) const result = checkDistBuilt(product) if (!result.ok) throw new Error(result.error) - recordProduct({ source, product: 'desktop', out: product, inputs }) + recordProduct({ source, product: 'desktop', out: product, inputs, stampClock }) }, { source }) return { out } } diff --git a/scripts/build/freshness.mjs b/scripts/build/freshness.mjs index 5318e90b01..0f3475d0e6 100644 --- a/scripts/build/freshness.mjs +++ b/scripts/build/freshness.mjs @@ -65,25 +65,55 @@ function outputHash(out) { } // The desktop install stamp is a real prepared input — buildDesktop bakes its bytes -// into electron-main.mjs — but write-build-stamp.mjs rewrites `builtAt` on every build -// (#123308). Hashing the whole file made the build's own first step invalidate the -// receipt it was about to write, so a second build racing the first killed it on -// "inputs changed". Hash the identity the output actually depends on instead: -// the baked bytes change only when provenance does, never when the clock moves. +// into electron-main.mjs — but write-build-stamp.mjs rewrites its `builtAt` clock on +// every build (#123308). Hashing the whole file let a second build racing this one +// kill it with "inputs changed" for a difference the build machinery itself made. +// Hash the stamp's provenance identity instead: commit/payload/tag must still +// invalidate, and only the clock is ignored. +// +// The clock is NOT dropped, only excluded from this comparison. It is recorded +// separately in the receipt as `stampClock` — the value the bake actually used — +// because the output really does embed it: outputHash covers dist/ at build time, +// but electron-builder's extraResources copies the live stamp file into the bundle +// afterwards (electron-builder.config.cjs), so a restamp landing between the two +// would ship a bundle whose Resources/install-stamp.json disagrees with the baked +// main, and detectBundleSwap (bundle-swap.ts) would report a swap for a bundle +// that was never replaced. The path differs per packager (the desktop workspace +// stamp, the release bundle's own stamp, a Nix store path); the race does not. const stampClockFields = new Set(['builtAt']) -// An unreadable/non-JSON stamp is not an identity loss: its bytes still feed the hash, -// so the file's content keeps deciding freshness and only the clock is ignored. +// Key order is not part of provenance: write-build-stamp emits one fixed literal +// order today, but a cosmetic re-serialisation must not force a rebuild. +function stampIdentity(identity) { + return Object.fromEntries(Object.entries(identity) + .filter(([key]) => !stampClockFields.has(key)) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) +} + +// Missing and non-JSON stamps fall back to the whole-file tree hash rather than +// failing: a missing input must keep reading as one distinct hash, never as a +// throw, so a build that has not stamped yet stays "not current" instead of +// aborting. Only a parsable stamp object can have its clock removed. function stampContentHash(path) { - let raw = readFileSync(path) - let identity + const parsed = readStamp(path) + return parsed ? createHash('sha256').update(JSON.stringify(stampIdentity(parsed))).digest('hex') + : treeHash(resolve(path), ['.'], () => false) +} + +function readStamp(path) { + let identity = null try { - identity = JSON.parse(raw) - } catch { return treeHash(resolve(path), ['.'], () => false) } - if (!identity || typeof identity !== 'object' || Array.isArray(identity)) return treeHash(resolve(path), ['.'], () => false) - return createHash('sha256').update(JSON.stringify( - Object.fromEntries(Object.entries(identity).filter(([key]) => !stampClockFields.has(key))), - )).digest('hex') + identity = JSON.parse(readFileSync(path)) + } catch { return null } + return identity && typeof identity === 'object' && !Array.isArray(identity) ? identity : null +} + +/** The clock the output bakes, or null when the stamp is absent/unparsable. */ +function stampClock(prepared = {}) { + const path = prepared.stamp + if (!path) return null + const identity = readStamp(resolve(path)) + return identity ? (identity.builtAt ?? null) : null } export function buildInputs(source, product, prepared = {}) { @@ -99,23 +129,40 @@ function preparedPaths(inputs) { return Object.fromEntries(inputs.prepared.map(({ name, path }) => [name, path])) } -export function recordProduct({ source, product, out, inputs }) { +// The clock the compiler BAKED, not the clock on disk now. desktop.mjs must pass +// the clock it read at bundleElectronMain: re-reading the stamp here would record +// a restamp that landed in between as the baked value, and productCurrent would +// then certify a product whose packaged clock disagrees with electron-main.mjs. +// Undefined (tui/web, which have no stamp) is dropped from the receipt, which is +// exactly the "predates this" shape productCurrent already tolerates. +export function recordProduct({ source, product, out, inputs, stampClock }) { if (JSON.stringify(buildInputs(source, product, preparedPaths(inputs))) !== JSON.stringify(inputs)) { throw new Error('Build inputs changed during compilation; retry the build') } writeFileSync(join(out, receiptName), JSON.stringify({ schema: 1, product, platform: process.platform, arch: process.arch, node: process.versions.node, - inputs, outputHash: outputHash(out), + inputs, stampClock, outputHash: outputHash(out), }) + '\n') } export function productCurrent({ source, product, out, prepared }) { try { const saved = JSON.parse(readFileSync(join(out, receiptName), 'utf8')) + const paths = prepared ?? preparedPaths(saved.inputs) return saved.schema === 1 && saved.product === product && saved.platform === process.platform && saved.arch === process.arch && saved.node === process.versions.node - && JSON.stringify(saved.inputs) === JSON.stringify(buildInputs(source, product, prepared ?? preparedPaths(saved.inputs))) + // The pre-build gate, unlike recordProduct, must also see the clock. A restamp + // that lands after the build (a racing write-build-stamp, or a second builder + // that reached extraResources first) leaves dist baking one builtAt while the + // live stamp says another, and packaging would then ship a bundle that + // detectBundleSwap reports as replaced. A receipt with no recorded clock + // predates this and stays comparable by hash alone. That branch is not a + // packaging hole: the release path (scripts/bundles/desktop.py) never consults + // this gate, it always stamps and compiles, and the source launchers that do + // consult it reuse the dist already on disk rather than packaging a new bundle. + && (saved.stampClock === undefined || saved.stampClock === stampClock(paths)) + && JSON.stringify(saved.inputs) === JSON.stringify(buildInputs(source, product, paths)) && saved.outputHash === outputHash(out) } catch { return false } }