test: cover every verify-build failure mode from make check (closes #227) #249

Merged
clawbot merged 1 commits from test/issue-227-verify-build-battery into next 2026-08-12 11:05:09 +02:00
Collaborator

Closes #227.

script/test-verify-build turns the hand battery into a committed target, run from script/check. 18 cases, each asserting the exit status AND a substring of the message — a guard that fails for the wrong reason is a defect this harness catches, and it did (see the break demo below).

Fixture

mktemp -d, holding script/verify-build as a symlink to the real script plus a synthetic dist/. verify-build derives ROOT from dirname "$0"/.., so it runs entirely against the fixture and never reads or writes the repo's build output. The symlink rather than a copy is what makes a break in the live script fail here. TMPDIR is pointed inside the work dir, so verify-build's own mktemp also leaves nothing behind; an EXIT/INT/TERM trap removes the tree, chmod -R u+rwX first because the cases deliberately create unreadable files.

Cases: control, trailing space, embedded newline, dist/ as a symlink, unwalkable subtree, dangling symlink, directory-pointing symlink, symlink to a listed bundle under an unlisted path, missing/empty/unreadable manifest, missing/empty/unreadable listed bundle, unlisted extension, no marker, both markers, wrong marker.

The root/permission problem, and which resolution this is

Neither of the issue's two options verbatim: a strict superset of the second, because a case that is permanently skipped in CI gates nothing, and CI is the only place this runs unattended.

The harness picks a runner for the three permission-dependent cases and then proves the runner in that very run before counting them, with three probes:

  • a mode-644 file must be readable through it, and
  • script/verify-build itself must be readable through it — else the runner is broken or cannot reach the script, and the cases would fail for the wrong reason;
  • a mode-000 file must NOT be readable through it — else permissions are not in force and the cases would pass without testing anything.

Unprivileged: the runner is "run directly" and the probes are about this process. As root: setpriv --reuid=65534 then runuser -u nobody, both present in the pinned node:22-slim CI image, so the cases really run in CI (verified below). Only when no candidate passes all three probes are those cases skipped — and then a banner names each skipped case and the final line reads SKIPPED AND NOT PROVEN instead of passed. A false green is impossible by construction: either permission enforcement was demonstrated against a mode-000 file in this run, or the output says the cases did not run.

Why not a non-root USER in the Dockerfile, the "honest" option: COPY . . and RUN script/bootstrap leave /app and node_modules root-owned, and RUN make build writes dist/, so a non-root USER needs a chown -R /app layer duplicating the entire node_modules tree in the image. That is a CI-container change with a real size cost, and outside this issue. Happy to file it separately if wanted.

Proof both branches behave, in the pinned CI image as uid 0:

$ docker run --rm -v ...:/app:ro -w /app node@sha256:5373f19...43fc36 sh -c 'id -u; script/test-verify-build; ls -A /tmp'
0
Testing script/verify-build failure modes...
  permission cases: enabled (runner: setpriv, proved against a mode-000 file)
  ...
test-verify-build: 18 case(s) passed
EXIT=0
--- residue in /tmp:            <- empty

Same image with setpriv and runuser moved aside:

  SKIP (permissions not in force): manifest unreadable
  ...
################################################################################
## WARNING: 3 PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## PROVE THEM. This process is uid 0, and no runner subject to file
## permissions was available. Tried: setpriv, runuser.
## Under root, chmod 000 stops neither find nor grep, so these cases would
## have passed without testing anything. They were skipped, not counted:
##   - unwalkable subtree under dist/
##   - manifest unreadable
##   - listed bundle unreadable
################################################################################
test-verify-build: 15 case(s) passed, 3 SKIPPED AND NOT PROVEN (see the warning above)

Deliberate break, demonstrated

has_marker's exit-2 branch in script/verify-build changed from fail to return 1 — the historical grep exit-2 conflation. Reverted after capture; the committed script is unmodified.

Testing script/verify-build failure modes...
  permission cases: enabled (runner: direct, proved against a mode-000 file)
  ok: control: untouched dist passes
  ok: unlisted marker-carrying file, trailing space in name
  ok: unlisted marker-carrying file, newline in name
  ok: dist/ replaced by a symlink
  ok: unwalkable subtree under dist/
  FAIL: dangling symlink under dist/
    exit status 0, wanted 1; message did not contain: reading dist/chrome/dangling.js, so the file could not be
    --- verify-build output ---
    Verifying emitted bundles (expecting autistmask-build-debug=off)...
      ok: dist/chrome/src/popup/index.js (autistmask-build-debug=off)
      ok: dist/firefox/src/popup/index.js (autistmask-build-debug=off)
    grep: dist/chrome/dangling.js: No such file or directory
    verify-build: 2 bundle(s) verified autistmask-build-debug=off
    --- end output ---
  FAIL: symlink to a directory under dist/
    exit status 0, wanted 1; message did not contain: reading dist/chrome/link-to-dir, so the file could not be
    --- verify-build output ---
    ...
    grep: dist/chrome/link-to-dir: Is a directory
    verify-build: 2 bundle(s) verified autistmask-build-debug=off
    --- end output ---
  ok: symlink to a listed bundle under an unlisted path
  ok: manifest missing
  ok: manifest empty
  ok: manifest unreadable
  ok: listed bundle missing
  ok: listed bundle empty
  FAIL: listed bundle unreadable
    message did not contain: reading dist/chrome/src/popup/index.js, so the file could not be
    --- verify-build output ---
    Verifying emitted bundles (expecting autistmask-build-debug=off)...
    grep: dist/chrome/src/popup/index.js: Permission denied
    verify-build: FAIL: dist/chrome/src/popup/index.js 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.
    --- end output ---
  ok: unlisted extension carrying a marker
  ok: listed bundle carries no marker
  ok: listed bundle carries both markers
  ok: wrong marker for the requested mode
test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped

Exit 1, so make check fails. Note the third failure: that break leaves the exit status at 1, and only the message assertion catches it — the guard still fails, but it now blames the bundle for a permissions fault. That is exactly the wrong-reason defect the issue asked this harness to catch, and status-only assertions would have passed it.

Verification

  • make check green on this branch rebased onto next at afe6dda: 21 test suites / 443 tests, 18 battery cases, prettier clean.
  • The deliberate break above was re-run against this rebased head: make check exits non-zero, test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped. Restored, green again, git status clean.
  • docker build --no-cache . exit 0 — RUN make check executed uncached, with permission cases: enabled (runner: setpriv, proved against a mode-000 file) and test-verify-build: 18 case(s) passed in the layer log, so the permission cases run rather than skip in CI.
  • No residue: nothing matching autistmask-*/verify-build* left in /tmp, no dist/ created, git status clean after runs.

Runtime cost

0.705s locally, 0.32s in the CI container. Each case rebuilds a 7-file fixture and runs verify-build once; nothing is compiled or installed.

Closes [#227](https://git.eeqj.de/sneak/AutistMask/issues/227). `script/test-verify-build` turns the hand battery into a committed target, run from `script/check`. 18 cases, each asserting the exit status AND a substring of the message — a guard that fails for the wrong reason is a defect this harness catches, and it did (see the break demo below). ## Fixture `mktemp -d`, holding `script/verify-build` as a **symlink to the real script** plus a synthetic `dist/`. `verify-build` derives `ROOT` from `dirname "$0"/..`, so it runs entirely against the fixture and never reads or writes the repo's build output. The symlink rather than a copy is what makes a break in the live script fail here. `TMPDIR` is pointed inside the work dir, so `verify-build`'s own `mktemp` also leaves nothing behind; an `EXIT`/`INT`/`TERM` trap removes the tree, `chmod -R u+rwX` first because the cases deliberately create unreadable files. Cases: control, trailing space, embedded newline, `dist/` as a symlink, unwalkable subtree, dangling symlink, directory-pointing symlink, symlink to a listed bundle under an unlisted path, missing/empty/unreadable manifest, missing/empty/unreadable listed bundle, unlisted extension, no marker, both markers, wrong marker. ## The root/permission problem, and which resolution this is Neither of the issue's two options verbatim: a strict superset of the second, because a case that is permanently skipped in CI gates nothing, and CI is the only place this runs unattended. The harness picks a runner for the three permission-dependent cases and then **proves the runner in that very run before counting them**, with three probes: - a mode-644 file must be readable through it, and - `script/verify-build` itself must be readable through it — else the runner is broken or cannot reach the script, and the cases would fail for the wrong reason; - a mode-000 file must NOT be readable through it — else permissions are not in force and the cases would pass without testing anything. Unprivileged: the runner is "run directly" and the probes are about this process. As root: `setpriv --reuid=65534` then `runuser -u nobody`, both present in the pinned `node:22-slim` CI image, so the cases really run in CI (verified below). Only when no candidate passes all three probes are those cases skipped — and then a banner names each skipped case and the final line reads `SKIPPED AND NOT PROVEN` instead of passed. A false green is impossible by construction: either permission enforcement was demonstrated against a mode-000 file in this run, or the output says the cases did not run. Why not a non-root `USER` in the Dockerfile, the "honest" option: `COPY . .` and `RUN script/bootstrap` leave `/app` and `node_modules` root-owned, and `RUN make build` writes `dist/`, so a non-root `USER` needs a `chown -R /app` layer duplicating the entire `node_modules` tree in the image. That is a CI-container change with a real size cost, and outside this issue. Happy to file it separately if wanted. Proof both branches behave, in the pinned CI image as uid 0: ``` $ docker run --rm -v ...:/app:ro -w /app node@sha256:5373f19...43fc36 sh -c 'id -u; script/test-verify-build; ls -A /tmp' 0 Testing script/verify-build failure modes... permission cases: enabled (runner: setpriv, proved against a mode-000 file) ... test-verify-build: 18 case(s) passed EXIT=0 --- residue in /tmp: <- empty ``` Same image with `setpriv` and `runuser` moved aside: ``` SKIP (permissions not in force): manifest unreadable ... ################################################################################ ## WARNING: 3 PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT ## PROVE THEM. This process is uid 0, and no runner subject to file ## permissions was available. Tried: setpriv, runuser. ## Under root, chmod 000 stops neither find nor grep, so these cases would ## have passed without testing anything. They were skipped, not counted: ## - unwalkable subtree under dist/ ## - manifest unreadable ## - listed bundle unreadable ################################################################################ test-verify-build: 15 case(s) passed, 3 SKIPPED AND NOT PROVEN (see the warning above) ``` ## Deliberate break, demonstrated `has_marker`'s exit-2 branch in `script/verify-build` changed from `fail` to `return 1` — the historical grep exit-2 conflation. Reverted after capture; the committed script is unmodified. ``` Testing script/verify-build failure modes... permission cases: enabled (runner: direct, proved against a mode-000 file) ok: control: untouched dist passes ok: unlisted marker-carrying file, trailing space in name ok: unlisted marker-carrying file, newline in name ok: dist/ replaced by a symlink ok: unwalkable subtree under dist/ FAIL: dangling symlink under dist/ exit status 0, wanted 1; message did not contain: reading dist/chrome/dangling.js, so the file could not be --- verify-build output --- Verifying emitted bundles (expecting autistmask-build-debug=off)... ok: dist/chrome/src/popup/index.js (autistmask-build-debug=off) ok: dist/firefox/src/popup/index.js (autistmask-build-debug=off) grep: dist/chrome/dangling.js: No such file or directory verify-build: 2 bundle(s) verified autistmask-build-debug=off --- end output --- FAIL: symlink to a directory under dist/ exit status 0, wanted 1; message did not contain: reading dist/chrome/link-to-dir, so the file could not be --- verify-build output --- ... grep: dist/chrome/link-to-dir: Is a directory verify-build: 2 bundle(s) verified autistmask-build-debug=off --- end output --- ok: symlink to a listed bundle under an unlisted path ok: manifest missing ok: manifest empty ok: manifest unreadable ok: listed bundle missing ok: listed bundle empty FAIL: listed bundle unreadable message did not contain: reading dist/chrome/src/popup/index.js, so the file could not be --- verify-build output --- Verifying emitted bundles (expecting autistmask-build-debug=off)... grep: dist/chrome/src/popup/index.js: Permission denied verify-build: FAIL: dist/chrome/src/popup/index.js 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. --- end output --- ok: unlisted extension carrying a marker ok: listed bundle carries no marker ok: listed bundle carries both markers ok: wrong marker for the requested mode test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped ``` Exit 1, so `make check` fails. Note the third failure: that break leaves the exit status at 1, and only the message assertion catches it — the guard still fails, but it now blames the bundle for a permissions fault. That is exactly the wrong-reason defect the issue asked this harness to catch, and status-only assertions would have passed it. ## Verification - `make check` green on this branch rebased onto `next` at `afe6dda`: 21 test suites / 443 tests, 18 battery cases, prettier clean. - The deliberate break above was re-run against this rebased head: `make check` exits non-zero, `test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped`. Restored, green again, `git status` clean. - `docker build --no-cache .` exit 0 — `RUN make check` executed uncached, with `permission cases: enabled (runner: setpriv, proved against a mode-000 file)` and `test-verify-build: 18 case(s) passed` in the layer log, so the permission cases run rather than skip in CI. - No residue: nothing matching `autistmask-*`/`verify-build*` left in `/tmp`, no `dist/` created, `git status` clean after runs. ## Runtime cost 0.705s locally, 0.32s in the CI container. Each case rebuilds a 7-file fixture and runs `verify-build` once; nothing is compiled or installed.
clawbot added the needs-review label 2026-08-12 10:27:49 +02:00
clawbot self-assigned this 2026-08-12 10:27:50 +02:00
Author
Collaborator

FAIL — needs-rebase.

1. Does not rebase onto current next (blocking)

origin/next has moved to ce4a0d7; this head (ef349e8) is 1 ahead / 2 behind and conflicts. Gitea's mergeable: true was computed against ba35282 and is stale.

$ git rebase origin/next
Auto-merging README.md
Auto-merging TODO.md
CONFLICT (content): Merge conflict in TODO.md
error: could not apply ef349e8... test: cover every verify-build failure mode (closes #227)

TODO.md: this commit and #230 both insert a bullet at the top of # Completed Steps. Acceptable: rebase onto ce4a0d7, keep both bullets (this one dated 2026-08-12, no landed entry dropped), re-run make check, force-push.

2. Commit message overstates the permission coverage (fix in the same rebase)

The commit body says "before counting the five permission-dependent cases"; the PR body and the plan comment on #227 repeat it. There are three, at script/test-verify-build:339, :366, :378 — grep -c 'yes release' script/test-verify-build returns 3, and the PR's own captured skip output says 3 PERMISSION CASE(S) DID NOT RUN. This lands in permanent history and overstates by 67% the coverage that the runner-proving machinery buys. Acceptable: say "three" in the commit body (and correct the PR body).

Independent break results (the central claim holds)

Eleven guards in script/verify-build broken one at a time in a scratch copy; the battery caught ten, exit 1 each time:

break caught by
find status check dropped unwalkable subtree
walk back to -print + while read trailing space + newline
is_listed newline guard dropped newline in name
manifest-missing returns 0 silently manifest missing
-type l dropped from find 3 symlink cases
[ "$MARKER" = "$expected" ] weakened wrong marker
[ -h dist ] guard dropped dist symlink — message only, status stayed 1
[ -s "$file" ] -> -e listed bundle empty — message only
has_marker exit-2 -> return 1 3 cases, one message-only (reproduces the PR's demo exactly)
[ -r "$MANIFEST" ] -> -e manifest unreadable

Two of my breaks beyond the author's were caught only by the message assertion with the exit status still correct — the wrong-reason defect class the issue asked for, independently confirmed.

Not caught: is_listed's exit-2 branch (script/verify-build:98). Changing it to return 1 leaves all 18 cases green, because main's [ -r "$MANIFEST" ] fires first and no fixture state reaches it. Not on the issue's case list and effectively unreachable while that guard stands — noting it, not blocking on it.

Root/permission resolution: verified by execution, not accepted on the description

In the pinned node:22-slim as uid 0 the cases run, not skip (runner: setpriv), and they are not vacuous under it: with the find-status, -r-manifest and has_marker guards broken simultaneously, all five permission-touching cases fail as root in the container (5 case(s) FAILED, 13 passed, 0 skipped). Forcing the skip path (both helpers moved aside) yields the banner naming each case and 3 SKIPPED AND NOT PROVEN, exit 0 — green-with-banner is the issue's sanctioned option 2, and it is unreachable in the pinned image. A broken environment (unreachable $WORK, no helper) fails the probe and skips loudly rather than false-greening.

Everything else checked and clean

Coverage matches the issue list exactly (18/18, no stubs); all 18 assert a message, only one substring shared between two cases and both fail independently under the line-delimited break; symlink fixture resolves ROOT into the temp tree; no dist/, no /tmp residue, repo tree and script/verify-build mode unchanged after runs; POSIX clean (dash and busybox ash parse and run it, shellcheck -s sh silent); make check 14.0s locally with the battery at 0.78s; script/cibuild rc=0 with RUN make check executed (26.1s, not CACHED) showing 14 suites / 361 tests and 18 case(s) passed; tracker CI green on ef349e8; single commit, author and committer clawbot, title ends (closes #227); one TODO.md bullet, pure addition; README Entrypoints updated; make fmt clean; no scope creep; no attribution trailers; no competitor named.

One portability note, not a defect in this PR: under busybox grep the "symlink to a directory" case fails, because busybox exits 1 on a directory where GNU exits 2 — the exact fragility check_unlisted_bundles' own comment predicts. Pre-existing in verify-build, surfaced rather than caused by this harness, and irrelevant to the GNU-grep CI image.

FAIL — `needs-rebase`. ## 1. Does not rebase onto current `next` (blocking) `origin/next` has moved to `ce4a0d7`; this head (`ef349e8`) is 1 ahead / 2 behind and conflicts. Gitea's `mergeable: true` was computed against `ba35282` and is stale. ``` $ git rebase origin/next Auto-merging README.md Auto-merging TODO.md CONFLICT (content): Merge conflict in TODO.md error: could not apply ef349e8... test: cover every verify-build failure mode (closes #227) ``` `TODO.md`: this commit and [#230](https://git.eeqj.de/sneak/AutistMask/issues/230) both insert a bullet at the top of `# Completed Steps`. Acceptable: rebase onto `ce4a0d7`, keep both bullets (this one dated `2026-08-12`, no landed entry dropped), re-run `make check`, force-push. ## 2. Commit message overstates the permission coverage (fix in the same rebase) The commit body says "before counting **the five** permission-dependent cases"; the PR body and the plan comment on [#227](https://git.eeqj.de/sneak/AutistMask/issues/227#issuecomment-57836) repeat it. There are **three**, at `script/test-verify-build:339`, `:366`, `:378` — `grep -c 'yes release' script/test-verify-build` returns 3, and the PR's own captured skip output says `3 PERMISSION CASE(S) DID NOT RUN`. This lands in permanent history and overstates by 67% the coverage that the runner-proving machinery buys. Acceptable: say "three" in the commit body (and correct the PR body). ## Independent break results (the central claim holds) Eleven guards in `script/verify-build` broken one at a time in a scratch copy; the battery caught **ten**, exit 1 each time: | break | caught by | |---|---| | `find` status check dropped | unwalkable subtree | | walk back to `-print` + `while read` | trailing space + newline | | `is_listed` newline guard dropped | newline in name | | manifest-missing returns 0 silently | manifest missing | | `-type l` dropped from `find` | 3 symlink cases | | `[ "$MARKER" = "$expected" ]` weakened | wrong marker | | `[ -h dist ]` guard dropped | dist symlink — **message only**, status stayed 1 | | `[ -s "$file" ]` -> `-e` | listed bundle empty — **message only** | | `has_marker` exit-2 -> `return 1` | 3 cases, one message-only (reproduces the PR's demo exactly) | | `[ -r "$MANIFEST" ]` -> `-e` | manifest unreadable | Two of my breaks beyond the author's were caught **only** by the message assertion with the exit status still correct — the wrong-reason defect class the issue asked for, independently confirmed. Not caught: `is_listed`'s exit-2 branch (`script/verify-build:98`). Changing it to `return 1` leaves all 18 cases green, because `main`'s `[ -r "$MANIFEST" ]` fires first and no fixture state reaches it. Not on the issue's case list and effectively unreachable while that guard stands — noting it, not blocking on it. ## Root/permission resolution: verified by execution, not accepted on the description In the pinned `node:22-slim` as uid 0 the cases **run**, not skip (`runner: setpriv`), and they are **not vacuous** under it: with the `find`-status, `-r`-manifest and `has_marker` guards broken simultaneously, all five permission-touching cases fail as root in the container (`5 case(s) FAILED, 13 passed, 0 skipped`). Forcing the skip path (both helpers moved aside) yields the banner naming each case and `3 SKIPPED AND NOT PROVEN`, exit 0 — green-with-banner is the issue's sanctioned option 2, and it is unreachable in the pinned image. A broken environment (unreachable `$WORK`, no helper) fails the probe and skips loudly rather than false-greening. ## Everything else checked and clean Coverage matches the issue list exactly (18/18, no stubs); all 18 assert a message, only one substring shared between two cases and both fail independently under the line-delimited break; symlink fixture resolves `ROOT` into the temp tree; no `dist/`, no `/tmp` residue, repo tree and `script/verify-build` mode unchanged after runs; POSIX clean (`dash` and `busybox ash` parse and run it, shellcheck `-s sh` silent); `make check` 14.0s locally with the battery at 0.78s; `script/cibuild` rc=0 with `RUN make check` **executed** (26.1s, not `CACHED`) showing 14 suites / 361 tests and `18 case(s) passed`; tracker CI green on `ef349e8`; single commit, author and committer `clawbot`, title ends ` (closes #227)`; one `TODO.md` bullet, pure addition; README Entrypoints updated; `make fmt` clean; no scope creep; no attribution trailers; no competitor named. One portability note, not a defect in this PR: under **busybox** `grep` the "symlink to a directory" case fails, because busybox exits 1 on a directory where GNU exits 2 — the exact fragility `check_unlisted_bundles`' own comment predicts. Pre-existing in `verify-build`, surfaced rather than caused by this harness, and irrelevant to the GNU-grep CI image.
clawbot added needs-rebase and removed needs-review labels 2026-08-12 10:40:11 +02:00
clawbot force-pushed test/issue-227-verify-build-battery from ef349e8a39 to 07780c3d8c 2026-08-12 11:03:29 +02:00 Compare
clawbot merged commit 78a1cb067e into next 2026-08-12 11:05:09 +02:00
clawbot deleted branch test/issue-227-verify-build-battery 2026-08-12 11:05:09 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#249