#!/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)" # Absolute path to this script, resolved before anything cd's anywhere. # check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be # relative to a directory we are about to leave. SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" # Internal re-entry flag; see scan_dist_paths. SCAN_FLAG="--scan-dist-paths" # A literal newline, for the is_listed guard. NEWLINE=' ' MANIFEST="dist/constants-bundles.txt" MARKER_ON="autistmask-build-debug=on" MARKER_OFF="autistmask-build-debug=off" # Set by read_marker. MARKER="" # Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT # trap because fail() exits from wherever it is called. LISTING="" fail() { echo "verify-build: FAIL: $*" >&2 exit 1 } cleanup() { [ -z "$LISTING" ] || rm -f "$LISTING" } trap cleanup EXIT # Is the literal $1 present in the file $2? Match (grep exit 0) and no-match # (exit 1) are answers about the emitted output. Anything else (exit 2: the # file could not be read) is not an answer at all, and must not be reported as # "no marker" — that would blame the bundle for a permissions or I/O fault. has_marker() { _hm_status=0 grep -q -F -e "$1" -- "$2" || _hm_status=$? case "$_hm_status" in 0) return 0 ;; 1) return 1 ;; *) fail "grep exited $_hm_status reading $2, so the file could not be searched and its DEBUG state was not checked at all. That is a permissions or I/O fault on the artifact, not a change in the emitted output. Refusing to report success." ;; esac } # Does the manifest list the path $1, as a whole line? Same discipline as # has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the # manifest could not be read and is not an answer at all. Without this, an # unreadable manifest reads as "this file is not listed" and every emitted # bundle gets reported as an unlisted one. # # A path containing a newline is answered without asking grep, because grep # would read the pattern as two patterns and report a match on either. That is # how such a path escaped this check even once the walk stopped splitting it: # the half before the newline matched a listed line and the file was skipped. # The manifest is line-delimited, so it cannot name such a path at all, and # "not listed" is the only true answer. is_listed() { case "$1" in *"$NEWLINE"*) return 1 ;; esac _il_status=0 grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$? case "$_il_status" in 0) return 0 ;; 1) return 1 ;; *) fail "grep exited $_il_status reading $MANIFEST, so it could not be searched and nothing was established about which bundles it lists. That is a permissions or I/O fault on the manifest, not a stale manifest. Refusing to report success." ;; esac } # 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. 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 DEBUG was not resolved at build time: the ternary in src/shared/constants.js survived into the emitted output. This does not mean the debug branch is live in this artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension context, so DEBUG evaluates to false at runtime. It does mean the release/debug distinction is no longer enforced at build time, and which way that fallback happens to evaluate is then an accident a refactor can flip. 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 file may carry one, which catches a manifest that has gone stale # or short rather than trusting whatever it happens to list. # # Deliberately unfiltered by extension. build.js selects manifest entries with # an endsWith(".js") test; repeating that literal here would mean a bundle # emitted under some other extension escaped the manifest AND this check at # once, which is the correlated blind spot the two-source design exists to # avoid. Every regular file and every symlink under dist/ is searched — that # is the whole of what a build emits — so build.js's filter is the only place # the assumption lives and this check is what catches it being wrong. # # That claim only holds if the walk is exhaustive and every name survives it # intact, so four things are enforced here rather than assumed: # # - the walk is NUL-delimited and the paths reach the check as arguments, so # no name can be reshaped on the way in. Read line by line, a name with a # trailing space lost it to read's field splitting and the remnant then # matched a manifest line, and a name containing a newline arrived as a # listed path plus an empty one. Both left a marker-carrying, unlisted file # unchecked while the script still reported success. Delivering such a name # intact is only half of it; is_listed also has to keep it out of grep's # pattern, for the same reason. # - find's exit status is checked. A subtree it cannot descend is reported on # stderr and then simply missing from the listing, so an unchecked status # turns "could not look" into "nothing was there" — the same conflation # has_marker exists to prevent. The status cannot be read off a pipeline, # so the listing lands in a file that xargs then reads back. # - symlinks are walked too (-type l), not skipped. A marker-carrying bundle # reachable under an unlisted path in dist/ is a stale manifest whether the # path is a link or a file, and grep reads through the link. A link that # cannot be read through — dangling, or pointing at a directory — fails # hard via has_marker's exit-2 path, which is the fail-closed answer: the # build emits neither, so their DEBUG state is unproven, not fine. # - dist/ itself must be a directory and not a symlink, which main asserts # before anything reads through it. find does not follow a symlink named on # its own command line, so a linked dist/ collapses this walk to one entry # and cross-checks nothing. # # Types other than regular files and symlinks are left out on purpose: a build # emits none of them, and grep on a fifo would hang rather than fail. check_unlisted_bundles() { LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || fail "could not create a temporary file for the dist/ listing, so the tree was never walked. Refusing to report success." _find_status=0 find dist \( -type f -o -type l \) -print0 >"$LISTING" || _find_status=$? [ "$_find_status" -eq 0 ] || fail "find exited $_find_status enumerating dist/, so part of the tree was never walked and nothing was established about the files in it. Any unlisted bundle there went unchecked. That is a permissions or I/O fault on the artifact, not a stale manifest. Refusing to report success." _scan_status=0 xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$? [ "$_scan_status" -eq 0 ] || fail "the unlisted-bundle scan exited $_scan_status: either a path under dist/ failed the check reported above, or the scan could not be run at all. Refusing to report success." } # The per-path half of check_unlisted_bundles. It runs in a re-invocation of # this script, so it uses the same is_listed and has_marker as the rest of the # file rather than a second copy of them that could drift. Paths arrive as # arguments and are never split, joined or trimmed. scan_dist_paths() { for _file in "$@"; do if is_listed "$_file"; then continue fi if has_marker "$MARKER_ON" "$_file" || has_marker "$MARKER_OFF" "$_file"; then fail "$_file carries a debug marker but is absent from $MANIFEST, so the manifest no longer describes the emitted bundles." fi done } # The requested mode, read from our own environment using build.js's exact # rule: only the literal 1 opts in. Deliberately not taken from anything # build.js records about itself, so build.js cannot vouch for build.js. expected_marker() { if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then echo "$MARKER_ON" else echo "$MARKER_OFF" fi } main() { 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 # entry point rather than a mode flag threaded through the checks below. if [ "${1-}" = "$SCAN_FLAG" ]; then shift scan_dist_paths "$@" return 0 fi expected="$(expected_marker)" echo "Verifying emitted bundles (expecting $expected)..." # Asserted here rather than left to grep. A symlinked dist/ used to fail # only because GNU grep exits 2 on a directory, so check_unlisted_bundles' # single entry hit has_marker's I/O path by luck; under a grep that exits 1 # instead, the whole cross-check would have collapsed into a pass. if [ -h dist ]; then fail "dist is a symlink, not a directory. find does not follow a symlink named on its own command line, so the unlisted-bundle cross-check would see one entry instead of the emitted tree and establish nothing about it. Refusing to report success." fi [ -d dist ] || fail "dist is not a directory, so there is no emitted tree to verify. build.js writes it; run make build first." [ -f "$MANIFEST" ] || fail "$MANIFEST is missing. build.js writes it at the end of a successful build; run make build first." [ -s "$MANIFEST" ] || fail "$MANIFEST is empty, so no emitted bundle was found to contain src/shared/constants.js. That is never correct, so it is a failure and not a pass." [ -r "$MANIFEST" ] || fail "$MANIFEST is not readable, so nothing was inspected. That is a permissions or I/O fault, not a pass." count=0 while read -r file; do [ -n "$file" ] || continue [ -f "$file" ] || fail "$MANIFEST lists $file, which does not exist." [ -s "$file" ] || fail "$MANIFEST lists $file, which is empty. An empty bundle carries no marker and proves nothing, so this is a failure and not a pass." read_marker "$file" [ "$MARKER" = "$expected" ] || fail "$file is $MARKER but this build expects $expected." echo " ok: $file ($MARKER)" count=$((count + 1)) done <"$MANIFEST" [ "$count" -gt 0 ] || fail "no bundles were inspected." check_unlisted_bundles echo "verify-build: $count bundle(s) verified $expected" } main "$@"