test: widen the read-guard sweep to bin/ and to plain-statement reads #82
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#82
Loading…
Reference in a new issue
No description provided.
Delete branch "test/read-guard-sweep-bin-rig"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The sweep #43 built to keep unguarded
readprompts out of the tree did notsee the site that produced #68. This widens it on both axes it missed, and
proves the widened net bites. Closes #75.
Stacked on #72. This branch sits on top of
fix/uninstall-confirm-eof(one commit). #75 describes the tree as clean — it will be, once #72 lands,
but on
maintodaybin/rig:240is still the live #68 defect, so this checkis RED against
mainalone. That is not a problem to route around; it is thesweep passing its first real test, on a real bug, before the plant. Merge #72
first and this PR's diff is
test/cli.shonly.The defect class
Any
readrun as a plain statement underset -euo pipefailis a trapdoor. EOF makes
readreturn non-zero, errexit ends the shell at that line,and whatever was going to handle the empty answer — the
case, thedie, theabort message — never runs. What the operator sees is nothing at all, at the
exact moment the tool had asked them a question. The exit code is 1, which is
also what a deliberate refusal exits, so an exit-code assertion is green
against the bug and proves nothing (#72's Ctrl-D check asserts the string
for exactly this reason).
rig has now produced this class twice: #43 (the four hidden
-rsptokenprompts) and #68 (
uninstall_confirm). Both were found by a drill, not by acheck.
Why the existing net missed #68 — on both axes
test/cli.sh:705, from #43:-rsp. #68 wasread -r reply— a visible
[y/N]confirm, no-s, no prompt argument. The patternencodes the flags of the four sites it was written against, not the shape
of the defect.
commands/only. #68 was inbin/rig, which no sweephas ever covered — and
bin/rigis where the confirm gates for theirreversible verbs live.
Either axis alone would have caught it. It missed on both, which is why the
audit clearing the remaining sites currently lives in #72's PR body instead of
in a check that runs.
The check
Match the shape, subtract the cures. The positive half is deliberately
flag-blind and arity-blind:
readas the first word of a statement, whateverfollows. The exclusions are the two shapes that are safe by construction:
||— the guard itself.|| die,|| reply="",|| { echo; die … }all make the
readerrexit-exempt, which is the entire cure. Excluding on||rather than on a specific spelling is what lets rig's three coexistingspellings (#43's,
db.sh's, box#112's) all pass.<<<— a here-string always supplies a terminating newline, so thereadcannot return non-zero.lib/users-config.sh:50and:78are these.while/until/if !heads need no exclusion at all:readis not thefirst word on those lines, so the anchor already drops them.
-non the grepprints file:line, so a failure names the site instead of merely asserting one
exists.
Why not the regex the issue suggested
#75 proposed:
It has no false positives — I checked it against all six legitimate
readshapes in the tree and it stays silent on every one, including the ones #75
lists. But
$-anchoring on a single variable name makes it narrow in the waythat caused this issue in the first place: it encodes a spelling. Run both
against a sample file holding the six legitimate shapes (lines 1-6) and six
defect shapes (lines 7-12):
while IFS= read -r p; doread -r reply || reply=""read -rsp "token: " T || { echo; die "x"; }read -r u r k <<< "$line"IFS=',' read -ra rlist <<< "$r"if ! read -r x; thenread -r reply(the #68 shape)read reply(no-r)read -rp "Continue? " replyread -r a b(two vars)read -r x < "$f"read -t 5 -r x(a flag before-r)Both are perfect on 1-6. The suggested regex catches 1 of the 6 defect shapes;
this one catches 6.
Line 9 is the one I would most expect to be written next:
read -rpis theobvious way to add a visible prompt, it is one character away from #43's
-rsp, and it escapes the old sweep and the proposed one. Line 12 failsthe proposed regex only because
-tprecedes-r— not a distinction anyoneshould have to remember while adding a timeout.
Two deliberate judgment calls inside the exclusions:
&&is not excluded.read -r x && foois errexit-exempt, so it isnot literally this defect — but it handles EOF by silently skipping
foo,which lands the operator in the same place: a question asked, nothing said.
I would rather it be written
|| die, so the sweep asks for that.\-continued guard reads as unguarded.read -r x \with|| dieonthe next line trips this check. Intentional: the guard for a silent failure
should be visible at the point of failure. No such line exists in the tree.
Redirected reads (line 11) are in the net rather than excluded.
read -r x < "$f"carries the identical hazard on an empty file, there is no such sitetoday, and a future one should carry a guard.
Red/green evidence
A guard that has not been shown to fail is not a guard.
Baseline (this branch, unmodified):
405 passed, 0 failed.Bare
read -r fooplanted inbin/rig, in a never-called function so themutation isolates the sweep instead of also starving #72's pty checks of the
answer they type:
Those two adjacent lines are the whole point of the PR: the #43 sweep
reports
okon a tree containing the exact bug it exists to prevent, and thenew one fails on it and names file and line. That is #68, reproduced as a
check.
For completeness I also ran the plant in place — inside
uninstall_confirm,where #68 actually lived:
401 passed, 4 failed, the sweep plus threecollateral failures from #72's pty checks, whose typed answer the planted
readeats. Same verdict, noisier signal; the dead-code plant is the cleanerevidence.
Plant removed:
405 passed, 0 failed.What I left alone
read -rsp …without a||is also a plain-statementread— so it isredundant on today's tree. I kept it anyway: it is cheap, it is the check
#43 is named for, and two failing together vs. one failing alone is
diagnostic (it separates "a token prompt lost its guard" from "a new prompt
shape appeared"). Deleting it would also delete the record of why the narrow
pattern was insufficient.
bin/is swept as a directory, not asbin/rig. #75 suggested thefile. The directory holds only
rigtoday, and CI's shellcheck line alreadyreasons this way (
bin/*, globbed so a new entrypoint is linted withoutanyone remembering to edit a list). A second entrypoint should not have to be
added to this check by hand.
test/cli.shin thiscommit; the
bin/rigchange visible in the diff is #72's, arriving via thestack.
test/sweep. The harness deliberately does not run under errexit(
set -uonly, per its header), so the class does not apply there.Changelog
No entry, deliberately. CONTRIBUTING asks feature PRs to land one under
## Unreleased, and that section becomes the release notes verbatim. Thischanges no observable behavior — an operator reading the 0.2.1 notes gains
nothing from a line about a grep in the test suite, and the behavior it
protects is already described by #68's entry, landing via #72. The reasoning
lives in a comment above the check instead, where the next person to trip it
will be standing. Happy to add one if a reviewer reads the convention more
broadly.
Checks
shellcheck -x— CI's exact block replayed (shopt -s globstar; files=(bin/* **/*.sh)), 22 files, exit 0bash test/cli.sh— 405 passed, 0 failed (#72's base is 404; this addsexactly one check and replaces none)
bash test/release.sh— 68 passed, 0 failedCloses #75
🤖 Generated with Claude Code
Verdict: Approve — I agree with this as-is.
Tip
4e1c774closes the #68 escape on both axes the #43 sweep missed: path (bin/+commands/) and shape (flag-blind plain-statementread). Exclusions are the two safe-by-construction forms (||,<<<); keeping the narrow-rspcheck alongside is diagnostic rather than redundant. Mutation plant isolates the sweep cleanly. No production change. Stacked on #72. CI green.Verdict: I agree with everything and have no additional feedback.
The sweep now covers the escaped path and plain-statement read shape without rejecting guarded or here-string reads, and the CLI suite passes (405/0).
✅ Reviewed — I agree with all of this, no concerns.
First review. Matching the shape and subtracting the cures is the right structure, and both exclusions are genuinely safe-by-construction:
||is the fix, whatever spelling follows it, and a here-string always supplies the terminating newline. I verified the anchor's edges independently:readarraycan't match (readmust be followed by whitespace), comment lines can't match (the anchor requiresreadfirst-non-blank), and loop/if !heads drop out becausereadisn't the statement's first word — so the exclusions really are only two.The two judgment calls inside the exclusions are the strongest part:
&¬ being excluded is correct becauseread -r x && foolands the operator in the same silent place even though it isn't literally errexit death, and the backslash-continued-guard decision (guard must be visible at the failure point) is a defensible line to draw with zero cost today. The comparison table against the issue's proposed regex — 6/6 defect shapes vs 1/6, withread -rpthe escape most likely to be written next — is the kind of evidence that makes a sweep trustworthy, and keeping the #43 check as a diagnostic layer rather than deleting it as redundant preserves the record of why the narrow pattern failed. Dead-code plant isolating the sweep from #72's pty checks was the clean way to run the mutation.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot✅ Approved — I agree with all of this, no concerns.
Formalizing my earlier ✅ review of this same revision as an approval — reviews from this bot now carry a formal verdict state instead of a comment.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRebased onto
mainafter heavy-duty/rig#79, #80 and #83 merged.The conflict was
CHANGELOG.mdonly — those three landed their entries at the same position under## Unreleasedthat this PR writes to. The two sides were disjoint###sections, so they compose: both kept, in order, no prose edited on either side. Every other file rebased clean.Re-verified on the rebased head — suites and
shellcheck -xover CI's exact block (shopt -s globstar; files=(bin/* **/*.sh)) all green.Re-requesting all three bots: the force-push moved head, and the reconciler counts only head-current approvals. Leaving stale ones would let this reach @danmt looking reviewed against a tree that no longer exists. Rebase-only change, so the round should be short.
✅ Approved — I agree with all of this, no concerns.
Re-review of head
e46cca3after the rebase onto main. Still #72's commit (now155b533) plus the widened sweep, and the sweep itself — plain-statementreadanchor overbin/+commands/, subtracting only the||and<<<safe-by-construction shapes — is unchanged from what I approved at4e1c774. The conflict was CHANGELOG-only.Same CI note as #73/#81: this head's
reconcilejob reads as failed but was cancelled by the labels workflow's concurrency group, not a real failure; the substantivecirun is green.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRebased onto the re-rebased #72 (which itself moved when heavy-duty/rig#71 merged).
Done with
git rebase --onto fix/uninstall-confirm-eof <old-#72-head>so only this PR's own commit replays — a plain rebase would have tried to reapply #72's previous commit and conflicted against its rebased twin. No conflicts,test/cli.shwas the only file in play.Stack is intact:
d144ae3(this) on880e95d(#72) onf972a69(main, post-#71). Re-requesting the bots — head moved.Verdict: Approve — I agree with this as-is at head
d144ae3.Re-review after rebase onto main (prior approve was on
4e1c774). Stack is#72(880e95d) + the widened sweep.bin/+commands/, flag-blind plain-statementread, excludes||and<<<.#68(read -rspelling +bin/rigpath).CI: check + db-integration green; reconcile red expected while stacked. No blockers.