diff --git a/TODO.md b/TODO.md index 6e39305..2758f9d 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,10 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts + `dist/` is a real directory, so a path with a trailing space or a newline can + no longer carry a debug marker past the unlisted-bundle check + ([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)). - 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of falling back to the 100,000 gwei default, and every address comparison in `src/shared/transactions.js` goes through one case-normalising helper so a diff --git a/script/verify-build b/script/verify-build index 6e6bf75..e766efc 100755 --- a/script/verify-build +++ b/script/verify-build @@ -22,6 +22,18 @@ 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" @@ -29,11 +41,20 @@ 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 @@ -58,7 +79,17 @@ has_marker() { # 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 @@ -117,35 +148,66 @@ read_marker() { # 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 file under dist/ is searched, so build.js's filter is the only -# place the assumption lives and this check is what catches it being wrong. +# 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, so two things are enforced -# here rather than assumed: +# 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 -# ending in sort, so the sort is a separate step. +# 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 - _listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$? + 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." - _listing="$(printf '%s\n' "$_listing" | sort)" - while read -r _file; do - [ -n "$_file" ] || continue + _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 @@ -154,9 +216,7 @@ check_unlisted_bundles() { fail "$_file carries a debug marker but is absent from $MANIFEST, so the manifest no longer describes the emitted bundles." fi - done <