From 9f3cc059852cbaf2342a699e6e97d61f9ad7d5e6 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 20 Aug 2026 12:10:37 +0000 Subject: [PATCH] fix: verify the build against its own receipt, with the expected mode as an argument (closes #309) script/verify-build computed its expectation from AUTISTMASK_DEBUG in its own environment, and the Makefile invoked it bare, so an operator with that flag exported who ran the release target got a debug bundle -- every wallet it creates carrying the publicly committed test recovery phrase -- verified green at exit 0. The mode is now the required argument --expect release|debug, with no default and nothing read from the environment; make build passes --expect release on an env -u AUTISTMASK_DEBUG environment and make build-debug passes --expect debug. The flag is deliberately still allowed to reach the compiler, so a shell that has it exported fails make build loudly rather than quietly receiving something other than the release build it asked for. The other half was provenance. The check was a marker grep over a file list read back out of dist/, so a 26-byte file containing only autistmask-build-debug=off verified ok, manifest.json and the content script that runs on every page were never read at all, and an entire hand-written dist/ passed as "1 bundle(s) verified". build.js now records every file it emits and writes a receipt of them -- path, sha256, and whether the file is one of the bundles containing constants.js -- to a path the Makefile creates with mktemp per invocation, outside the repo, and deletes afterwards; a receipt path inside dist/ is refused. dist/ is cleared before a build, so it holds only what that build wrote. dist/constants-bundles.txt is gone, and with it the standalone make verify-build target: re-verifying a dist/ out of the dist/ itself is the thing that was broken. verify-build now checks the receipt's shape, then that dist/ contains nothing the build did not emit and no symlinks, then each recorded file's bytes against its digest and each audited bundle's marker against --expect. The guarantee is narrow and README.md states it as such: dist/ is byte for byte the output of the build.js run that just finished. It proves nothing about the honesty of the source tree or of build.js, and offers nothing to a third party holding a dist/. That is signing: https://git.eeqj.de/sneak/AutistMask/issues/310 script/test-verify-build goes from 18 cases to 39, extended in place: one per demonstrated bypass, the missing/invalid argument cases, an AUTISTMASK_DEBUG=1 environment that the verifier must ignore, debug bundles that must fail --expect release, and four checks that read the make build and make build-debug recipes back out of make -n. The existing failure modes (grep exit-2, find's status, newline and trailing-space paths, symlinked dist/, and the root probe that refuses to count permission cases vacuously) are kept. Verified: make check green (39 suites / 811 tests, 39 verify-build cases, permission cases enabled), and green again inside the pinned image via script/cibuild with --no-cache-filter=check, where the harness runs as root and reports the setpriv runner rather than skipping. Non-vacuity proved by mutation: disabling the digest comparison fails exactly the four bypass cases, removing the dist/ walk fails the eight extra-file and symlink cases, restoring the ambient AUTISTMASK_DEBUG fallback fails the no---expect case, breaking the Makefile recipe fails the wiring cases, and dropping manifest.json from the recorded emissions fails a real make build. --- Makefile | 36 ++- README.md | 82 ++++-- TODO.md | 32 ++- build.js | 175 ++++++++++-- script/test-verify-build | 502 ++++++++++++++++++++++++++++------ script/verify-build | 572 ++++++++++++++++++++++++++++++--------- 6 files changed, 1118 insertions(+), 281 deletions(-) diff --git a/Makefile b/Makefile index 76490ed..5f272ea 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug verify-build vendor-blocklist clean dev +.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug vendor-blocklist clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -47,10 +47,27 @@ docker: hooks: @script/install-precommit +# build.js writes a receipt of everything it emitted — every path, its sha256, +# and whether it is a bundle containing constants.js — and script/verify-build +# checks dist/ against that. The receipt is made here, fresh per invocation, +# outside the repo, and deleted again: a standing file inside dist/ would be +# rewritten by whoever rewrote dist/, which is what made the old check +# satisfiable by a hand-written tree. +# +# The expected mode is an explicit argument and AUTISTMASK_DEBUG is scrubbed +# from the verifier's environment. The script no longer reads it at all; env -u +# is here so that stays true of anything it calls. It is deliberately NOT +# scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target +# compiles a debug bundle and then fails on it, loudly, rather than quietly +# handing back something other than the release build that was asked for. build: @echo "Building extension..." - @yarn run build 2>&1 - @script/verify-build + @set -eu; \ + receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ + trap 'rm -f "$$receipt"' EXIT INT TERM; \ + AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \ + env -u AUTISTMASK_DEBUG script/verify-build --expect release \ + --receipt "$$receipt" @script/check-censored --require-dist # Development-only build: enables the red DEBUG / INSECURE banner and makes @@ -58,15 +75,14 @@ build: # distribute the artifacts this produces. build-debug: @echo "Building extension (DEBUG)..." - @AUTISTMASK_DEBUG=1 yarn run build 2>&1 - @AUTISTMASK_DEBUG=1 script/verify-build + @set -eu; \ + receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ + trap 'rm -f "$$receipt"' EXIT INT TERM; \ + AUTISTMASK_DEBUG=1 AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \ + env -u AUTISTMASK_DEBUG script/verify-build --expect debug \ + --receipt "$$receipt" @script/check-censored --require-dist -# Assert the compiled DEBUG state of the bundles already in dist/. Runs at -# the end of build and build-debug; separate target for re-running it alone. -verify-build: - @script/verify-build - # Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream. # Run deliberately, land the diff: the extension does no runtime fetching, so # the shipped list is as fresh as the last vendoring run that was released. diff --git a/README.md b/README.md index 5d41a96..f6aaa4d 100644 --- a/README.md +++ b/README.md @@ -48,28 +48,57 @@ Load the extension: ### Debug Builds -`make build` always produces a release build: the build-time `DEBUG` constant is -`false`, so wallet creation uses real entropy and the red banner is off. To -produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment: +`make build` never hands back a debug build. `make build-debug` is the only +target that produces one: ```bash -make build-debug # or: AUTISTMASK_DEBUG=1 make build +make build-debug ``` -Only the exact value `1` enables it; any other value (including unset, empty, or -`true`) yields a release build, so a typo cannot accidentally ship the debug -behavior. The build prints which mode it used. See the +`AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value +`1` does; any other value (including unset, empty, or `true`) yields a release +build, so a typo cannot accidentally ship the debug behavior. But it is the +compiler's input, not the verifier's: if it happens to be exported in the shell +that runs `make build`, that target compiles a debug bundle and then **fails**, +because it tells `script/verify-build` in so many words that it was supposed to +produce a release build. It used to be that the verifier read the same variable +out of its own environment, agreed with itself, and reported a debug artifact as +verified. The build prints which mode it used. See the [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never distribute a debug build** — every wallet it creates gets the same publicly known test recovery phrase. -Both builds end by running `script/verify-build`, which reads the compiled +Both targets end by running `script/verify-build`, which reads the compiled `DEBUG` state back out of the emitted bundles and fails the build if it is not the one that was asked for. The test suite cannot check this: it loads `src/shared/constants.js` outside a bundle, so it only ever sees the fallback value. The assertion is on the artifacts because that is where the property lives. +### Build Receipts + +`build.js` records every file it emits — path, sha256, and whether the file is +one of the bundles containing `src/shared/constants.js` — into a build receipt, +and `script/verify-build` checks `dist/` against that receipt: every recorded +file present with exactly the recorded bytes, every audited bundle carrying the +requested `DEBUG` marker, and nothing under `dist/` that the build did not +write. The `Makefile` creates the receipt path with `mktemp` per invocation, +outside the repo, and deletes it afterwards. + +That is what ties the check to a build rather than to a directory. What it +establishes is narrow and worth stating exactly: `dist/` is byte for byte the +output of the `build.js` run that just finished, with nothing added, removed or +altered in between. It establishes nothing about whether the source tree or +`build.js` were honest, and it offers nothing to someone handed a `dist/` from +elsewhere — without the receipt from its own build there is no input to the +check. Verifiable provenance for a third party is signing, which this is not. + +There is deliberately no target that re-verifies an existing `dist/` on its own. +The list of files to check has to come from the build that produced them; read +back out of `dist/`, it is the artifact vouching for itself, which is how a +26-byte file containing only the marker string, a hostile content script, and an +entire hand-written `dist/` all used to verify green. + ## Entrypoints This repository adheres to the @@ -114,20 +143,26 @@ provide: serves. Run deliberately, never as part of a build: the output is committed and there is no runtime fetch, so the shipped list is as fresh as the last vendoring run that was released -- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in - `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` - off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of - `make build` and `make build-debug`; fails loudly rather than passing if it - cannot determine a bundle's state. Not part of `make check`, which does not - depend on build artifacts existing. +- `script/verify-build --expect release|debug --receipt PATH` — assert that + `dist/` is exactly what the build that just ran emitted, and that the compiled + `DEBUG` state of the bundles in it is the one that was asked for. Both + arguments are required and neither has a default: the expected mode is stated + by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list + comes from the build's receipt rather than from `dist/` (see + [Build Receipts](#build-receipts)). Run automatically at the end of + `make build` and `make build-debug`; fails loudly rather than passing whenever + it cannot determine something. Not part of `make check`, which does not depend + on build artifacts existing. - `script/test-verify-build` — exercise every failure mode of `script/verify-build` against a fixture tree in a temp dir, asserting the exit - status and the message of each. Part of `make check`; it reads no build - artifacts and writes nothing under `dist/`. The cases that depend on file - permissions cannot mean anything for a process that is not subject to them, so - the harness proves its runner against a mode-000 file before counting them, - dropping to an unprivileged user when run as root; if it cannot, it skips - those cases and says so in a banner rather than passing them. + status and the message of each, and read the `make build` and + `make build-debug` recipes back out of `make -n` to check that they pass the + mode as an argument on a scrubbed environment. Part of `make check`; it reads + no build artifacts and writes nothing under `dist/`. The cases that depend on + file permissions cannot mean anything for a process that is not subject to + them, so the harness proves its runner against a mode-000 file before counting + them, dropping to an unprivileged user when run as root; if it cannot, it + skips those cases and says so in a banner rather than passing them. - `script/docker` — build the Docker image tagged via `script/projectname` - `script/cibuild` — CI entrypoint: plain `docker build .` - `script/precommit` — run by the git pre-commit hook; runs `script/check` @@ -140,9 +175,10 @@ The Makefile shims to those. It also carries a few targets that have no of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being silently rewritten. Use `make setup` for a fresh clone. - `make hooks` — shims to `script/install-precommit` -- `make build` — build the extension into `dist/chrome/` and `dist/firefox/` -- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see - [Debug Builds](#debug-builds)) +- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`, + then verify the result against the build's receipt as a release build +- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a + debug build (see [Debug Builds](#debug-builds)) - `make clean` — remove `dist/` - `make dev` — build in watch mode diff --git a/TODO.md b/TODO.md index f377488..da94589 100644 --- a/TODO.md +++ b/TODO.md @@ -25,8 +25,8 @@ pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The milestone is in flight on `next`; its `next` -> `main` PR is [#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces -`dist/chrome/` and `dist/firefox/` with every bundle verified to have `DEBUG` -compiled off. +`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to +be exactly what that build emitted with `DEBUG` compiled off. The backlog lives on the [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is @@ -44,6 +44,34 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-20: `make build` can no longer hand back a debug build, and + `script/verify-build` can no longer be satisfied by bytes the build did not + produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The + verifier used to compute its expectation from `AUTISTMASK_DEBUG` in its own + environment, so an operator with that exported who ran the release target got + a debug bundle — every wallet it creates carrying the publicly committed test + phrase — certified green at exit 0. The expected mode is now the required + argument `--expect release|debug`, with no default and nothing read from the + environment, and the `Makefile` scrubs the flag from the verifier while + deliberately leaving it reaching the compiler, so that shell fails the build + loudly instead of quietly getting something other than what it asked for. + Provenance was the other half: the check was a marker grep over a file list + read back out of `dist/`, so a 26-byte file containing only + `autistmask-build-debug=off` verified `ok`, `manifest.json` and the content + script that runs on every page were never read at all, and an entire + hand-written `dist/` passed. `build.js` now records every file it emits, with + its sha256 and whether it is one of the bundles containing `constants.js`, + into a receipt whose path the `Makefile` makes fresh per invocation outside + the repo and deletes afterwards; `dist/constants-bundles.txt` is gone, and + `dist/` is cleared before a build so it holds only what that build wrote. The + standalone `make verify-build` target went with it: re-verifying a `dist/` + from the `dist/` itself is the thing that was broken. What this establishes is + narrow and stated as such in README.md — `dist/` is byte for byte the output + of the `build.js` run that just finished — and it is not signing, which is + [#310](https://git.eeqj.de/sneak/AutistMask/issues/310). + `script/test-verify-build` grew from 18 cases to 39, including one per + demonstrated bypass and the `make -n` read-back that proves the recipes pass + the mode as an argument. - 2026-08-20: A hostile ERC-20 symbol no longer renders as live HTML in the popup ([#307](https://git.eeqj.de/sneak/AutistMask/issues/307)). A token symbol is whatever the contract's `symbol()` returns, the block explorer diff --git a/build.js b/build.js index ec38667..2eae29d 100644 --- a/build.js +++ b/build.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const crypto = require("crypto"); const { execSync } = require("child_process"); const esbuild = require("esbuild"); @@ -8,12 +9,29 @@ const DIST_CHROME = path.join(DIST, "chrome"); const DIST_FIREFOX = path.join(DIST, "firefox"); const SRC = path.join(__dirname, "src"); -// The module whose compiled DEBUG state script/verify-build asserts, and the -// manifest naming every emitted bundle that ends up containing it. The -// manifest is derived from esbuild's own dependency graph rather than from a -// hardcoded list, so it tracks the bundle layout instead of rotting with it. +// The module whose compiled DEBUG state script/verify-build asserts. Which +// bundles contain it is derived from esbuild's own dependency graph rather +// than from a hardcoded list, so it tracks the bundle layout instead of +// rotting with it. const AUDITED_MODULE = "src/shared/constants.js"; -const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt"); + +// The build receipt: every file this build emits, with its sha256 and whether +// it is one of the audited bundles. script/verify-build is handed this and +// checks dist/ against it, so the file list comes from the build that just ran +// rather than being read back out of the tree it is supposed to vouch for. +// +// The path is supplied by the caller, not chosen here, and the Makefile makes +// a fresh one per invocation outside the repo: that is what ties a receipt to +// one build rather than leaving a standing file anyone can write. +const RECEIPT_HEADER = "autistmask-build-receipt v1"; +const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT"; + +// Every emitted path must be plainly nameable, because the receipt is a +// line-oriented text file consumed by a POSIX shell script and a path with a +// space or a newline in it could not be read back unambiguously. Nothing this +// build emits looks like that; if that ever changes, the build fails here +// rather than writing a receipt that cannot be checked. +const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/; function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); @@ -31,10 +49,10 @@ function repoRelative(p) { // searching the minified text, it does not depend on what survived minification. // // The ".js" filter below is the only place that assumption lives: -// script/verify-build searches every file and symlink under dist/ for a -// marker, without filtering by extension, and hard-fails if it cannot walk the -// whole tree, so a bundle emitted under some other extension fails there as -// unlisted rather than escaping both checks at once. +// script/verify-build reads every file the receipt names, whatever its +// extension, and fails on any that carries a debug marker without being +// recorded as an audited bundle — so a bundle emitted under some other +// extension fails there rather than escaping both checks at once. function outputsContainingAuditedModule(metafile) { return Object.entries(metafile.outputs) .filter(([outFile, info]) => { @@ -46,6 +64,94 @@ function outputsContainingAuditedModule(metafile) { .map(([outFile]) => repoRelative(outFile)); } +// Every file this build writes under dist/, recorded as it is written. This is +// the build's own account of what it emitted; it is never recovered by +// listing dist/, because a file that is in dist/ without this build having put +// it there is exactly what the receipt exists to expose. +const emittedFiles = []; + +function recordEmitted(absPath) { + emittedFiles.push(absPath); +} + +// Copying is the only other way a file reaches dist/; esbuild and the Tailwind +// CLI record their outputs where they are invoked. +function copyEmitted(src, dest) { + fs.copyFileSync(src, dest); + recordEmitted(dest); +} + +function sha256File(absPath) { + return crypto + .createHash("sha256") + .update(fs.readFileSync(absPath)) + .digest("hex"); +} + +// Write the receipt for the files this build emitted. Deliberately records no +// build mode: which mode was asked for is script/verify-build's argument, so +// build.js cannot vouch for build.js. All the receipt says is "these bytes, +// under these names, are what I wrote, and these ones bundle constants.js". +function writeReceipt(receiptPath, auditedBundles) { + const audited = new Set(auditedBundles); + const paths = [...new Set(emittedFiles.map(repoRelative))].sort(); + + for (const p of paths) { + if (!SAFE_EMITTED_PATH.test(p)) { + throw new Error( + `emitted path cannot be written to a build receipt: ${JSON.stringify(p)}`, + ); + } + } + + // A bundle esbuild reported but that nothing recorded as emitted means the + // two halves have drifted apart, and the receipt would then leave an + // audited bundle out. Fail rather than emit a short receipt. + for (const bundle of audited) { + if (!paths.includes(bundle)) { + throw new Error( + `${bundle} contains ${AUDITED_MODULE} but was not recorded as emitted`, + ); + } + } + if (audited.size === 0) { + throw new Error( + `no emitted bundle contains ${AUDITED_MODULE}, which is never correct`, + ); + } + + const lines = [RECEIPT_HEADER, `root ${fs.realpathSync(__dirname)}`]; + for (const p of paths) { + const flag = audited.has(p) ? "A" : "P"; + lines.push(`file ${sha256File(path.join(__dirname, p))} ${flag} ${p}`); + } + fs.writeFileSync(receiptPath, lines.map((l) => `${l}\n`).join("")); + + console.log( + `Build receipt: ${paths.length} emitted file(s), ${audited.size} ` + + `containing ${AUDITED_MODULE} (${receiptPath})`, + ); +} + +// Where the receipt goes, decided before anything is emitted so a build that +// cannot produce a checkable receipt fails before it writes any artifacts. +// Inside dist/ is refused: a receipt that lives in the tree it describes can +// be rewritten by whoever rewrites the tree, which is the hole this replaces. +function receiptTarget() { + const requested = process.env[RECEIPT_ENV]; + if (!requested) { + return null; + } + const resolved = path.resolve(requested); + if (resolved === DIST || resolved.startsWith(DIST + path.sep)) { + throw new Error( + `${RECEIPT_ENV} points inside dist/ (${resolved}). The receipt ` + + `describes dist/ and must not live in it.`, + ); + } + return resolved; +} + // DEBUG is a build-time flag, off unless explicitly requested. It is the only // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // exact: anything other than the literal "1" (unset, empty, "true", a typo) @@ -87,6 +193,15 @@ function getBuildInfo() { async function build() { console.log("Building AutistMask extension..."); + const receiptPath = receiptTarget(); + if (!receiptPath) { + console.warn( + `WARNING: ${RECEIPT_ENV} is unset, so this build writes no ` + + `receipt and script/verify-build cannot verify what it ` + + `emitted. Build through make build / make build-debug.`, + ); + } + const buildInfo = getBuildInfo(); console.log("Build info:", buildInfo); @@ -108,19 +223,21 @@ async function build() { }; // Emitted bundles that contain constants.js, accumulated across every - // esbuild run below and written out for script/verify-build. + // esbuild run below and recorded in the receipt for script/verify-build. const auditedBundles = []; // compile tailwind CSS console.log("Compiling Tailwind CSS..."); const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindOutput = path.join(DIST, "styles.css"); + + // Start from an empty dist/, so what is there afterwards is what this + // build put there and nothing else. Leftovers from an earlier build are + // not covered by this build's receipt, and script/verify-build rejects + // any file it did not emit rather than ignoring it. + fs.rmSync(DIST, { recursive: true, force: true }); ensureDir(DIST); - // Drop any manifest from a previous build before emitting anything, so a - // build that never gets around to writing one cannot be verified against - // a stale list. - fs.rmSync(BUNDLE_MANIFEST, { force: true }); // The locally installed binary, not `npx` — npx silently fetches from the // registry when the binary is absent, which is an unpinned network fetch // in the middle of a build. @@ -134,6 +251,7 @@ async function build() { `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, { stdio: "inherit" }, ); + recordEmitted(tailwindOutput); // Every bundle goes through here, so metafile collection cannot be // forgotten when a new entry point is added. @@ -149,6 +267,7 @@ async function build() { metafile: true, define, }); + recordEmitted(outfile); auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); } @@ -182,39 +301,39 @@ async function build() { ); // copy popup HTML - fs.copyFileSync( + copyEmitted( path.join(SRC, "popup", "index.html"), path.join(distDir, "src", "popup", "index.html"), ); // place compiled CSS next to popup HTML - fs.copyFileSync( + copyEmitted( tailwindOutput, path.join(distDir, "src", "popup", "styles.css"), ); } // copy manifests - fs.copyFileSync( + copyEmitted( path.join(__dirname, "manifest", "chrome.json"), path.join(DIST_CHROME, "manifest.json"), ); - fs.copyFileSync( + copyEmitted( path.join(__dirname, "manifest", "firefox.json"), path.join(DIST_FIREFOX, "manifest.json"), ); - // Written last so a build that died partway through leaves no manifest - // at all, which script/verify-build treats as a hard failure rather than - // as "nothing to check". - const manifest = [...new Set(auditedBundles)].sort(); - fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join("")); - console.log( - `Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` + - `(listed in ${repoRelative(BUNDLE_MANIFEST)})`, - ); + // Written last so a build that died partway through leaves no receipt at + // all, which script/verify-build treats as a hard failure rather than as + // "nothing to check". + if (receiptPath) { + writeReceipt(receiptPath, auditedBundles); + } console.log("Build complete: dist/chrome/ and dist/firefox/"); } -build(); +build().catch((err) => { + console.error(`Build failed: ${err && err.message ? err.message : err}`); + process.exit(1); +}); diff --git a/script/test-verify-build b/script/test-verify-build index a708afd..6ffa058 100755 --- a/script/test-verify-build +++ b/script/test-verify-build @@ -3,11 +3,13 @@ # script/verify-build. Our own extension to scripts-to-rule-them-all, run # from script/check so make check covers it. # -# Why this exists: verify-build is the build-integrity guard, and three -# separate reviews of it each found a fresh vacuous pass — the grep exit-2 -# conflation, the discarded find status, the line-delimited walk. Every one -# was caught by someone building a tree by hand, because nothing in make check -# could catch it. This is that hand battery, committed and automated. +# Why this exists: verify-build is the build-integrity guard, and four separate +# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation, +# the discarded find status, the line-delimited walk, and then the two the +# receipt replaced: an expectation read out of the verifier's own environment, +# and a file list read back out of the tree it was supposed to vouch for. Every +# one was caught by someone building a tree by hand, because nothing in make +# check could catch it. This is that hand battery, committed and automated. # # Each case asserts the exit status AND a substring of the message. A guard # that fails for the wrong reason (right status, different fault) is itself a @@ -17,7 +19,14 @@ # the real script: verify-build takes its ROOT from dirname "$0"/.., so it # operates on the fixture's dist/ and never reads or writes the repo's build # output. The symlink rather than a copy is what makes a deliberate break in -# the real script fail here. +# the real script fail here. The fixture's receipt is written from the bytes +# the fixture actually holds, exactly as a build writes one from the bytes it +# emitted; a case that means "the build emitted this" regenerates it, and a +# case that means "something changed dist/ afterwards" does not. +# +# The sha256 command is selected here independently of the one verify-build +# picks. That is deliberate: a harness that reused the implementation's helper +# would agree with it even when it is wrong. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -26,6 +35,8 @@ VERIFY_BUILD="$ROOT/script/verify-build" MARKER_ON="autistmask-build-debug=on" MARKER_OFF="autistmask-build-debug=off" +RECEIPT_HEADER="autistmask-build-receipt v1" + NEWLINE=' ' @@ -41,6 +52,9 @@ UNPRIV="" PERM_ENABLED=no PERM_HOW="" +# The sha256 command, chosen by pick_sha256_tool. +SHA256_CMD="" + WORK="" cleanup() { @@ -54,6 +68,10 @@ trap cleanup EXIT INT TERM WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")" FIXTURE="$WORK/fixture" +# The build receipt for the fixture, kept outside the fixture's dist/ — and +# outside the fixture altogether — because that is where a real one lives. +RECEIPT="$WORK/receipt" + # verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside # our work dir keeps the run leaving no residue, and keeps it writable for the # unprivileged user the permission cases run as. @@ -65,15 +83,67 @@ chmod 755 "$WORK" # --- fixture --------------------------------------------------------------- +# The emitted tree a build of this repo produces in miniature: audited bundles +# (A) that must carry a marker, and plain emitted files (P) that must not — +# including the content script, which runs on every page, and the manifest, +# neither of which the pre-receipt verifier read at all. +FIXTURE_FILES="A dist/chrome/src/popup/index.js +A dist/firefox/src/popup/index.js +P dist/chrome/src/content/index.js +P dist/chrome/manifest.json +P dist/styles.css" + +FIXTURE_REAL="" + # A stand-in for an emitted bundle: some text plus one marker literal, which -# is all verify-build reads out of the real thing. +# is all verify-build reads out of the real thing beyond its digest. write_bundle() { printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1" } -# A dist/ shaped like a real build: two listed bundles under different -# browsers, an unlisted subtree to make unwalkable, and unlisted files that -# carry no marker and must not be objected to. +# Digest of $1, taken with the harness's own sha256 command. +fixture_sha256() { + # Word-split on purpose: SHA256_CMD is a command with its arguments. + # shellcheck disable=SC2086 + _fs_out="$($SHA256_CMD "$1")" + printf '%s' "${_fs_out%% *}" +} + +# Write the fixture's receipt, with a substitutable header and root line so the +# cases can hand verify-build a receipt that is not one. +write_receipt_custom() { + _wrc_header="$1" + _wrc_root="$2" + + chmod u+rw "$RECEIPT" 2>/dev/null || true + rm -f "$RECEIPT" + + ( + cd "$FIXTURE" + printf '%s\n' "$_wrc_header" + printf 'root %s\n' "$_wrc_root" + _saved_ifs="$IFS" + IFS="$NEWLINE" + for _entry in $FIXTURE_FILES; do + IFS="$_saved_ifs" + _flag="${_entry%% *}" + _path="${_entry#* }" + printf 'file %s %s %s\n' "$(fixture_sha256 "$_path")" \ + "$_flag" "$_path" + IFS="$NEWLINE" + done + IFS="$_saved_ifs" + ) >"$RECEIPT" + + # Readable by the unprivileged user the permission cases run as, whatever + # umask this process has, until a case takes that away on purpose. + chmod 644 "$RECEIPT" +} + +write_receipt() { + write_receipt_custom "$RECEIPT_HEADER" "$FIXTURE_REAL" +} + build_fixture() { chmod -R u+rwX "$FIXTURE" 2>/dev/null || true rm -rf "$FIXTURE" @@ -87,13 +157,12 @@ build_fixture() { write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF" write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF" + printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/index.js" + printf '{"manifest_version":3}\n' >"$FIXTURE/dist/chrome/manifest.json" printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css" - printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js" - { - echo "dist/chrome/src/popup/index.js" - echo "dist/firefox/src/popup/index.js" - } >"$FIXTURE/dist/constants-bundles.txt" + FIXTURE_REAL="$(cd "$FIXTURE" && pwd -P)" + write_receipt # Readable and traversable by the unprivileged user the permission cases # run as, before those cases take that away again on purpose. @@ -185,7 +254,51 @@ runuser|runuser -u nobody --" # --- case runner ------------------------------------------------------------ -# check_case +# How verify-build is invoked for a case. The arguments are literal here rather +# than assembled from a string, so nothing about a case's invocation depends on +# word splitting. "envdebug" variants export AUTISTMASK_DEBUG=1 to prove the +# verifier ignores it — that is the whole of the ambient-environment defect. +run_verify() { + _rv_variant="$1" + _rv_perm="$2" + _rv_bin="$FIXTURE/script/verify-build" + + case "$_rv_variant" in + release | release-envdebug) + set -- --expect release --receipt "$RECEIPT" + ;; + debug) + set -- --expect debug --receipt "$RECEIPT" + ;; + no-expect) + set -- --receipt "$RECEIPT" + ;; + no-receipt) + set -- --expect release + ;; + bad-expect) + set -- --expect maybe --receipt "$RECEIPT" + ;; + unknown-arg) + set -- --expect release --receipt "$RECEIPT" --force + ;; + receipt-in-dist) + set -- --expect release --receipt "$FIXTURE/dist/receipt.txt" + ;; + *) + echo "test-verify-build: unknown variant $_rv_variant" >&2 + exit 1 + ;; + esac + + if [ "$_rv_perm" = yes ]; then + run_unpriv "$_rv_bin" "$@" + else + "$_rv_bin" "$@" + fi +} + +# check_case # # Rebuilds the fixture, applies inside it, runs verify-build, and # requires both the exit status and the message. marks a case that only @@ -193,7 +306,7 @@ runuser|runuser -u nobody --" check_case() { _name="$1" _perm="$2" - _mode="$3" + _variant="$3" _want_status="$4" _want_text="$5" _setup="$6" @@ -213,23 +326,22 @@ check_case() { return 0 fi - if [ "$_mode" = debug ]; then - _debug=1 - else - _debug="" - fi - - # Exported rather than set as a command prefix: run_unpriv is a function, - # and an assignment prefixed to a function call is not portable. - AUTISTMASK_DEBUG="$_debug" - export AUTISTMASK_DEBUG + # Exported rather than set as a command prefix: run_verify may go through + # run_unpriv, which is a function, and an assignment prefixed to a function + # call is not portable. Every other case unsets it, so the environment this + # harness happens to run in cannot decide anything. + case "$_variant" in + *envdebug) + AUTISTMASK_DEBUG=1 + export AUTISTMASK_DEBUG + ;; + *) + unset AUTISTMASK_DEBUG || true + ;; + esac _status=0 - if [ "$_perm" = yes ]; then - _out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$? - else - _out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$? - fi + _out="$(run_verify "$_variant" "$_perm" 2>&1)" || _status=$? _ok=yes _why="" @@ -272,7 +384,9 @@ check_case() { # --- cases ------------------------------------------------------------------ # -# Each runs with the fixture as its working directory. +# Each runs with the fixture as its working directory. A case that regenerates +# the receipt is saying "this is what the build emitted"; one that does not is +# saying "the build emitted something else and this happened afterwards". c_control() { :; } @@ -299,38 +413,233 @@ c_dir_symlink() { ln -s src dist/chrome/link-to-dir; } c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; } -c_manifest_missing() { rm dist/constants-bundles.txt; } +c_receipt_missing() { rm "$RECEIPT"; } -c_manifest_empty() { : >dist/constants-bundles.txt; } +c_receipt_empty() { : >"$RECEIPT"; } -c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; } +c_receipt_unreadable() { chmod 000 "$RECEIPT"; } -c_bundle_missing() { rm dist/chrome/src/popup/index.js; } +c_receipt_bad_header() { + write_receipt_custom "some other file entirely" "$FIXTURE_REAL" +} -c_bundle_empty() { : >dist/chrome/src/popup/index.js; } +c_receipt_other_tree() { + write_receipt_custom "$RECEIPT_HEADER" "/some/other/checkout" +} -c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; } +c_receipt_path_with_space() { + write_receipt + printf 'file %s P dist/two words.js\n' \ + "0000000000000000000000000000000000000000000000000000000000000000" \ + >>"$RECEIPT" +} -c_unlisted_extension() { +c_receipt_path_outside_dist() { + write_receipt + printf 'file %s P etc/passwd\n' \ + "0000000000000000000000000000000000000000000000000000000000000000" \ + >>"$RECEIPT" +} + +c_receipt_in_dist() { cp "$RECEIPT" dist/receipt.txt; } + +c_emitted_missing() { rm dist/chrome/src/popup/index.js; } + +c_emitted_empty() { : >dist/chrome/src/popup/index.js; } + +c_emitted_unreadable() { chmod 000 dist/chrome/src/popup/index.js; } + +c_extra_file_with_marker() { cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs } -c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; } +c_extra_file_no_marker() { + printf 'var e=5;\n' >dist/chrome/src/popup/vendor.js +} + +# The four demonstrated bypasses of the pre-receipt verifier. + +# A 26-byte file whose entire content is the marker string used to verify ok. +c_marker_only_stub() { + printf '%s' "$MARKER_OFF" >dist/chrome/src/popup/index.js +} + +# The content script runs on every page the browser loads and was never read. +c_tampered_content_script() { + printf 'fetch("https://example.invalid/"+document.cookie);\n' \ + >>dist/chrome/src/content/index.js +} + +# The manifest decides permissions and CSP and was never read either. +c_tampered_manifest() { + printf '{"manifest_version":3,"host_permissions":[""]}\n' \ + >dist/chrome/manifest.json +} + +# A dist/ that has nothing to do with this build, carrying the right file +# names and the right marker, offered against this build's receipt. +c_foreign_dist() { + rm -rf dist + mkdir -p dist/chrome/src/popup dist/chrome/src/content dist/firefox/src/popup + write_bundle dist/chrome/src/popup/index.js "$MARKER_OFF" + write_bundle dist/firefox/src/popup/index.js "$MARKER_OFF" + printf 'var hostile=1;\n' >dist/chrome/src/content/index.js + printf '{"manifest_version":3}\n' >dist/chrome/manifest.json + printf 'body{color:#fff}\n' >dist/styles.css +} + +# Cases that state what the build itself emitted, and so regenerate the +# receipt over the changed bytes. + +c_no_marker() { + printf 'var d=4;\n' >dist/chrome/src/popup/index.js + write_receipt +} c_both_markers() { printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js + write_receipt +} + +c_marker_on_plain_file() { + printf 'var c=3;/* %s */\n' "$MARKER_OFF" \ + >dist/chrome/src/content/index.js + write_receipt +} + +c_debug_build() { + write_bundle dist/chrome/src/popup/index.js "$MARKER_ON" + write_bundle dist/firefox/src/popup/index.js "$MARKER_ON" + write_receipt +} + +# --- Makefile wiring -------------------------------------------------------- + +# The verifier cases above prove what verify-build does when it is told what to +# expect. This proves the Makefile tells it — with the mode as an argument, on +# a scrubbed environment, and identically whether or not AUTISTMASK_DEBUG is +# exported in the shell that ran make. Read off `make -n`, so no build runs. +check_makefile_wiring() { + if ! command -v make >/dev/null 2>&1; then + SKIPPED=$((SKIPPED + 1)) + SKIPPED_NAMES="$SKIPPED_NAMES## - Makefile wiring (make not found)$NEWLINE" + echo " SKIP (make not found): Makefile wiring" + return 0 + fi + + # make build must ask for release, and must scrub the flag from the + # verifier's environment, even when the caller has it exported. + _wiring_case "make build passes --expect release" \ + build "verify-build --expect release" + _wiring_case "make build scrubs AUTISTMASK_DEBUG for the verifier" \ + build "env -u AUTISTMASK_DEBUG" + _wiring_case "make build-debug passes --expect debug" \ + build-debug "verify-build --expect debug" + _wiring_case "make build-debug scrubs AUTISTMASK_DEBUG for the verifier" \ + build-debug "env -u AUTISTMASK_DEBUG" +} + +_wiring_case() { + _wc_name="$1" + _wc_target="$2" + _wc_want="$3" + + AUTISTMASK_DEBUG=1 + export AUTISTMASK_DEBUG + _wc_status=0 + _wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$? + unset AUTISTMASK_DEBUG + + if [ "$_wc_status" -ne 0 ]; then + FAILED=$((FAILED + 1)) + echo " FAIL: $_wc_name" + echo " make -n $_wc_target exited $_wc_status" + return 0 + fi + + _wc_g=0 + printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$? + case "$_wc_g" in + 0) + PASSED=$((PASSED + 1)) + echo " ok: $_wc_name" + ;; + 1) + FAILED=$((FAILED + 1)) + echo " FAIL: $_wc_name" + echo " make -n $_wc_target does not run: $_wc_want" + ;; + *) + FAILED=$((FAILED + 1)) + echo " FAIL: $_wc_name" + echo " grep exited $_wc_g, so the recipe was never checked" + ;; + esac } run_cases() { check_case "control: untouched dist passes" \ - no release 0 "2 bundle(s) verified $MARKER_OFF" c_control + no release 0 "2 bundle(s) $MARKER_OFF" c_control - check_case "unlisted marker-carrying file, trailing space in name" \ - no release 1 "carries a debug marker but is absent from" \ + check_case "AUTISTMASK_DEBUG=1 in the environment does not decide the mode" \ + no release-envdebug 0 "2 bundle(s) $MARKER_OFF" c_control + + check_case "debug bundles under --expect release fail (make build with + AUTISTMASK_DEBUG=1 exported)" \ + no release-envdebug 1 \ + "is $MARKER_ON but this build was told to expect" c_debug_build + + check_case "debug bundles under --expect debug pass" \ + no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build + + check_case "no --expect argument" \ + no no-expect 1 "no --expect argument." c_control + + check_case "no --receipt argument" \ + no no-receipt 1 "no --receipt argument." c_control + + check_case "--expect takes release or debug" \ + no bad-expect 1 "--expect takes release or debug" c_control + + check_case "unknown argument" \ + no unknown-arg 1 "unknown argument: --force" c_control + + check_case "receipt inside the tree it describes" \ + no receipt-in-dist 1 "the receipt is inside dist/" c_receipt_in_dist + + check_case "bundle replaced by a file containing only the marker" \ + no release 1 "does not contain the bytes this build emitted" \ + c_marker_only_stub + + check_case "content script tampered with after the build" \ + no release 1 \ + "dist/chrome/src/content/index.js does not contain the bytes" \ + c_tampered_content_script + + check_case "manifest.json tampered with after the build" \ + no release 1 "dist/chrome/manifest.json does not contain the bytes" \ + c_tampered_manifest + + check_case "hand-written dist/ offered against this build's receipt" \ + no release 1 "does not contain the bytes this build emitted" \ + c_foreign_dist + + check_case "extra file under dist/ carrying a marker" \ + no release 1 \ + "dist/chrome/src/popup/extra.mjs is under dist/ but the build" \ + c_extra_file_with_marker + + check_case "extra file under dist/ carrying no marker" \ + no release 1 \ + "dist/chrome/src/popup/vendor.js is under dist/ but the build" \ + c_extra_file_no_marker + + check_case "extra file, trailing space in name" \ + no release 1 "is under dist/ but the build that just ran did not emit" \ c_trailing_space - check_case "unlisted marker-carrying file, newline in name" \ - no release 1 "carries a debug marker but is absent from" \ + check_case "extra file, newline in name" \ + no release 1 "is under dist/ but the build that just ran did not emit" \ c_embedded_newline check_case "dist/ replaced by a symlink" \ @@ -342,64 +651,88 @@ run_cases() { check_case "dangling symlink under dist/" \ no release 1 \ - "reading dist/chrome/dangling.js, so the file could not be" \ - c_dangling_symlink + "dist/chrome/dangling.js is a symlink under dist/" c_dangling_symlink check_case "symlink to a directory under dist/" \ no release 1 \ - "reading dist/chrome/link-to-dir, so the file could not be" \ - c_dir_symlink + "dist/chrome/link-to-dir is a symlink under dist/" c_dir_symlink - check_case "symlink to a listed bundle under an unlisted path" \ + check_case "symlink aliasing an emitted bundle under another path" \ no release 1 \ - "dist/chrome/src/aliased.js carries a debug marker but is absent" \ - c_alias_symlink + "dist/chrome/src/aliased.js is a symlink under dist/" c_alias_symlink - check_case "manifest missing" \ - no release 1 "dist/constants-bundles.txt is missing." \ - c_manifest_missing + check_case "receipt missing" \ + no release 1 "is missing. build.js writes it" c_receipt_missing - check_case "manifest empty" \ - no release 1 "is empty, so no emitted bundle was found to contain" \ - c_manifest_empty + check_case "receipt empty" \ + no release 1 "is empty, so the build wrote nothing to it" \ + c_receipt_empty - check_case "manifest unreadable" \ + check_case "receipt unreadable" \ yes release 1 "is not readable, so nothing was inspected." \ - c_manifest_unreadable + c_receipt_unreadable - check_case "listed bundle missing" \ + check_case "receipt is not a build receipt" \ + no release 1 "does not start with" c_receipt_bad_header + + check_case "receipt from a different checkout" \ + no release 1 "was written by a build of a different tree" \ + c_receipt_other_tree + + check_case "receipt names a path containing a space" \ + no release 1 "cannot be read back unambiguously" \ + c_receipt_path_with_space + + check_case "receipt names a path outside dist/" \ + no release 1 "names a path that is not under dist/" \ + c_receipt_path_outside_dist + + check_case "emitted file missing" \ no release 1 \ - "lists dist/chrome/src/popup/index.js, which does not exist." \ - c_bundle_missing + "names dist/chrome/src/popup/index.js, which does not exist." \ + c_emitted_missing - check_case "listed bundle empty" \ - no release 1 "which is empty. An empty bundle" c_bundle_empty + check_case "emitted file empty" \ + no release 1 "which is empty. An empty file" c_emitted_empty - check_case "listed bundle unreadable" \ + check_case "emitted file unreadable" \ yes release 1 \ - "reading dist/chrome/src/popup/index.js, so the file could not be" \ - c_bundle_unreadable + "on dist/chrome/src/popup/index.js, so its bytes were never read" \ + c_emitted_unreadable - check_case "unlisted extension carrying a marker" \ - no release 1 \ - "dist/chrome/src/popup/extra.mjs carries a debug marker but is" \ - c_unlisted_extension - - check_case "listed bundle carries no marker" \ + check_case "emitted bundle carries no marker" \ no release 1 "carries no debug marker, so its DEBUG state cannot be" \ c_no_marker - check_case "listed bundle carries both markers" \ + check_case "emitted bundle carries both markers" \ no release 1 "carries both debug markers, so DEBUG was not resolved" \ c_both_markers - check_case "wrong marker for the requested mode" \ - no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \ - c_control + check_case "marker on a file the build did not record as a bundle" \ + no release 1 "carries a debug marker but the build did not" \ + c_marker_on_plain_file + + check_makefile_wiring } # --- main -------------------------------------------------------------------- +# The harness cannot build a receipt without a digest, so a missing sha256 +# command is a failure here rather than a silent reduction in coverage. +pick_sha256_tool() { + if command -v sha256sum >/dev/null 2>&1; then + SHA256_CMD="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + SHA256_CMD="shasum -a 256" + elif command -v openssl >/dev/null 2>&1; then + SHA256_CMD="openssl dgst -sha256 -r" + else + echo "test-verify-build: no sha256 command found (tried sha256sum," \ + "shasum, openssl), so no fixture receipt can be written" >&2 + exit 1 + fi +} + main() { cd "$ROOT" @@ -409,6 +742,7 @@ main() { } echo "Testing script/verify-build failure modes..." + pick_sha256_tool probe_permission_runner if [ "$PERM_ENABLED" = yes ]; then echo " permission cases: enabled (runner: $PERM_HOW, proved against" \ @@ -426,11 +760,11 @@ main() { if [ "$SKIPPED" -ne 0 ]; then cat <&2 +} + cleanup() { [ -z "$LISTING" ] || rm -f "$LISTING" } trap cleanup EXIT +# --- reading files ---------------------------------------------------------- + # Is the literal $1 present in the file $2? Match (grep exit 0) and no-match # (exit 1) are answers about the emitted output. Anything else (exit 2: the # file could not be read) is not an answer at all, and must not be reported as @@ -74,34 +126,46 @@ has_marker() { esac } -# Does the manifest list the path $1, as a whole line? Same discipline as -# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the -# manifest could not be read and is not an answer at all. Without this, an -# unreadable manifest reads as "this file is not listed" and every emitted -# bundle gets reported as an unlisted one. -# -# A path containing a newline is answered without asking grep, because grep -# would read the pattern as two patterns and report a match on either. That is -# how such a path escaped this check even once the walk stopped splitting it: -# the half before the newline matched a listed line and the file was skipped. -# The manifest is line-delimited, so it cannot name such a path at all, and -# "not listed" is the only true answer. -is_listed() { - case "$1" in - *"$NEWLINE"*) return 1 ;; - esac - _il_status=0 - grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$? - case "$_il_status" in - 0) return 0 ;; - 1) return 1 ;; - *) - fail "grep exited $_il_status reading $MANIFEST, so it could not be - searched and nothing was established about which bundles it lists. That is - a permissions or I/O fault on the manifest, not a stale manifest. Refusing - to report success." +# Pick the sha256 command once. All three print the digest as the first +# whitespace-delimited field. If none is present the digests cannot be taken at +# all, and this script has nothing left to check with, so it fails rather than +# degrading to the marker grep it used to be. +pick_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + SHA256="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + SHA256="shasum -a 256" + elif command -v openssl >/dev/null 2>&1; then + SHA256="openssl dgst -sha256 -r" + else + fail "no sha256 command found (tried sha256sum, shasum, openssl), so + the emitted files cannot be checked against the build receipt at all. + Refusing to report success." + fi +} + +# Digest of $1 into SHA. A digest that could not be taken is not a mismatch and +# not a pass: it means the artifact was never read. +read_sha256() { + _rs_status=0 + # Word-split on purpose: SHA256 is a command with its arguments. + # shellcheck disable=SC2086 + _rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$? + [ "$_rs_status" -eq 0 ] || + fail "$SHA256 exited $_rs_status on $1, so its bytes were never read + and nothing was established about them. That is a permissions or I/O fault + on the artifact, not a mismatch. Refusing to report success." + + SHA="${_rs_out%% *}" + case "$SHA" in + "" | *[!0-9a-f]*) + fail "$SHA256 produced no usable digest for $1, so its bytes were never + checked. Refusing to report success." ;; esac + [ "${#SHA}" -eq 64 ] || + fail "$SHA256 produced a ${#SHA}-character digest for $1, which is not + a sha256. Refusing to report success." } # Read one bundle's DEBUG state into MARKER. Exactly one marker must be @@ -140,40 +204,189 @@ read_marker() { fi } -# The manifest says which bundles must carry a marker. This says no other -# emitted file may carry one, which catches a manifest that has gone stale -# or short rather than trusting whatever it happens to list. +# --- the receipt ------------------------------------------------------------ + +# Split one "file " line into ENTRY_HASH, ENTRY_FLAG and +# ENTRY_PATH, and require the shape rather than assuming it. The path is the +# remainder of the line, so a path carrying a space or a tab would be read back +# as something other than what was written; build.js refuses to emit such a +# name, and a receipt that contains one is malformed rather than describing a +# file. Every rejection here is a failure: a line that cannot be understood is +# a file that would otherwise go unchecked. +parse_file_line() { + case "$1" in + "file "*) ;; + *) + fail "$RECEIPT line $LINENO_R is not a file entry and this script does + not know what it means: ${1}. Refusing to report success." + ;; + esac + + _pl="${1#file }" + ENTRY_HASH="${_pl%% *}" + _pl="${_pl#* }" + ENTRY_FLAG="${_pl%% *}" + ENTRY_PATH="${_pl#* }" + + case "$ENTRY_HASH" in + "" | *[!0-9a-f]*) fail "$RECEIPT line $LINENO_R has no sha256: $1" ;; + esac + [ "${#ENTRY_HASH}" -eq 64 ] || + fail "$RECEIPT line $LINENO_R has a ${#ENTRY_HASH}-character digest, + which is not a sha256: $1" + + case "$ENTRY_FLAG" in + A | P) ;; + *) fail "$RECEIPT line $LINENO_R has no A/P audit flag: $1" ;; + esac + + case "$ENTRY_PATH" in + dist/*) ;; + *) + fail "$RECEIPT line $LINENO_R names a path that is not under dist/: + $ENTRY_PATH. The receipt describes the emitted tree and nothing else." + ;; + esac + case "$ENTRY_PATH" in + *" "* | *"$TAB"* | *"$NEWLINE"*) + fail "$RECEIPT line $LINENO_R names a path containing whitespace, which + cannot be read back unambiguously from a line-oriented receipt: $1" + ;; + esac +} + +# Check one emitted file against its receipt entry: it must be a regular file +# with exactly the recorded bytes, and its debug marker must match what the +# caller said this build was. +check_entry() { + [ ! -h "$ENTRY_PATH" ] || + fail "the receipt names $ENTRY_PATH but that path is a symlink. The + build emits regular files only, so this is not the file it wrote. Refusing + to report success." + [ -f "$ENTRY_PATH" ] || + fail "the receipt names $ENTRY_PATH, which does not exist. dist/ does + not hold what the build emitted." + [ -s "$ENTRY_PATH" ] || + fail "the receipt names $ENTRY_PATH, which is empty. An empty file + carries no marker and matches no digest, so this is a failure and not a + pass." + + read_sha256 "$ENTRY_PATH" + [ "$SHA" = "$ENTRY_HASH" ] || + fail "$ENTRY_PATH does not contain the bytes this build emitted: the + receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote + to dist/ after the build, so this artifact is not the one that was built." + + if [ "$ENTRY_FLAG" = A ]; then + read_marker "$ENTRY_PATH" + [ "$MARKER" = "$EXPECT" ] || + fail "$ENTRY_PATH is $MARKER but this build was told to expect + $EXPECT. If AUTISTMASK_DEBUG=1 is exported in the shell that ran make + build, that is why: the flag still reaches the compiler, and this is the + check that stops the debug artifact being taken for a release one." + echo " ok: $ENTRY_PATH ($MARKER)" + AUDITED=$((AUDITED + 1)) + else + if has_marker "$MARKER_ON" "$ENTRY_PATH" || + has_marker "$MARKER_OFF" "$ENTRY_PATH"; then + fail "$ENTRY_PATH carries a debug marker but the build did not + record it as containing src/shared/constants.js. build.js selects audited + bundles with an endsWith(\".js\") test; a marker-carrying file outside that + set means the test no longer describes what is emitted, and the DEBUG state + of this file was never asserted against anything." + fi + fi + COUNT=$((COUNT + 1)) +} + +# Walk the receipt line by line, applying $1 to each file entry. The header and +# the root line are checked on the way past; the root line is what stops a +# receipt written by a build of some other tree being pointed at this one. +walk_receipt() { + _wr_each="$1" + LINENO_R=0 + _line="" + while IFS= read -r _line || [ -n "$_line" ]; do + LINENO_R=$((LINENO_R + 1)) + if [ "$LINENO_R" -eq 1 ]; then + [ "$_line" = "$RECEIPT_HEADER" ] || + fail "$RECEIPT does not start with \"$RECEIPT_HEADER\", so it + is not a build receipt this script understands. Refusing to report + success." + continue + fi + if [ "$LINENO_R" -eq 2 ]; then + [ "$_line" = "root $ROOT" ] || + fail "$RECEIPT was written by a build of a different tree: it + says \"$_line\" and this is $ROOT. A receipt only describes the dist/ of + the tree it was built in." + continue + fi + parse_file_line "$_line" + "$_wr_each" + done <"$RECEIPT" + + [ "$LINENO_R" -ge 2 ] || + fail "$RECEIPT is truncated: it has no root line, so it is not a + receipt this script can check anything against." +} + +# Pass one: the receipt has to be a receipt before anything is concluded from +# it. A line this script cannot read is a file that would go unchecked, and a +# receipt naming no audited bundle asserts no DEBUG state at all — both are +# failures, and both have to be established before the tree is walked against +# it, because a receipt entry that was misread would otherwise surface as a +# complaint about dist/. +count_entry() { + SHAPE_COUNT=$((SHAPE_COUNT + 1)) + if [ "$ENTRY_FLAG" = A ]; then + SHAPE_AUDITED=$((SHAPE_AUDITED + 1)) + fi +} + +check_receipt_shape() { + SHAPE_COUNT=0 + SHAPE_AUDITED=0 + walk_receipt count_entry + + [ "$SHAPE_COUNT" -gt 0 ] || + fail "$RECEIPT names no emitted files, so nothing was inspected. A + build always emits some." + [ "$SHAPE_AUDITED" -gt 0 ] || + fail "$RECEIPT names no bundle containing src/shared/constants.js, so + no DEBUG state would be asserted at all. That is never correct, so it is a + failure and not a pass." +} + +# Pass three: every file the receipt names, checked against the bytes on disk. +check_receipt_entries() { + walk_receipt check_entry +} + +# --- the emitted tree ------------------------------------------------------- + +# The receipt says which files the build emitted. This says dist/ contains no +# others: an artifact that was added after the build, or that a hand-written +# dist/ brought with it, is not something the build vouches for and is not +# something this check may pass over. # -# Deliberately unfiltered by extension. build.js selects manifest entries with -# an endsWith(".js") test; repeating that literal here would mean a bundle -# emitted under some other extension escaped the manifest AND this check at -# once, which is the correlated blind spot the two-source design exists to -# avoid. Every regular file and every symlink under dist/ is searched — that -# is the whole of what a build emits — so build.js's filter is the only place -# the assumption lives and this check is what catches it being wrong. -# -# That claim only holds if the walk is exhaustive and every name survives it -# intact, so four things are enforced here rather than assumed: +# The walk has to be exhaustive and every name has to survive it intact, so +# four things are enforced rather than assumed: # # - the walk is NUL-delimited and the paths reach the check as arguments, so # no name can be reshaped on the way in. Read line by line, a name with a # trailing space lost it to read's field splitting and the remnant then -# matched a manifest line, and a name containing a newline arrived as a -# listed path plus an empty one. Both left a marker-carrying, unlisted file -# unchecked while the script still reported success. Delivering such a name -# intact is only half of it; is_listed also has to keep it out of grep's -# pattern, for the same reason. +# matched a listed path, and a name containing a newline arrived as a +# listed path plus an empty one. Both left an unchecked file in dist/ while +# the script still reported success. # - find's exit status is checked. A subtree it cannot descend is reported on # stderr and then simply missing from the listing, so an unchecked status # turns "could not look" into "nothing was there" — the same conflation # has_marker exists to prevent. The status cannot be read off a pipeline, # so the listing lands in a file that xargs then reads back. -# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle -# reachable under an unlisted path in dist/ is a stale manifest whether the -# path is a link or a file, and grep reads through the link. A link that -# cannot be read through — dangling, or pointing at a directory — fails -# hard via has_marker's exit-2 path, which is the fail-closed answer: the -# build emits neither, so their DEBUG state is unproven, not fine. +# - symlinks are walked too (-type l), not skipped. The build emits none, so +# a symlink under dist/ is a path the build did not produce, whatever it +# points at, and it fails as one instead of being read through. # - dist/ itself must be a directory and not a symlink, which main asserts # before anything reads through it. find does not follow a symlink named on # its own command line, so a linked dist/ collapses this walk to one entry @@ -181,7 +394,7 @@ read_marker() { # # Types other than regular files and symlinks are left out on purpose: a build # emits none of them, and grep on a fifo would hang rather than fail. -check_unlisted_bundles() { +check_dist_tree() { LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || fail "could not create a temporary file for the dist/ listing, so the tree was never walked. Refusing to report success." @@ -191,105 +404,196 @@ check_unlisted_bundles() { [ "$_find_status" -eq 0 ] || fail "find exited $_find_status enumerating dist/, so part of the tree was never walked and nothing was established about the files in it. Any - unlisted bundle there went unchecked. That is a permissions or I/O fault on - the artifact, not a stale manifest. Refusing to report success." + file the build did not emit could be sitting there unchecked. That is a + permissions or I/O fault on the artifact. Refusing to report success." _scan_status=0 - xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$? + xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$? [ "$_scan_status" -eq 0 ] || - fail "the unlisted-bundle scan exited $_scan_status: either a path - under dist/ failed the check reported above, or the scan could not be run - at all. Refusing to report success." + fail "the dist/ tree scan exited $_scan_status: either a path under + dist/ failed the check reported above, or the scan could not be run at all. + Refusing to report success." } -# The per-path half of check_unlisted_bundles. It runs in a re-invocation of -# this script, so it uses the same is_listed and has_marker as the rest of the -# file rather than a second copy of them that could drift. Paths arrive as -# arguments and are never split, joined or trimmed. +# Does the receipt name the path $1? Compared as whole strings, never through +# grep: a path found under dist/ is attacker-shaped input, and a pattern is not +# the place to put one. The receipt's own paths are known to carry no +# whitespace by the time this runs — verify_receipt failed the run otherwise — +# so stripping the three leading fields recovers each one exactly. +receipt_names() { + _rn_want="$1" + _rn_line="" + while IFS= read -r _rn_line || [ -n "$_rn_line" ]; do + case "$_rn_line" in + "file "*) ;; + *) continue ;; + esac + [ "${_rn_line#file * * }" != "$_rn_want" ] || return 0 + done <"$RECEIPT" + return 1 +} + +# The per-path half of check_dist_tree. It runs in a re-invocation of this +# script, so it uses the same helpers as the rest of the file rather than a +# second copy of them that could drift. Paths arrive as arguments and are never +# split, joined or trimmed. scan_dist_paths() { for _file in "$@"; do - if is_listed "$_file"; then + if [ -h "$_file" ]; then + fail "$_file is a symlink under dist/. The build emits regular + files only, so this path is not something it produced, and what it points + at is not what was verified. Refusing to report success." + fi + if receipt_names "$_file"; then continue fi - if has_marker "$MARKER_ON" "$_file" || - has_marker "$MARKER_OFF" "$_file"; then - fail "$_file carries a debug marker but is absent from $MANIFEST, - so the manifest no longer describes the emitted bundles." - fi + fail "$_file is under dist/ but the build that just ran did not emit + it. dist/ must contain exactly what the build produced: an extra file there + is an artifact nothing vouches for, and shipping the directory ships it." done } -# The requested mode, read from our own environment using build.js's exact -# rule: only the literal 1 opts in. Deliberately not taken from anything -# build.js records about itself, so build.js cannot vouch for build.js. -expected_marker() { - if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then - echo "$MARKER_ON" - else - echo "$MARKER_OFF" - fi +# --- arguments -------------------------------------------------------------- + +# The expected mode and the receipt are stated by the caller. Nothing is read +# from the environment, and there is no default for either. +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --expect) + [ "$#" -ge 2 ] || fail "--expect needs an argument (release|debug)." + set_expect "$2" + shift 2 + ;; + --expect=*) + set_expect "${1#--expect=}" + shift + ;; + --receipt) + [ "$#" -ge 2 ] || fail "--receipt needs a path." + set_receipt "$2" + shift 2 + ;; + --receipt=*) + set_receipt "${1#--receipt=}" + shift + ;; + *) + usage + fail "unknown argument: $1" + ;; + esac + done } -main() { - cd "$ROOT" +set_expect() { + [ -z "$EXPECT" ] || fail "--expect given more than once." + case "$1" in + release) EXPECT="$MARKER_OFF" ;; + debug) EXPECT="$MARKER_ON" ;; + *) fail "--expect takes release or debug, not \"$1\"." ;; + esac +} - # Internal re-entry from check_unlisted_bundles' xargs. Not part of the +set_receipt() { + [ -z "$RECEIPT" ] || fail "--receipt given more than once." + [ -n "$1" ] || fail "--receipt was given an empty path." + # Resolved against the caller's directory, before main cd's to the repo + # root. + case "$1" in + /*) RECEIPT="$1" ;; + *) RECEIPT="$PWD/$1" ;; + esac +} + +# --- main ------------------------------------------------------------------- + +main() { + # Internal re-entry from check_dist_tree's xargs. Not part of the # command-line interface: nothing else invokes it, and it is a distinct # entry point rather than a mode flag threaded through the checks below. if [ "${1-}" = "$SCAN_FLAG" ]; then shift + [ "$#" -ge 1 ] || fail "internal: $SCAN_FLAG needs the receipt path." + RECEIPT="$1" + shift + cd "$ROOT" + [ -r "$RECEIPT" ] || + fail "$RECEIPT became unreadable during the run, so the dist/ tree + could not be checked against it. Refusing to report success." scan_dist_paths "$@" return 0 fi - expected="$(expected_marker)" - echo "Verifying emitted bundles (expecting $expected)..." + parse_args "$@" + + [ -n "$EXPECT" ] || { + usage + fail "no --expect argument. The mode this build was supposed to produce + has to be stated by whoever ran the build; it is not a default and it is + not read from AUTISTMASK_DEBUG in this script's environment, because an + operator with that exported would then have their debug build verified as + the release one they asked for." + } + [ -n "$RECEIPT" ] || { + usage + fail "no --receipt argument. The list of files to check comes from the + build that just ran, not from dist/: without it, a hand-written dist/ would + be verifying itself. make build and make build-debug pass one." + } + + pick_sha256 + cd "$ROOT" # Asserted here rather than left to grep. A symlinked dist/ used to fail - # only because GNU grep exits 2 on a directory, so check_unlisted_bundles' - # single entry hit has_marker's I/O path by luck; under a grep that exits 1 - # instead, the whole cross-check would have collapsed into a pass. + # only because GNU grep exits 2 on a directory, so the tree walk hit + # has_marker's I/O path by luck; under a grep that exits 1 instead, the + # whole cross-check would have collapsed into a pass. if [ -h dist ]; then fail "dist is a symlink, not a directory. find does not follow a - symlink named on its own command line, so the unlisted-bundle cross-check - would see one entry instead of the emitted tree and establish nothing about - it. Refusing to report success." + symlink named on its own command line, so the tree walk would see one entry + instead of the emitted tree and establish nothing about it. Refusing to + report success." fi [ -d dist ] || fail "dist is not a directory, so there is no emitted tree to verify. build.js writes it; run make build first." - [ -f "$MANIFEST" ] || - fail "$MANIFEST is missing. build.js writes it at the end of a - successful build; run make build first." - [ -s "$MANIFEST" ] || - fail "$MANIFEST is empty, so no emitted bundle was found to contain - src/shared/constants.js. That is never correct, so it is a failure and not - a pass." - [ -r "$MANIFEST" ] || - fail "$MANIFEST is not readable, so nothing was inspected. That is a + case "$RECEIPT" in + "$ROOT/dist" | "$ROOT/dist/"*) + fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in + the tree it describes is rewritten by whoever rewrites the tree, and vouches + for nothing. make build keeps it outside the repo." + ;; + esac + + [ -e "$RECEIPT" ] || + fail "$RECEIPT is missing. build.js writes it at the end of a + successful build; run make build rather than invoking this directly." + [ -f "$RECEIPT" ] || + fail "$RECEIPT is not a regular file, so it is not a build receipt." + [ -s "$RECEIPT" ] || + fail "$RECEIPT is empty, so the build wrote nothing to it and there is + no account of what it emitted. build.js writes the receipt last, so an + empty one means the build did not finish." + [ -r "$RECEIPT" ] || + fail "$RECEIPT is not readable, so nothing was inspected. That is a permissions or I/O fault, not a pass." - count=0 - while read -r file; do - [ -n "$file" ] || continue - [ -f "$file" ] || - fail "$MANIFEST lists $file, which does not exist." - [ -s "$file" ] || - fail "$MANIFEST lists $file, which is empty. An empty bundle - carries no marker and proves nothing, so this is a failure and not a pass." - read_marker "$file" - [ "$MARKER" = "$expected" ] || - fail "$file is $MARKER but this build expects $expected." - echo " ok: $file ($MARKER)" - count=$((count + 1)) - done <"$MANIFEST" + echo "Verifying emitted files against the build receipt (expecting" \ + "$EXPECT)..." - [ "$count" -gt 0 ] || fail "no bundles were inspected." + # Order matters. The receipt has to be well-formed before it is used as an + # expectation, and the tree has to be walkable in full before any single + # file in it is pronounced on: a subtree that cannot be descended makes + # every file under it look absent, and "could not look" must never be + # reported as "was not there". + check_receipt_shape + check_dist_tree + check_receipt_entries - check_unlisted_bundles - - echo "verify-build: $count bundle(s) verified $expected" + echo "verify-build: $COUNT emitted file(s) verified against the receipt," \ + "$AUDITED bundle(s) $EXPECT" } main "$@"