#!/bin/sh # script/verify-build: assert that dist/ holds exactly what the build that just # ran emitted, and that the compiled DEBUG state of that output is the one the # caller asked for. Our own extension to scripts-to-rule-them-all, run at the # end of make build / make build-debug. # # Why the DEBUG half 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. # # Which mode to expect is an ARGUMENT (--expect release|debug) and is never # taken from this script's environment. It used to be read from # AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1 # exported in their shell could run the release target, get a debug build, and # have it verified green and exit 0. There is also no default: a caller that # does not say what it built gets a failure, because "no opinion" is not a # state this can check anything against. # # Why the provenance half exists: on its own, a marker grep proves nothing # about where the bytes came from. A 26-byte file containing only the marker # string used to verify ok; the content script and manifest.json were not read # at all; an entire hand-written dist/ passed. The list of files to check has # therefore moved OUT of dist/: build.js writes a receipt naming every file it # emitted, with each file's sha256 and whether it is one of the bundles # containing src/shared/constants.js, and the Makefile creates that receipt # path fresh per invocation, outside the repo, and deletes it afterwards. # # What that does and does not establish. It establishes that dist/ is byte for # byte the output of the build.js run that just finished, with nothing added, # nothing missing and nothing altered in between, and that the audited bundles # in it compiled to the requested mode. It does NOT establish that the source # tree or build.js were honest, and it says nothing at all to someone handed a # dist/ from elsewhere: without the receipt from its own build they have no # input to this check. That is signing, and it is not this control. # # It fails rather than passes whenever it cannot determine something. Minified # output is not a stable contract, so "matched neither marker" is not evidence # of anything and must never read as green; the same discipline applies to # every read here, which is why a grep or a digest that could not be taken is # a hard failure and not an absence of a problem. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # 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 # 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 and tab, for the receipt-shape guards. NEWLINE=' ' TAB=' ' MARKER_ON="autistmask-build-debug=on" MARKER_OFF="autistmask-build-debug=off" RECEIPT_HEADER="autistmask-build-receipt v1" # Set by the arguments. RECEIPT="" EXPECT="" # Set by read_marker, read_sha256 and parse_file_line respectively, plus the # receipt line number the diagnostics quote. MARKER="" 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 # trap because fail() exits from wherever it is called. LISTING="" fail() { echo "verify-build: FAIL: $*" >&2 exit 1 } usage() { echo "usage: verify-build --expect release|debug --receipt PATH" >&2 } cleanup() { [ -z "$LISTING" ] || rm -f "$LISTING" } trap cleanup EXIT # --- reading files ---------------------------------------------------------- # 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 } # Pick the sha256 command once. All three print the digest as the first # whitespace-delimited field. If none is present the digests cannot be taken at # all, and this script has nothing left to check with, so it fails rather than # degrading to the marker grep it used to be. pick_sha256() { if command -v sha256sum >/dev/null 2>&1; then SHA256="sha256sum" elif command -v shasum >/dev/null 2>&1; then SHA256="shasum -a 256" elif command -v openssl >/dev/null 2>&1; then SHA256="openssl dgst -sha256 -r" else fail "no sha256 command found (tried sha256sum, shasum, openssl), so the emitted files cannot be checked against the build receipt at all. Refusing to report success." fi } # Digest of $1 into SHA. A digest that could not be taken is not a mismatch and # not a pass: it means the artifact was never read. read_sha256() { _rs_status=0 # Word-split on purpose: SHA256 is a command with its arguments. # shellcheck disable=SC2086 _rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$? [ "$_rs_status" -eq 0 ] || fail "$SHA256 exited $_rs_status on $1, so its bytes were never read and nothing was established about them. That is a permissions or I/O fault on the artifact, not a mismatch. Refusing to report success." SHA="${_rs_out%% *}" case "$SHA" in "" | *[!0-9a-f]*) fail "$SHA256 produced no usable digest for $1, so its bytes were never checked. Refusing to report success." ;; esac [ "${#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 # 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 receipt ------------------------------------------------------------ # Split one "file " line into ENTRY_HASH, ENTRY_FLAG and # ENTRY_PATH, and require the shape rather than assuming it. The path is the # remainder of the line, so a path carrying a space or a tab would be read back # as something other than what was written; build.js refuses to emit such a # name, and a receipt that contains one is malformed rather than describing a # file. Every rejection here is a failure: a line that cannot be understood is # a file that would otherwise go unchecked. parse_file_line() { case "$1" in "file "*) ;; *) fail "$RECEIPT line $LINENO_R is not a file entry and this script does not know what it means: ${1}. Refusing to report success." ;; esac _pl="${1#file }" ENTRY_HASH="${_pl%% *}" _pl="${_pl#* }" ENTRY_FLAG="${_pl%% *}" ENTRY_PATH="${_pl#* }" case "$ENTRY_HASH" in "" | *[!0-9a-f]*) fail "$RECEIPT line $LINENO_R has no sha256: $1" ;; esac [ "${#ENTRY_HASH}" -eq 64 ] || fail "$RECEIPT line $LINENO_R has a ${#ENTRY_HASH}-character digest, which is not a sha256: $1" case "$ENTRY_FLAG" in A | P) ;; *) fail "$RECEIPT line $LINENO_R has no A/P audit flag: $1" ;; esac case "$ENTRY_PATH" in dist/*) ;; *) fail "$RECEIPT line $LINENO_R names a path that is not under dist/: $ENTRY_PATH. The receipt describes the emitted tree and nothing else." ;; esac case "$ENTRY_PATH" in *" "* | *"$TAB"* | *"$NEWLINE"*) fail "$RECEIPT line $LINENO_R names a path containing whitespace, which cannot be read back unambiguously from a line-oriented receipt: $1" ;; esac } # Check one emitted file against its receipt entry: it must be a regular file # with exactly the recorded bytes, and its debug marker must match what the # caller said this build was. check_entry() { [ ! -h "$ENTRY_PATH" ] || fail "the receipt names $ENTRY_PATH but that path is a symlink. The build emits regular files only, so this is not the file it wrote. Refusing to report success." [ -f "$ENTRY_PATH" ] || fail "the receipt names $ENTRY_PATH, which does not exist. dist/ does not hold what the build emitted." [ -s "$ENTRY_PATH" ] || fail "the receipt names $ENTRY_PATH, which is empty. An empty file carries no marker and matches no digest, so this is a failure and not a pass." read_sha256 "$ENTRY_PATH" [ "$SHA" = "$ENTRY_HASH" ] || fail "$ENTRY_PATH does not contain the bytes this build emitted: the receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote to dist/ after the build, so this artifact is not the one that was built." if [ "$ENTRY_FLAG" = A ]; then read_marker "$ENTRY_PATH" [ "$MARKER" = "$EXPECT" ] || fail "$ENTRY_PATH is $MARKER but this build was told to expect $EXPECT. If AUTISTMASK_DEBUG=1 is exported in the shell that ran make build, that is why: the flag still reaches the compiler, and this is the check that stops the debug artifact being taken for a release one." echo " ok: $ENTRY_PATH ($MARKER)" AUDITED=$((AUDITED + 1)) else if has_marker "$MARKER_ON" "$ENTRY_PATH" || has_marker "$MARKER_OFF" "$ENTRY_PATH"; then fail "$ENTRY_PATH carries a debug marker but the build did not record it as containing src/shared/constants.js. build.js selects audited bundles with an endsWith(\".js\") test; a marker-carrying file outside that set means the test no longer describes what is emitted, and the DEBUG state of this file was never asserted against anything." fi fi COUNT=$((COUNT + 1)) } # Walk the receipt line by line, applying $1 to each file entry. The header and # the root line are checked on the way past; the root line is what stops a # receipt written by a build of some other tree being pointed at this one. walk_receipt() { _wr_each="$1" LINENO_R=0 _line="" while IFS= read -r _line || [ -n "$_line" ]; do LINENO_R=$((LINENO_R + 1)) if [ "$LINENO_R" -eq 1 ]; then [ "$_line" = "$RECEIPT_HEADER" ] || fail "$RECEIPT does not start with \"$RECEIPT_HEADER\", so it is not a build receipt this script understands. Refusing to report success." continue fi if [ "$LINENO_R" -eq 2 ]; then [ "$_line" = "root $ROOT" ] || fail "$RECEIPT was written by a build of a different tree: it says \"$_line\" and this is $ROOT. A receipt only describes the dist/ of the tree it was built in." continue fi parse_file_line "$_line" "$_wr_each" done <"$RECEIPT" [ "$LINENO_R" -ge 2 ] || fail "$RECEIPT is truncated: it has no root line, so it is not a receipt this script can check anything against." } # Pass one: the receipt has to be a receipt before anything is concluded from # it. A line this script cannot read is a file that would go unchecked, and a # receipt naming no audited bundle asserts no DEBUG state at all — both are # failures, and both have to be established before the tree is walked against # it, because a receipt entry that was misread would otherwise surface as a # complaint about dist/. count_entry() { SHAPE_COUNT=$((SHAPE_COUNT + 1)) if [ "$ENTRY_FLAG" = A ]; then SHAPE_AUDITED=$((SHAPE_AUDITED + 1)) fi } check_receipt_shape() { SHAPE_COUNT=0 SHAPE_AUDITED=0 walk_receipt count_entry [ "$SHAPE_COUNT" -gt 0 ] || fail "$RECEIPT names no emitted files, so nothing was inspected. A build always emits some." [ "$SHAPE_AUDITED" -gt 0 ] || fail "$RECEIPT names no bundle containing src/shared/constants.js, so no DEBUG state would be asserted at all. That is never correct, so it is a failure and not a pass." } # Pass three: every file the receipt names, checked against the bytes on disk. check_receipt_entries() { walk_receipt check_entry } # --- the emitted tree ------------------------------------------------------- # The receipt says which files the build emitted. This says dist/ contains no # others: an artifact that was added after the build, or that a hand-written # dist/ brought with it, is not something the build vouches for and is not # something this check may pass over. # # The walk has to be exhaustive and every name has to survive it intact, so # four things are enforced 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 listed path, and a name containing a newline arrived as a # listed path plus an empty one. Both left an unchecked file in dist/ while # the script still reported success. # - 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. The build emits none, so # a symlink under dist/ is a path the build did not produce, whatever it # points at, and it fails as one instead of being read through. # - 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_dist_tree() { 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 file the build did not emit could be sitting there unchecked. That is a permissions or I/O fault on the artifact. Refusing to report success." _scan_status=0 xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$? [ "$_scan_status" -eq 0 ] || fail "the dist/ tree 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." } # Does the receipt name the path $1? Compared as whole strings, never through # grep: a path found under dist/ is attacker-shaped input, and a pattern is not # the place to put one. The receipt's own paths are known to carry no # whitespace by the time this runs — verify_receipt failed the run otherwise — # so stripping the three leading fields recovers each one exactly. receipt_names() { _rn_want="$1" _rn_line="" while IFS= read -r _rn_line || [ -n "$_rn_line" ]; do case "$_rn_line" in "file "*) ;; *) continue ;; esac [ "${_rn_line#file * * }" != "$_rn_want" ] || return 0 done <"$RECEIPT" return 1 } # The per-path half of check_dist_tree. It runs in a re-invocation of this # script, so it uses the same helpers as the rest of the file rather than a # second copy of them that could drift. Paths arrive as arguments and are never # split, joined or trimmed. scan_dist_paths() { for _file in "$@"; do if [ -h "$_file" ]; then fail "$_file is a symlink under dist/. The build emits regular files only, so this path is not something it produced, and what it points at is not what was verified. Refusing to report success." fi if receipt_names "$_file"; then continue fi fail "$_file is under dist/ but the build that just ran did not emit it. dist/ must contain exactly what the build produced: an extra file there is an artifact nothing vouches for, and shipping the directory ships it." done } # --- arguments -------------------------------------------------------------- # The expected mode and the receipt are stated by the caller. Nothing is read # from the environment, and there is no default for either. parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --expect) [ "$#" -ge 2 ] || fail "--expect needs an argument (release|debug)." set_expect "$2" shift 2 ;; --expect=*) set_expect "${1#--expect=}" shift ;; --receipt) [ "$#" -ge 2 ] || fail "--receipt needs a path." set_receipt "$2" shift 2 ;; --receipt=*) set_receipt "${1#--receipt=}" shift ;; *) usage fail "unknown argument: $1" ;; esac done } 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() { # Internal re-entry from check_dist_tree's xargs. Not part of the # command-line interface: nothing else invokes it, and it is a distinct # entry point rather than a mode flag threaded through the checks below. if [ "${1-}" = "$SCAN_FLAG" ]; then 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 "$@" return 0 fi parse_args "$@" [ -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 # only because GNU grep exits 2 on a directory, so the tree walk 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 tree walk 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." case "$RECEIPT" in "$ROOT/dist" | "$ROOT/dist/"*) fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in the tree it describes is rewritten by whoever rewrites the tree, and vouches for nothing. make build keeps it outside the repo." ;; esac [ -e "$RECEIPT" ] || fail "$RECEIPT is missing. build.js writes it at the end of a successful build; run make build rather than invoking this directly." [ -f "$RECEIPT" ] || fail "$RECEIPT is not a regular file, so it is not a build receipt." [ -s "$RECEIPT" ] || fail "$RECEIPT is empty, so the build wrote nothing to it and there is no account of what it emitted. build.js writes the receipt last, so an empty one means the build did not finish." [ -r "$RECEIPT" ] || fail "$RECEIPT is not readable, so nothing was inspected. That is a permissions or I/O fault, not a pass." echo "Verifying emitted files against the build receipt (expecting" \ "$EXPECT)..." # Order matters. The receipt has to be well-formed before it is used as an # expectation, and the tree has to be walkable in full before any single # file in it is pronounced on: a subtree that cannot be descended makes # every file under it look absent, and "could not look" must never be # reported as "was not there". check_receipt_shape check_dist_tree check_receipt_entries echo "verify-build: $COUNT emitted file(s) verified against the receipt," \ "$AUDITED bundle(s) $EXPECT" } main "$@"