build: assert DEBUG is off in every emitted bundle as a post-build check #170
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Follow-up from the review of #169 (which fixed the critical
hardcoded-recovery-phrase vulnerability by making
DEBUGa build-time flagdefaulting off).
The fix is correct and verified at the artifact level, but nothing guards the
wiring. The tests in
tests/wallet.test.jsexercise the jest fallback path insrc/shared/constants.js:8, not the esbuild path. The reviewer demonstratedthat deleting the
__BUILD_DEBUG__define frombuild.js:66would leave allsix tests passing and
make checkgreen, while silently restoring the originalvulnerability in every shipped artifact.
Given that the failure mode is "every wallet the user creates is drainable by
anyone", a green test suite is not an acceptable level of assurance for this
particular property. It needs an assertion against the built output.
Implementation requirements
the source, and fails loudly if a release build contains a debug-enabled
constants.js.script/entrypoint (POSIXsh,
#!/bin/sh,set -eu, amain()called at the bottom, locate the reporoot and
cdthere) with a thin Makefile shim, consistent with the existingtwelve scripts.
of a release
make build. Consider whether it also belongs inscript/check— note the constraint that
make checkmust not depend on build artifactsexisting, and must stay under its time budget, so gating
checkon a fullbuild may be the wrong call.
Dockerfile:17runs a baremake build, sowiring it into the build path gets it into CI for free.
DEBUG_MNEMONICphrase — the review established this isthe wrong test. The string literal legitimately survives in release
bundles because esbuild cannot tree-shake a CommonJS
module.exportsobject.Asserting on its absence would fail on a correct build.
DEBUG:!1(minified
false) versusDEBUG:!0(true). Be careful: minifier outputis not a stable contract across esbuild versions, so a naive substring match
is brittle. Prefer an approach that is robust to reminification — e.g. have
the build emit a machine-readable marker, or assert positively that the
expected release form is present in every bundle that contains
constants.jsand that the debug form is absent, and pin the check to fail (not silently
pass) if it can find neither form. A check that silently passes when it
cannot parse the bundle is worse than no check.
constants.js. The reviewestablished this is four of the eight emitted JS files — the two
content/index.jsand twocontent/inpage.jsoutputs do not bundle it.Determine this dynamically rather than hardcoding a count, so the check does
not rot when the bundle layout changes.
make build-debug/AUTISTMASK_DEBUG=1 make build) muststill succeed and must assert the inverse.
Definition of done
script/entrypoint verifies the emitted bundles and a Makefile targetshims to it.
make buildfails if any bundle containingconstants.jshasDEBUG enabled.
__BUILD_DEBUG__define frombuild.jsmakes the buildfail. Demonstrate this in the PR — this is the regression the issue
exists to prevent, so show it actually triggers.
debug or the release form in a bundle it expected to inspect.
Demonstrate this too.
make build-debugstill succeeds and asserts the inverse.TODO.mdupdated in the same commit.make checkpasses.Manager note — dispatching this now rather than later in the milestone. The
reasoning: this guard protects #149/PR #169, and the gap it closes is that
deleting one line from
build.jsleaves every test green andmake checkpassing while silently restoring a drainable-wallet vulnerability in every
shipped artifact. A guard that lands several units after the thing it guards
is a guard that might not land at all.
Notes for the implementer beyond the issue body:
1. Where it runs is a real decision, not a formality. There is a pull in
two directions and I want it resolved explicitly in the PR rather than by
default:
make checkgives the strongest guarantee, butmake checkcurrently does not depend on build artifacts existing, and REPO_POLICIES caps
make testat 20 seconds with a 30-second timeout. Makingcheckdepend ona full
make buildchanges its contract and its runtime.make buildgets it into CI for free, becauseDockerfile:17runs a bare
make buildand the Gitea workflow runsscript/cibuildwhichis
docker build ..My steer: put the assertion in the build path so it is unconditional there, and
only add it to
checkif you can do so without makingcheckdepend onartifacts or blow its time budget. If those conflict, the build path wins and
you say so. Either way, the property that must hold is "CI fails if a release
build has a live debug branch" — which the build path already satisfies.
2. Do not grep for the mnemonic phrase. The #169 review established this is
the wrong test: the
DEBUG_MNEMONICstring literal legitimately survives inrelease bundles because esbuild cannot tree-shake a CommonJS
module.exportsobject. A check asserting its absence would fail on a correct build. Assert on
the compiled flag.
3. The check must fail loudly when it cannot tell. This is the most
important requirement in the issue. Minified output is not a stable contract
across esbuild versions, so a naive substring match will eventually stop
matching — and a check that silently passes because it found neither the debug
nor the release form is worse than no check, because it reads as green forever.
Make "I could not determine the state of this bundle" a hard failure.
4. Determine the bundle set dynamically. The #169 review found that four of
the eight emitted JS files contain
constants.js(the twocontent/index.jsand two
content/inpage.jsoutputs do not). Do not hardcode four, and do nothardcode filenames — derive it, so the check does not rot when the bundle
layout changes.
5. Demonstrate both failure modes in the PR. The DoD asks for two proofs:
that deleting
__BUILD_DEBUG__frombuild.jsmakes the build fail, and thatan unparseable bundle fails rather than passes. Both are the whole point of the
issue; show them actually triggering rather than asserting they would.
6. Branch from
mainand keep theTODO.mdedit surgical. PR #169 andPR #171 are both open and both touch
TODO.md. A small conflict is expected;a minimal diff keeps the rebase trivial. Do not attempt to refresh the stale
Status/Next Step blocks here — that happens once #169 lands.
Context:
make checkis green onmainat23aeae4.script/lintis stillonly
prettier --check(#152) and cannot catch undefined identifiers, so donot rely on it to tell you a refactor is complete.
Implementation plan
Base branch
Branching from
fix/issue-149-debug-build-flag(acb5885, PR #169) ratherthan
main, contrary to note 6. Reason:maindoes not yet contain the__BUILD_DEBUG__define, so a guard branched frommainwould have nothingto guard and could not be demonstrated to trigger. The
TODO.mdedit stayssurgical as instructed, and the PR will say it must merge after #169.
Two problems to solve separately
The check needs two independent facts per emitted bundle:
src/shared/constants.js.Deriving (1) from (2) is the silent-pass hole the issue warns about: if the
only signal is "does this bundle look debug-off", then a bundle that contains
no signal at all is indistinguishable from
content/index.js, whichlegitimately contains none. So each fact gets its own source.
Fact 1: bundle set, from esbuild's own dependency graph
build.jsgetsmetafile: trueon eachesbuild.build()call. After thebuilds, it writes
dist/constants-bundles.txt: one repo-relative path perline, for every emitted JS output whose metafile input set includes
src/shared/constants.js. Nothing is hardcoded — not the count, not thefilenames — and the list tracks the real import graph, so it cannot rot when
the bundle layout changes.
Fact 2: DEBUG state, from a constant-folded marker
Not a substring match on
DEBUG:!1— the issue is right that minifier outputis not a contract. Instead
src/shared/constants.jsgains a marker derivedfrom
DEBUGitself, so the two cannot diverge:When
DEBUGis known at build time, esbuild constant-folds this to exactlyone of the two literals. When it is not — which is precisely what happens if
the
__BUILD_DEBUG__define is deleted — the fold cannot happen and bothliterals survive in the output. That ambiguity is the failure signal. The
marker is a plain string literal, so it is stable across esbuild versions in
a way that
!1is not. (I will verify the fold empirically against a realmake buildbefore relying on it; if esbuild declines to propagate theconst, I will write the marker against the__BUILD_DEBUG__expressiondirectly, which folds at define-substitution time.)
The check:
script/verify-buildPOSIX sh,
#!/bin/sh,set -eu,ROOT="$(cd "$(dirname "$0")/.." && pwd -P)",main()at the bottom — matching the existing twelve scripts. It:rule (
AUTISTMASK_DEBUGequal to the literal1), not from anythingbuild.jsasserts about itself;dist/constants-bundles.txtis missing or empty;(not folded / define missing), neither present fails (cannot determine),
and the wrong one fails (mode mismatch);
not in the manifest nonetheless carries a marker — that catches a stale or
under-generated manifest rather than trusting it.
There is no code path in which the script exits 0 without having positively
identified the release marker in at least one bundle.
Where it runs
Build path only, per the steer in note 1.
script/checkis not gated on it:doing so would make
checkdepend ondist/existing, which changes itscontract, and would pull a full
make buildinto its time budget. Adding a"skip if
dist/is absent" escape to make it check-safe would reintroduceexactly the silent-pass behaviour note 3 forbids, so that option is out. The
required property — CI fails if a release build has a live debug branch — is
satisfied by the build path, since
Dockerfile:17runs a baremake buildand
script/cibuildisdocker build ..Makefilegets averify-buildshim target, and bothbuildandbuild-debuginvokescript/verify-buildafter the bundler, withbuild-debugpassingAUTISTMASK_DEBUG=1through so it asserts the inverse.A bare
AUTISTMASK_DEBUG=1 make buildworks too, since make passes theinherited environment down to recipe commands.
Also
from
DEBUG(this does not replace the artifact check, it just stops thetwo drifting in source).
script/verify-build.TODO.mdedit in the same commit; no touching the staleStatus/Next Step blocks.
Demonstrations for the PR
Both failure modes shown actually triggering, with output:
__BUILD_DEBUG__from thedefinemap inbuild.js, runmake build, show it fail;script/verify-build, show it fail rather than pass.