Compare commits

...

1 Commits

Author SHA1 Message Date
clawbot
c0a9b58e0c fix: NUL-delimit verify-build's dist walk so no path escapes the check (closes #223)
All checks were successful
check / check (push) Successful in 43s
check_unlisted_bundles read dist/ line by line, so two unlisted paths
carrying a debug marker went unchecked while the script still exited 0:
a name with a trailing space (read stripped it and the remnant matched a
manifest line) and a name containing a newline (find printed it as a
listed path plus an empty one).

The walk is now find -print0 into a temporary listing, checked for find's
exit status as before, and xargs -0 hands the paths back to this script as
arguments, where the same is_listed and has_marker run on them. is_listed
also answers "not listed" for any path containing a newline without asking
grep, which would otherwise read such a path as two patterns and match on
the first half — the escape survives NUL delimiting on its own.

dist/ being a symlink is now its own check with its own message. It failed
before only because GNU grep exits 2 on a directory, so a grep that exits 1
instead would have turned the whole cross-check into a pass.

The comment claiming every file under dist/ is searched now says what is
actually guaranteed: every regular file and every symlink, with the four
properties that coverage rests on stated as enforced rather than assumed.
2026-08-11 13:23:49 +00:00
2 changed files with 100 additions and 13 deletions

View File

@@ -44,6 +44,10 @@ undefined identifiers, which is how
# Completed Steps # 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 - 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 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 `src/shared/transactions.js` goes through one case-normalising helper so a

View File

@@ -22,6 +22,18 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" 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" MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
@@ -29,11 +41,20 @@ MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker. # Set by read_marker.
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() { fail() {
echo "verify-build: FAIL: $*" >&2 echo "verify-build: FAIL: $*" >&2
exit 1 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 # 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 # (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 # 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 # 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 # unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one. # 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() { is_listed() {
case "$1" in
*"$NEWLINE"*) return 1 ;;
esac
_il_status=0 _il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$? grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in case "$_il_status" in
@@ -117,35 +148,66 @@ read_marker() {
# an endsWith(".js") test; repeating that literal here would mean a bundle # an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at # 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 # 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 # avoid. Every regular file and every symlink under dist/ is searched — that
# place the assumption lives and this check is what catches it being wrong. # 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 # That claim only holds if the walk is exhaustive and every name survives it
# here rather than assumed: # 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 # - 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 # stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation # turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline # has_marker exists to prevent. The status cannot be read off a pipeline,
# ending in sort, so the sort is a separate step. # so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle # - 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 # 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 # 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 # 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 # 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. # 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() { check_unlisted_bundles() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success."
_find_status=0 _find_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 ] || [ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree 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 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 unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success." the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
while read -r _file; do _scan_status=0
[ -n "$_file" ] || continue 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 if is_listed "$_file"; then
continue continue
fi fi
@@ -154,9 +216,7 @@ check_unlisted_bundles() {
fail "$_file carries a debug marker but is absent from $MANIFEST, fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles." so the manifest no longer describes the emitted bundles."
fi fi
done <<EOF done
$_listing
EOF
} }
# The requested mode, read from our own environment using build.js's exact # The requested mode, read from our own environment using build.js's exact
@@ -173,9 +233,32 @@ expected_marker() {
main() { main() {
cd "$ROOT" cd "$ROOT"
# Internal re-entry from check_unlisted_bundles' xargs. Not part of the
# command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
scan_dist_paths "$@"
return 0
fi
expected="$(expected_marker)" expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..." echo "Verifying emitted bundles (expecting $expected)..."
# Asserted here rather than left to grep. A symlinked dist/ used to fail
# only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# single entry hit has_marker's I/O path by luck; under a grep that exits 1
# instead, the whole cross-check would have collapsed into a pass.
if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a
symlink named on its own command line, so the unlisted-bundle cross-check
would see one entry instead of the emitted tree and establish nothing about
it. Refusing to report success."
fi
[ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first."
[ -f "$MANIFEST" ] || [ -f "$MANIFEST" ] ||
fail "$MANIFEST is missing. build.js writes it at the end of a fail "$MANIFEST is missing. build.js writes it at the end of a
successful build; run make build first." successful build; run make build first."