fix: close the silent-EOF class at the two host/ gates it also reached (#111)

The first pass fixed #111 where it was reported — confirm() and
uninstall_confirm() in bin/box — and stopped there, while the same defect
sat at two more destructive gates in this repo:

  · host/revoke-user.sh:50 guards 'box revoke --purge', the prompt whose
    own text says "this cannot be undone";
  · host/teardown-host.sh:31 guards a full host teardown.

Both run under 'set -euo pipefail', so an unguarded 'read' returning
non-zero on EOF ends the run before the 'case' that names the abort —
exit 1, in silence, at the moment the script asked. Each now carries the
guard in its own script's wording rather than importing bin/box's die().

The three drill/ prompts are deliberately untouched: they run under
'set -u' only, so EOF falls through to the '*)' arm and already aborts
out loud. install.sh:65 was already guarded.

What keeps the class closed is a repo-wide sweep in test/cli.sh: every
statement-initial 'read' fed from stdin, in any file that turns on
errexit, must carry a '||' guard — 'while read' loops and '<<<'
herestrings excluded, since neither is a prompt. Removing any of the four
guards makes it fail and name the file:line; it flags nothing else across
the tree's fifteen shell files. The absence of exactly this check is why
the host/ pair was missed the first time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-19 21:06:44 +00:00
parent e27ab239f1
commit bb51aeeca9
4 changed files with 67 additions and 3 deletions

View file

@ -103,7 +103,24 @@ which records not just what changed but what each drill run proved.
`script`: they were structurally untested before, because `[ -t 0 ]` `script`: they were structurally untested before, because `[ -t 0 ]`
sends a terminal-less suite to the refusal branch and every existing sends a terminal-less suite to the refusal branch and every existing
check stopped there — which is exactly how this survived four check stopped there — which is exactly how this survived four
releases. releases. Review caught that the first pass fixed the bug where it was
reported and stopped there, while the same defect sat at two more
destructive gates in this repo: `host/revoke-user.sh:50`, the prompt
guarding `box revoke --purge` — the one whose own text says "this
cannot be undone" — and `host/teardown-host.sh:31`, guarding a full
host teardown. Both run under `set -euo pipefail`, both died mute on
EOF with their `aborted` line never reached; both now carry the guard
in their own script's wording. The three `drill/` prompts are
deliberately left alone — they run under `set -u` only, so EOF falls
through to the `*)` arm and already aborts out loud — and
`install.sh:65` was already guarded. What keeps the class closed is a
repo-wide sweep in `test/cli.sh`: every statement-initial `read` fed
from stdin, in any file that turns on errexit, must carry a `||`
guard, with `while read` loops and `<<<` herestrings excluded because
neither is a prompt. The sweep flags all four sites when their guards
are removed and nothing else across the tree's fifteen shell files —
the absence of exactly this check is why the `host/` pair was missed
in the first place.
- **`box restore` asks before it destroys — and the confirmation prompt is - **`box restore` asks before it destroys — and the confirmation prompt is
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
discard user state, and only one of them asked. The table gave `restore` discard user state, and only one of them asked. The table gave `restore`

View file

@ -47,7 +47,11 @@ if [ "$purge" -eq 1 ]; then
if [ -z "${BOX_YES:-}" ]; then if [ -z "${BOX_YES:-}" ]; then
if [ -t 0 ]; then if [ -t 0 ]; then
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project" printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
read -r reply # EOF (Ctrl-D) is an answer too, and it is a refusal. Unguarded, 'read'
# returns non-zero and errexit ends the run right here — before the
# 'case' below can name the abort, so the most destructive prompt box
# has would go silent at the moment it asked (#111).
read -r reply || { echo "box revoke: aborted." >&2; exit 1; }
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
else else
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2 echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2

View file

@ -28,7 +28,9 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
if [ "$yes" -eq 1 ]; then if [ "$yes" -eq 1 ]; then
echo "(confirmed non-interactively: --yes/BOX_YES)" echo "(confirmed non-interactively: --yes/BOX_YES)"
else else
read -rp "Continue? [y/N] " a # EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on
# this line and the 'aborted' below would never print (#111).
read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; }
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
fi fi

View file

@ -938,6 +938,47 @@ else
echo "skip: the interactive confirm answers (no util-linux 'script' here; CI has it)" echo "skip: the interactive confirm answers (no util-linux 'script' here; CI has it)"
fi fi
# --- the sweep: no prompt-shaped 'read' under 'set -e' may go unguarded (#111)
# The pty checks above prove the two 'bin/box' gates. This proves the CLASS,
# repo-wide, and it exists because the class is exactly what the first pass at
# #111 missed: 'host/revoke-user.sh' and 'host/teardown-host.sh' carried the
# identical defect and survived, because nothing here was looking for the shape.
#
# The shape: a 'read' at the start of a statement, fed from the script's own
# stdin (so a human, or an EOF), inside a file that turns on errexit. On EOF
# 'read' returns non-zero and 'set -e' ends the run BEFORE the 'case' that was
# going to name the abort — the tool goes mute at the moment it asked.
#
# What is deliberately NOT flagged, because it is not the shape:
# · 'while IFS= read -r' loops — fed by a redirect at 'done', and a non-zero
# read is how the loop is supposed to end;
# · '<<<' herestring reads — fed from a string, never from a human;
# · files without errexit ('drill/wipe.sh', 'drill/drill.sh',
# 'drill/multiuser.sh' run under 'set -u' only, wipe.sh documents why), where
# EOF simply falls through to the '*)' arm and aborts out loud on its own.
# A guard is any '||' on the read's own line: '|| die', '|| reply=""',
# '|| { echo …; exit 1; }' — the spelling is each script's to choose, the
# guard is not.
eof_guard_sweep() {
local f n line bad=0 files
files="$(cd "$ROOT" && shopt -s globstar && printf '%s\n' bin/* ./**/*.sh | sed 's|^\./||' | sort -u)"
while IFS= read -r f; do
[ -f "$ROOT/$f" ] || continue
grep -qE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*e' "$ROOT/$f" || continue
while IFS=: read -r n line; do
case "$line" in
*'<<<'*) continue ;; # herestring, not a prompt
*'||'*) continue ;; # guarded — the whole point
esac
echo "$f:$n: prompt-shaped 'read' under 'set -e' with no '||' guard:$line"
bad=1
done < <(grep -nE '^[[:space:]]*(IFS=[^[:space:]]+[[:space:]]+)?read([[:space:]]|$)' "$ROOT/$f")
done <<<"$files"
return "$bad"
}
check "no prompt-shaped 'read' under 'set -e' goes unguarded, repo-wide (#111)" \
0 "" eof_guard_sweep
rm -rf "$CSHIM" "$CWORK" rm -rf "$CSHIM" "$CWORK"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------