diff --git a/Makefile b/Makefile index 33ab6d0..b74a06b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build build-debug clean dev +.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build build-debug verify-build clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -37,6 +37,7 @@ hooks: build: @echo "Building extension..." @yarn run build 2>&1 + @script/verify-build # Development-only build: enables the red DEBUG / INSECURE banner and makes # the hardcoded test recovery phrase the output of wallet creation. Never @@ -44,6 +45,12 @@ build: build-debug: @echo "Building extension (DEBUG)..." @AUTISTMASK_DEBUG=1 yarn run build 2>&1 + @AUTISTMASK_DEBUG=1 script/verify-build + +# 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 clean: @rm -rf dist/ diff --git a/README.md b/README.md index a4023da..0daa103 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ behavior. The build prints which mode it used. See the distribute a debug build** — every wallet it creates gets the same publicly known test recovery phrase. +Both builds end by running `script/verify-build`, which reads the compiled +`DEBUG` state back out of the emitted bundles and fails the build if it is not +the one that was asked for. The test suite cannot check this: it loads +`src/shared/constants.js` outside a bundle, so it only ever sees the fallback +value. The assertion is on the artifacts because that is where the property +lives. + ## Entrypoints This repository adheres to the @@ -77,6 +84,12 @@ provide: - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check +- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in + `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` + off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of + `make build` and `make build-debug`; fails loudly rather than passing if it + cannot determine a bundle's state. Not part of `make check`, which does not + depend on build artifacts existing. - `script/docker` — build the Docker image tagged via `script/projectname` - `script/cibuild` — CI entrypoint: plain `docker build .` - `script/precommit` — run by the git pre-commit hook; runs `script/check` diff --git a/TODO.md b/TODO.md index b94902f..8ae3b80 100644 --- a/TODO.md +++ b/TODO.md @@ -27,6 +27,10 @@ review. # Completed Steps +- 2026-08-09: Post-build assertion that every emitted bundle containing + `constants.js` has `DEBUG` compiled off, via `script/verify-build` on the + `make build` path (#170). Branched from `fix/issue-149-debug-build-flag`; + merge after #169. - 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog (#149-#168). - 2026-07-26: About well in settings with build info, repo link and the version diff --git a/build.js b/build.js index d738586..0ecdad4 100644 --- a/build.js +++ b/build.js @@ -3,14 +3,43 @@ const path = require("path"); const { execSync } = require("child_process"); const esbuild = require("esbuild"); -const DIST_CHROME = path.join(__dirname, "dist", "chrome"); -const DIST_FIREFOX = path.join(__dirname, "dist", "firefox"); +const DIST = path.join(__dirname, "dist"); +const DIST_CHROME = path.join(DIST, "chrome"); +const DIST_FIREFOX = path.join(DIST, "firefox"); const SRC = path.join(__dirname, "src"); +// The module whose compiled DEBUG state script/verify-build asserts, and the +// manifest naming every emitted bundle that ends up containing it. The +// manifest is derived from esbuild's own dependency graph rather than from a +// hardcoded list, so it tracks the bundle layout instead of rotting with it. +const AUDITED_MODULE = "src/shared/constants.js"; +const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt"); + function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } +// Repo-relative, forward-slashed, so the manifest reads the same on every +// platform and can be consumed by a POSIX shell script without further work. +function repoRelative(p) { + return path.relative(__dirname, p).split(path.sep).join("/"); +} + +// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild +// reports every input that contributed to an output in the metafile, which is +// the authoritative answer to "is constants.js in this bundle" — unlike +// searching the minified text, it does not depend on what survived minification. +function outputsContainingAuditedModule(metafile) { + return Object.entries(metafile.outputs) + .filter(([outFile, info]) => { + if (!outFile.endsWith(".js")) return false; + return Object.keys(info.inputs).some( + (input) => repoRelative(input) === AUDITED_MODULE, + ); + }) + .map(([outFile]) => repoRelative(outFile)); +} + // DEBUG is a build-time flag, off unless explicitly requested. It is the only // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // exact: anything other than the literal "1" (unset, empty, "true", a typo) @@ -72,68 +101,70 @@ async function build() { __BUILD_DATE__: JSON.stringify(buildInfo.buildDate), }; + // Emitted bundles that contain constants.js, accumulated across every + // esbuild run below and written out for script/verify-build. + const auditedBundles = []; + // compile tailwind CSS console.log("Compiling Tailwind CSS..."); const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); - const tailwindOutput = path.join(__dirname, "dist", "styles.css"); - ensureDir(path.join(__dirname, "dist")); + const tailwindOutput = path.join(DIST, "styles.css"); + 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 }); execSync( `npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, { stdio: "inherit" }, ); + // Every bundle goes through here, so metafile collection cannot be + // forgotten when a new entry point is added. + async function bundle(entryPoint, outfile) { + const result = await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + format: "iife", + outfile, + platform: "browser", + target: ["chrome110", "firefox110"], + minify: true, + metafile: true, + define, + }); + auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); + } + for (const distDir of [DIST_CHROME, DIST_FIREFOX]) { ensureDir(path.join(distDir, "src", "popup")); ensureDir(path.join(distDir, "src", "background")); ensureDir(path.join(distDir, "src", "content")); // bundle popup JS with esbuild (inlines ethers, libsodium, etc.) - await esbuild.build({ - entryPoints: [path.join(SRC, "popup", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "popup", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "popup", "index.js"), + path.join(distDir, "src", "popup", "index.js"), + ); // bundle background script - await esbuild.build({ - entryPoints: [path.join(SRC, "background", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "background", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "background", "index.js"), + path.join(distDir, "src", "background", "index.js"), + ); // bundle content script - await esbuild.build({ - entryPoints: [path.join(SRC, "content", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "content", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "content", "index.js"), + path.join(distDir, "src", "content", "index.js"), + ); // bundle inpage script (injected into page context, separate file) - await esbuild.build({ - entryPoints: [path.join(SRC, "content", "inpage.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "content", "inpage.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "content", "inpage.js"), + path.join(distDir, "src", "content", "inpage.js"), + ); // copy popup HTML fs.copyFileSync( @@ -158,6 +189,16 @@ async function build() { path.join(DIST_FIREFOX, "manifest.json"), ); + // Written last so a build that died partway through leaves no manifest + // at all, which script/verify-build treats as a hard failure rather than + // as "nothing to check". + const manifest = [...new Set(auditedBundles)].sort(); + fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join("")); + console.log( + `Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` + + `(listed in ${repoRelative(BUNDLE_MANIFEST)})`, + ); + console.log("Build complete: dist/chrome/ and dist/firefox/"); } diff --git a/script/verify-build b/script/verify-build new file mode 100755 index 0000000..23b1b7c --- /dev/null +++ b/script/verify-build @@ -0,0 +1,136 @@ +#!/bin/sh +# script/verify-build: assert the compiled DEBUG state of the emitted +# bundles. Our own extension to scripts-to-rule-them-all, run at the end of +# make build / make build-debug. +# +# Why this exists: DEBUG makes the publicly committed test recovery phrase the +# output of wallet creation, so a release artifact built with it live hands +# every new wallet to anyone who reads the repo. The test suite cannot see +# this, because it loads src/shared/constants.js outside a bundle and takes +# the fallback branch; the property only exists in the emitted output, so it +# has to be asserted against the emitted output. +# +# What it reads: dist/constants-bundles.txt, written by build.js from +# esbuild's metafile, naming every emitted bundle that contains +# src/shared/constants.js. Each of those must carry exactly one of the two +# BUILD_DEBUG_MARKER literals that constants.js folds down to. +# +# It fails rather than passes whenever it cannot determine a bundle's state. +# Minified output is not a stable contract, so "matched neither form" is not +# evidence of anything and must never read as green. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +MANIFEST="dist/constants-bundles.txt" +MARKER_ON="autistmask-build-debug=on" +MARKER_OFF="autistmask-build-debug=off" + +# Set by read_marker. +MARKER="" + +fail() { + echo "verify-build: FAIL: $*" >&2 + exit 1 +} + +has_marker() { + grep -q -F "$1" "$2" 2>/dev/null +} + +# Read one bundle's DEBUG state into MARKER. Exactly one marker must be +# present. Both means the ternary in constants.js was never folded, which is +# what happens when the __BUILD_DEBUG__ define goes missing from build.js: +# DEBUG stops being known at build time and the debug branch is live again. +# Neither means we are reading output we do not understand. Both are hard +# failures; neither is ever treated as absence of a problem. +read_marker() { + _file="$1" + _on=no + _off=no + if has_marker "$MARKER_ON" "$_file"; then _on=yes; fi + if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi + + if [ "$_on" = yes ] && [ "$_off" = yes ]; then + fail "$_file carries both debug markers, so the build-time DEBUG value + was never resolved and the debug branch is still live. Check that build.js + still defines __BUILD_DEBUG__." + fi + if [ "$_on" = no ] && [ "$_off" = no ]; then + fail "$_file carries no debug marker, so its DEBUG state cannot be + determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js + or the emitted output changed shape. Refusing to report success." + fi + + if [ "$_on" = yes ]; then + MARKER="$MARKER_ON" + else + MARKER="$MARKER_OFF" + fi +} + +# The manifest says which bundles must carry a marker. This says no other +# emitted bundle may carry one, which catches a manifest that has gone stale +# or short rather than trusting whatever it happens to list. +check_unlisted_bundles() { + _listing="$(find dist -type f -name '*.js' | sort)" + while read -r _file; do + [ -n "$_file" ] || continue + if grep -q -x -F "$_file" "$MANIFEST"; then + continue + fi + if has_marker "$MARKER_ON" "$_file" || + has_marker "$MARKER_OFF" "$_file"; then + fail "$_file carries a debug marker but is absent from $MANIFEST, + so the manifest no longer describes the emitted bundles." + fi + done < { expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); }); + // This does not replace script/verify-build, which is the only thing that + // can see the compiled DEBUG state of a real bundle. It pins the source + // invariant that the marker tracks DEBUG, so the two cannot be edited + // apart and leave verify-build asserting something that is no longer the + // flag the code branches on. + test("build debug marker is derived from DEBUG", () => { + expect(BUILD_DEBUG_MARKER).toBe( + DEBUG ? "autistmask-build-debug=on" : "autistmask-build-debug=off", + ); + }); + + // Outside a bundle there is no __BUILD_DEBUG__ define, and the fallback + // must be the safe one. + test("DEBUG is off when loaded outside a bundle", () => { + expect(DEBUG).toBe(false); + expect(BUILD_DEBUG_MARKER).toBe("autistmask-build-debug=off"); + }); + test("exports ERC-20 ABI with expected functions", () => { expect(Array.isArray(ERC20_ABI)).toBe(true); expect(ERC20_ABI.length).toBeGreaterThan(0);