From 880e95df21eef9d41116918642f8628c5aec5cff Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 23:32:24 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20uninstall=5Fconfirm=20swallows=20Ctr?= =?UTF-8?q?l-D=20=E2=80=94=20the=20abort=20was=20silent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uninstall_confirm() read the operator's answer unguarded: read -r reply case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac bin/rig runs under `set -euo pipefail`, and both call sites (the single-version and the --all confirms) invoke the function as a plain statement — nothing suppresses errexit. Ctrl-D makes `read` return non-zero, so the shell died AT THE READ and the case on the next line was never evaluated: `die "aborted."` could not fire. The operator saw the question, pressed Ctrl-D, and got nothing — no message, exit 1, at exactly the moment the tool had asked whether to delete their install. It failed closed, so nothing was ever wrongly removed; the damage was that rig went silent at the one moment silence is unreadable. The fix is `read -r reply || reply=""` — commands/db.sh:152's spelling for the identical [y/N] confirm one file away. Empty routes through the existing `*)` arm, so EOF aborts through the same path a bare Enter already does: exactly one "aborted." message, no second die to keep in sync. test/cli.sh gains the first drills of the interactive path, which was structurally untested (every existing uninstall check goes through --force or RIG_YES, which is why this survived): `y` and Ctrl-D driven through a real pty via util-linux `script`, guarded by a command -v skip. They assert the MESSAGE, never the exit code — the unfixed code also exits 1, so an exit-code assertion is green against the bug. Mutation-verified: with `|| reply=""` reverted, 403 passed / 1 failed, the single failure being `output missing 'aborted.'`; restored, 404 passed / 0 failed. Refs #68 --- CHANGELOG.md | 15 +++++++++++++++ bin/rig | 7 ++++++- test/cli.sh | 23 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7beda3e..cc80cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,21 @@ on the way to cutting its first release, and this file starts there. cannot reopen quietly — including via a symlinked directory, which `globstar` declines to traverse. +- **Ctrl-D at the `rig uninstall` confirm no longer aborts in silence** + (#68) — `uninstall_confirm`'s `read -r reply` was unguarded. Under + `set -euo pipefail`, and called as a plain statement, EOF made `read` + return non-zero and killed the shell *at the read* — the `case` on the + next line never ran, so `die "aborted."` never fired. The operator saw + the question, pressed Ctrl-D, and got nothing back: no message, just + exit 1, at the exact moment the tool had asked whether to delete their + install. It failed closed (nothing was ever removed), but nothing said + so. Now `read -r reply || reply=""`, so EOF falls through to the `*)` + arm and aborts out loud — the spelling `commands/db.sh` already used for + the same `[y/N]` shape, one file away. `test/cli.sh` gains the first + drills of the interactive path, driving `y` and Ctrl-D through a real + pty (util-linux `script`, skipped where it is absent) and asserting the + MESSAGE rather than the exit code, which the bug also produced. + ## 0.2.0 — 2026-07-19 ### Added diff --git a/bin/rig b/bin/rig index 3de7315..c352918 100755 --- a/bin/rig +++ b/bin/rig @@ -241,7 +241,12 @@ uninstall_confirm() { # $1 = question fi local reply printf 'rig: %s? [y/N] ' "$1" - read -r reply + # `|| reply=""` is load-bearing under `set -e`: Ctrl-D makes read return + # non-zero, and as a plain-statement call site this function would die HERE, + # before the case — the abort would be real but completely silent. Empty + # falls through to the `*)` arm, so EOF aborts with exactly one message, + # the same spelling commands/db.sh uses for the same [y/N] shape. + read -r reply || reply="" case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac } diff --git a/test/cli.sh b/test/cli.sh index 75aea97..8594117 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -1888,6 +1888,29 @@ check "uninstall --all: RIG_YES=1 is consent without a terminal" 0 "uninstalled" check "uninstall --all: ZERO residue — root and symlinks" 0 "" bash -c " [ ! -e '$H2' ] && [ ! -L '$H2' ] && [ ! -e '$B2/rig' ] && [ ! -L '$B2/rig' ]" +# --- the INTERACTIVE confirm, driven through a real pty (#68) --------------- +# uninstall_confirm only reaches its `read` when stdin is a terminal, which is +# why every check above goes through --force or RIG_YES and why the EOF bug +# survived. `script` gives us the terminal. Assert on the MESSAGE, never on the +# exit code: the unfixed `read -r reply` (no `|| reply=""`) dies at the read +# under `set -e` and also exits 1, just silently — an exit-code assertion is +# green against the bug and proves nothing. +if command -v script >/dev/null 2>&1; then + H8="$WORK/h8"; B8="$WORK/b8" + inst "$H8" "$B8" >/dev/null 2>&1 + check "uninstall: Ctrl-D at the confirm prompt ABORTS OUT LOUD (#68)" 1 "aborted." \ + irig bash -c "script -qec \"'$B8/rig' uninstall --all\" /dev/null "$WORK/yes-in" + check "uninstall: 'y' at the confirm prompt goes through" 0 "uninstalled" \ + irig bash -c "script -qec \"'$B8/rig' uninstall --all\" /dev/null < '$WORK/yes-in'" + check "uninstall: ...and that really removed the install" 0 "" \ + bash -c "[ ! -e '$H8' ] && [ ! -e '$B8/rig' ]" +else + echo "skip: interactive uninstall confirm drills (no util-linux script)" +fi + # The last word is a re-check: a survivor must turn into a loud INCOMPLETE, # never a cheerful "uninstalled". (Root ignores file modes, so this drill is # meaningful — and runnable — for a non-root runner only.) -- 2.45.2 From d144ae379cfab862f35d0cb257cc6f714c993fa2 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 09:58:19 +0000 Subject: [PATCH 2/2] test: widen the read-guard sweep to bin/ and to plain-statement reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #43 sweep (test/cli.sh:705) matched the literal `-rsp` spelling and scanned commands/ only. #68 was a plain `read -r reply` in bin/rig, so it missed on BOTH axes — the spelling and the path — and the bug survived until a drill hit it. The class is the shape, not the flags: any `read` run as a plain statement under `set -euo pipefail` kills the shell at EOF, before the `case` that would have printed the abort. The failure is silent and exits 1, which is also what a normal refusal exits, so an exit-code assertion passes against it. The new sweep anchors `read` at the start of a statement across bin/ and commands/, whatever its flags or arity, and subtracts only the two shapes that are safe by construction: a `||` guard (the cure itself) and a `<<<` here-string (which cannot return non-zero). `while`/`if ! ` heads need no subtraction — the anchor already excludes them. Guard against reintroduction, not a live fix: the tree is clean once #68's one-token fix lands. Mutation-verified — a bare `read -r foo` planted in bin/rig gives 404 passed, 1 failed, and the old #43 sweep stays green on the same tree, which is precisely the gap being closed. Co-Authored-By: Claude Opus 4.8 --- test/cli.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/cli.sh b/test/cli.sh index 8594117..ec6c0f3 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -817,6 +817,30 @@ check "runner remove: headless token prompt refuses loudly" 0 "" \ # so a bare `read -rsp` (no `||` on its line) must not exist anywhere. check "prompts: no bare read -rsp remains" 1 "" \ grep -RE 'read -rsp[^|]*$' "$ROOT/commands/" +# ...and the same sweep, widened on the two axes #68 escaped through (#75). +# That check reads `-rsp` literally and scans commands/ only; #68 was a plain +# `read -r reply` in bin/rig, so it missed on the spelling AND on the path. +# The class is the shape, not the flags: any `read` run as a PLAIN STATEMENT +# under `set -euo pipefail` kills the shell at EOF, before the `case` that +# would have printed the abort — silently, with exit 1, indistinguishable +# from a normal refusal. +# +# So: match `read` at the start of a statement (leading whitespace only), +# whatever its flags or arity, then subtract the two shapes that are safe by +# construction: +# `||` — the guard itself (`|| die`, `|| reply=""`, `|| { echo; die … }`). +# An errexit-exempt read, which is the whole cure. +# `<<<` — a here-string always supplies a terminating newline, so the read +# cannot return non-zero. lib/users-config.sh:50/:78 are these. +# `while`/`until`/`if` heads need no subtraction: the anchor already excludes +# them, since `read` is not the first word on those lines. Keep the guard on +# the read's own line — a `\`-continued `||` reads as unguarded here, by +# design, because it is not visible at the point of failure. +unguarded_read() { + grep -REn '^[[:space:]]*read[[:space:]]' "$ROOT/bin/" "$ROOT/commands/" \ + | grep -Ev '\|\||<<<' +} +check "prompts: no unguarded plain-statement read remains (#75)" 1 "" unguarded_read # --- runner install: --repo must agree with what the box is already on ------- # The bug: `install --repo B` on a box registered to repo A skipped configure, -- 2.45.2