build: make verify-build take an explicit expectation and a build receipt (closes #309)
verify-build read its expectation from AUTISTMASK_DEBUG in its own environment and the Makefile invoked it bare, so an operator with that variable exported who ran the release target got an INSECURE debug build — every wallet it creates uses the publicly committed test phrase — verified green, exit 0. It also had no provenance: a 26-byte file containing the right marker string passed, the content script and manifest.json were never inspected, and an entire hand-written dist/ passed. --expect release|debug and --receipt PATH are now both required, with no defaults and nothing read from the environment. build.js records every file it emits with its sha256 and writes the receipt; the Makefile mktemps it outside the repo per invocation with a trap, and build.js refuses a receipt path inside dist/. Verification runs three passes in a load-bearing order — receipt shape, full dist/ walk, then per-file bytes — so an unwalkable subtree cannot make files look absent. dist/constants-bundles.txt, which was an unsigned trust root living inside the tree it vouched for, is gone. What this proves is bounded and stated as such: dist/ is byte-for-byte the output of the build.js run that just finished, within one make build invocation. It proves nothing about the honesty of the source tree or build.js, and nothing to anyone handed a dist/ from elsewhere — that is signing, #310. The standalone make verify-build target is removed because its only input would be dist/ itself, i.e. the artifact vouching for itself. Verified: make check green, test-verify-build 39 cases (was 18), test-e2e 55/55 and test-e2e-firefox 8/8 with make build running uncached inside both images. All four original bypasses now exit 1. Mutations: digests disabled fails exactly 4 cases, dropping the dist/ walk fails exactly 8, restoring the ambient fallback fails exactly 1.
This commit was merged in pull request #330.
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