fix: verify the build against its own receipt, with the expected mode as an argument (closes #309)
script/verify-build computed its expectation from AUTISTMASK_DEBUG in its own environment, and the Makefile invoked it bare, so an operator with that flag exported who ran the release target got a debug bundle -- every wallet it creates carrying the publicly committed test recovery phrase -- verified green at exit 0. The mode is now the required argument --expect release|debug, with no default and nothing read from the environment; make build passes --expect release on an env -u AUTISTMASK_DEBUG environment and make build-debug passes --expect debug. The flag is deliberately still allowed to reach the compiler, so a shell that has it exported fails make build loudly rather than quietly receiving something other than the release build it asked for. The other half was provenance. 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 as "1 bundle(s) verified". build.js now records every file it emits and writes a receipt of them -- path, sha256, and whether the file is one of the bundles containing constants.js -- to a path the Makefile creates with mktemp per invocation, outside the repo, and deletes afterwards; a receipt path inside dist/ is refused. dist/ is cleared before a build, so it holds only what that build wrote. dist/constants-bundles.txt is gone, and with it the standalone make verify-build target: re-verifying a dist/ out of the dist/ itself is the thing that was broken. verify-build now checks the receipt's shape, then that dist/ contains nothing the build did not emit and no symlinks, then each recorded file's bytes against its digest and each audited bundle's marker against --expect. The guarantee is narrow and README.md states it as such: dist/ is byte for byte the output of the build.js run that just finished. It proves nothing about the honesty of the source tree or of build.js, and offers nothing to a third party holding a dist/. That is signing: #310 script/test-verify-build goes from 18 cases to 39, extended in place: one per demonstrated bypass, the missing/invalid argument cases, an AUTISTMASK_DEBUG=1 environment that the verifier must ignore, debug bundles that must fail --expect release, and four checks that read the make build and make build-debug recipes back out of make -n. The existing failure modes (grep exit-2, find's status, newline and trailing-space paths, symlinked dist/, and the root probe that refuses to count permission cases vacuously) are kept. Verified: make check green (39 suites / 811 tests, 39 verify-build cases, permission cases enabled), and green again inside the pinned image via script/cibuild with --no-cache-filter=check, where the harness runs as root and reports the setpriv runner rather than skipping. Non-vacuity proved by mutation: disabling the digest comparison fails exactly the four bypass cases, removing the dist/ walk fails the eight extra-file and symlink cases, restoring the ambient AUTISTMASK_DEBUG fallback fails the no---expect case, breaking the Makefile recipe fails the wiring cases, and dropping manifest.json from the recorded emissions fails a real make build.
This commit is contained in:
@@ -1,45 +1,91 @@
|
||||
#!/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.
|
||||
# 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 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.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# 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_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
|
||||
# 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, for the is_listed guard.
|
||||
# A literal newline and tab, for the receipt-shape guards.
|
||||
NEWLINE='
|
||||
'
|
||||
TAB=' '
|
||||
|
||||
MANIFEST="dist/constants-bundles.txt"
|
||||
MARKER_ON="autistmask-build-debug=on"
|
||||
MARKER_OFF="autistmask-build-debug=off"
|
||||
|
||||
# Set by read_marker.
|
||||
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.
|
||||
@@ -50,11 +96,17 @@ fail() {
|
||||
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
|
||||
@@ -74,34 +126,46 @@ has_marker() {
|
||||
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."
|
||||
# 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
|
||||
@@ -140,40 +204,189 @@ read_marker() {
|
||||
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.
|
||||
# --- the receipt ------------------------------------------------------------
|
||||
|
||||
# Split one "file <sha256> <A|P> <path>" 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.
|
||||
#
|
||||
# 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 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 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.
|
||||
# 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. 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.
|
||||
# - 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
|
||||
@@ -181,7 +394,7 @@ read_marker() {
|
||||
#
|
||||
# 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() {
|
||||
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."
|
||||
@@ -191,105 +404,196 @@ check_unlisted_bundles() {
|
||||
[ "$_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."
|
||||
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" <"$LISTING" || _scan_status=$?
|
||||
xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$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."
|
||||
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."
|
||||
}
|
||||
|
||||
# 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.
|
||||
# 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 is_listed "$_file"; then
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
# 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
|
||||
# --- 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
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
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
|
||||
}
|
||||
|
||||
# Internal re-entry from check_unlisted_bundles' xargs. Not part of the
|
||||
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
|
||||
|
||||
expected="$(expected_marker)"
|
||||
echo "Verifying emitted bundles (expecting $expected)..."
|
||||
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 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.
|
||||
# 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 unlisted-bundle cross-check
|
||||
would see one entry instead of the emitted tree and establish nothing about
|
||||
it. Refusing to report success."
|
||||
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."
|
||||
|
||||
[ -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
|
||||
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."
|
||||
|
||||
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"
|
||||
echo "Verifying emitted files against the build receipt (expecting" \
|
||||
"$EXPECT)..."
|
||||
|
||||
[ "$count" -gt 0 ] || fail "no bundles were inspected."
|
||||
# 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
|
||||
|
||||
check_unlisted_bundles
|
||||
|
||||
echo "verify-build: $count bundle(s) verified $expected"
|
||||
echo "verify-build: $COUNT emitted file(s) verified against the receipt," \
|
||||
"$AUDITED bundle(s) $EXPECT"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user