From a1ad839a8d9c09dadd429821ecc64e56dd708da9 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:29:14 +0200 Subject: [PATCH 1/4] chore(release): bump VERSION to 0.10.2 for next cycle (#431) --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 5712157..5eef0f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.1 +0.10.2 From f3cba3d1b24cdc972db4d38db02ecb0239d190b2 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:32:32 +0200 Subject: [PATCH 2/4] =?UTF-8?q?ci:=20arm=20the=20code-quality=20gate=20?= =?UTF-8?q?=E2=80=94=20soft-fail=20off,=20findings=20block=20(backend#1303?= =?UTF-8?q?)=20(#435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog at zero fleet-wide; the quality contexts are already required on develop. Also adds a workflow_dispatch(all-files) trigger for whole-tree scans (gitleaks baseline). Co-authored-by: Claude Opus 4.8 --- .github/workflows/code-quality-caller.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-quality-caller.yml b/.github/workflows/code-quality-caller.yml index 584c557..eb1e992 100644 --- a/.github/workflows/code-quality-caller.yml +++ b/.github/workflows/code-quality-caller.yml @@ -3,6 +3,14 @@ name: Code quality on: pull_request: types: [opened, reopened, synchronize, ready_for_review] + # Manual whole-tree scan (gitleaks baseline etc.) -- runs every enabled + # job in all-files mode instead of a PR diff. + workflow_dispatch: + inputs: + all-files: + description: "Scan the whole repo, not a diff" + type: boolean + default: true # Supersede the previous run when a branch is pushed again. Measured: # workflows missing this stack ~10-minute duplicate runs per push. @@ -19,4 +27,7 @@ jobs: with: python: true # repos with Python shell: true # repos with shell scripts - # soft-fail: false # flip once the backlog is clear + # The gate is armed: findings fail the job. Backlog cleared to zero + # fleet-wide + advisory soak done (backend#1303). + soft-fail: false + all-files: ${{ inputs.all-files || false }} From 928399abd433aed16c5b153cd6dd0717c7337497 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:34:57 +0200 Subject: [PATCH 3/4] fix(install): own ONE PATH block in the shell profile, never append a second (#433) (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install): own ONE PATH block in the shell profile, never append a second (#433) `scripts/install.sh` guarded its profile append with "does the rc mention $PREFIX", so an install with a DIFFERENT prefix appended another block every time and the profile grew without bound. The v0.10.1 validation box collected TEN blocks, each naming a temp dir that no longer existed. The polluter was our own test harness: scripts/tests/install-verify.sh mints a fresh mktemp --prefix per case and never sandboxed $HOME, so it wrote 3 blocks into the developer's real ~/.bash_profile per invocation. - Tag our block with a stable $PATH_MARKER and REPLACE it on a re-run instead of stacking a second one. A profile already polluted by an older installer collapses to one block on the next install. - Decide "is $PREFIX already handled?" from the rc with our block stripped out, so the answer comes from the user's own lines only — that is what makes the write idempotent. - Compare whole path COMPONENTS, not substrings. `grep -F "$PREFIX"` matched --prefix /opt/tb against an existing /opt/tb2 line and then claimed "already in your PATH config" for a directory that was on nobody's PATH. - Append when there is nothing of ours to clean up; only rewrite the file when a stale block must go. The rewrite truncates in place, so the inode, mode and owner survive and an rc symlinked into a dotfiles repo is written THROUGH rather than replaced by a regular file. - Removal only takes the line under the marker when it is shaped like a PATH op we wrote, so a dangling marker can never eat unrelated user content. - Quote the fish line (`fish_add_path "$PREFIX"`), matching the client installer's hint, so a prefix containing a space survives. - Sandbox $HOME in install-verify.sh and add 7 assertions: same prefix x3 → one block; three different prefixes → one block naming the newest; prefix already on PATH → no rc written at all; unrelated lines preserved byte-for-byte; dangling marker harmless; zsh and fish route to the right rc. A prefix already on $PATH still writes nothing (unchanged), and a $HOME prefix is still persisted even when it looks on-PATH — that hit can be session-only (Bugbot #392 r2), and it now costs at most one line rather than one per run. Fixes tracebloc/cli#433 Co-Authored-By: Claude Fable 5 * fix(install): a same-prefix re-run must not rewrite the rc at all The first pass consolidated correctly but reached the replace path even when our one block already said exactly what this run would write — so a re-install, and every `tracebloc upgrade` (which re-execs this installer), rewrote the user's profile byte-for-byte and reported "Updated the tracebloc PATH entry". Recognise that case and leave the file completely alone: count our blocks, and when there is exactly one whose PATH line already matches, report `present` ("already in your PATH config — nothing to add") without touching the rc. Asserted: re-install with the same prefix leaves the rc byte-identical. Co-Authored-By: Claude Fable 5 * fix(install): restore the rc if the rewrite dies partway The redirection in replace_rc truncates before cat writes, so a write that died halfway (no space left, a vanishing mount) would leave the user's profile in pieces. Put the original contents back on failure — best effort, but far better than a half-written file we don't own. The caller already reports `failed` and prints the line to add by hand. Verified with a read-only rc: the file keeps its user content and its previous block, and the manual-add advice is printed. Co-Authored-By: Claude Fable 5 * fix(install): require positive proof a PATH line is ours before removing it (Bugbot) Bugbot on #434: the marker alone was treated as proof of ownership, so a marker left dangling by a hand-edit directly above the USER's own PATH export would take that export with it on the next install — silently deleting a PATH entry we never added, while still reporting success. Removal now demands positive evidence about the directory the following line names (_tb_owns_dir): * a '$' anywhere -> never ours. We always write a literal, expanded path; "$HOME/mytools" is the user's own idiom. This kills the most realistic form of the bug. * == the prefix we are installing to now -> ours * a directory that no longer exists -> ours (the #433 cruft) * a directory still holding a tracebloc binary -> ours (a prior install) Anything else is the user's line: we drop only the orphaned marker comment and leave their PATH op alone. The bias is deliberate — for an installer editing a file it does not own, failing to clean one line is much cheaper than deleting a PATH entry someone depends on. The residue is bounded: at most one unmarked line per pathological cycle, never renewed growth. The pair-then-verdict pass goes through a temp file rather than a pipe (so the verdicts land in this shell) and rather than a heredoc (so the awk program needs no nested-expansion escaping). 3 new assertions, 23/23 in install-verify.sh: a dangling marker above the user's own PATH export keeps it, likewise with an unexpanded $HOME, and a block naming a vanished directory is still cleaned up. Co-Authored-By: Claude Fable 5 * style(install): state the empty-prefix exit status outright Bugbot read the bare `exit` in the BEGIN rule as exiting 0, i.e. 'prefix already listed', which would skip persistence. It does not: a bare exit in BEGIN still runs END, so the status is END's exit(found ? 0 : 1) = 1, the fail-safe direction. Verified 1 on BSD awk and by the POSIX rule. Making it `exit 1` costs nothing and means the intent cannot be misread by a later reader or a stricter awk, rather than resting on the fallthrough. * fix(install): prove ownership from the marker, and stop mislabelling a failed tidy-up (Bugbot) Two Medium findings, both in the ownership/reporting logic. 1. _tb_owns_dir claimed ANY non-directory as ours, so a dangling marker above the user's own literal-path line for a directory they hadn't created yet was stripped as an owned block — deleting a PATH entry we never wrote, which is not recoverable. The marker we write now records the directory: # Added by the tracebloc CLI installer (prefix: /opt/tracebloc) export PATH="/opt/tracebloc:$PATH" When the recorded prefix and the directory on the line below agree, we provably wrote both halves, so the block is reclaimable even if that directory has since been deleted — which is what keeps the #433 cleanup working, ten blocks and all. $PATH_MARKER stays the stable BEGINNING of the line (matched literally via index(), never as a whole line) so older blocks are still recognised. A legacy marker that recorded nothing can now only be claimed when the directory still holds a tracebloc binary, or is the prefix being installed to. A legacy marker over a vanished directory is indistinguishable from the user's own entry for a directory they plan to create, so we leave it alone. That gives up auto-healing pre-#433 cruft in exactly one case; Lukas's profile was already cleaned by hand, so that is a nice-to-have, whereas deleting someone's PATH line is not. Blocks we cannot claim are now left ENTIRELY intact, comment included, rather than losing their marker: a dead line still labelled "Added by the tracebloc CLI installer" tells the user what it is, a bare one doesn't. 2. A failed replace_rc reported `failed` even when the user's own line already persisted the prefix and all we'd failed to do was drop a redundant block — so the installer told them to hand-edit their profile while their PATH was in fact correct. Split into `tidied`/`tidy_failed` (PATH is right; at most a cosmetic note) versus `failed` (prefix genuinely not persisted; manual instruction warranted), and the write decision now hinges on content equality (rc_same), so a run that changes nothing writes nothing. rc_same deliberately avoids cmp(1) — it lives in diffutils, which a minimal container image can lack, and a missing tool must not silently turn "leave the file alone" into "rewrite it every run". Also restores the `mkdir -p "$(dirname "$rc")"` that the state-machine rewrite dropped; without it fish's ~/.config/fish/config.fish could never be created. The matrix caught it. install-verify.sh: 29/29, with six new cases — legacy marker can't claim a missing dir, nor one without our binary; a recorded-prefix block naming a vanished dir is still cleaned; ten of them collapse to one; an unclaimable legacy block is kept without churning the rc; a failed tidy-up never asks for a manual PATH line, while a real failure still does. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- scripts/install.sh | 323 +++++++++++++++++++++++++++++--- scripts/tests/install-verify.sh | 268 +++++++++++++++++++++++++- 2 files changed, 565 insertions(+), 26 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 5886c61..e1507fe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -489,6 +489,216 @@ on_path=no # means the binary is usable now, so don't nag "open a new terminal". case ":$PATH:" in *":$PREFIX:"*|*":$PREFIX/:"*) on_path=yes ;; esac +# -------------------------------------------------------------------- +# The ONE PATH block this installer owns. +# +# Before #433 the append was guarded only by "does the rc mention $PREFIX", so +# every install with a DIFFERENT prefix appended another block and the profile +# grew without bound: the v0.10.1 validation box collected TEN, each pointing at +# a temp dir that no longer existed. We now tag our block with $PATH_MARKER and +# a re-run REPLACES it instead of stacking a second one. +# +# Keep $PATH_MARKER byte-stable — matching it is exactly what lets a re-run find +# and clean up the blocks older installers left behind. +# -------------------------------------------------------------------- +PATH_MARKER="# Added by the tracebloc CLI installer" + +# The marker line we WRITE also records the directory, so a later run can prove +# it wrote both halves of the block: +# +# # Added by the tracebloc CLI installer (prefix: /opt/tracebloc) +# export PATH="/opt/tracebloc:$PATH" +# +# When the recorded prefix and the directory on the PATH line agree, the block is +# unambiguously ours and can be reclaimed even if that directory has since been +# deleted — which is what keeps the #433 cleanup working. $PATH_MARKER stays the +# stable *beginning* of the line (matched literally, never as a whole line) so +# blocks written by older installers are still recognised. +PATH_MARKER_LINE="$PATH_MARKER (prefix: $PREFIX)" + +# _tb_owns_dir : 0 if the block naming , under a marker that +# recorded ("-" when it recorded nothing), is one we wrote. +# +# The marker alone is NOT proof. A marker left dangling by a hand-edit can end up +# directly above the USER's own PATH line, and taking that line with it would +# silently delete a PATH entry we never added — unrecoverable, and the one outcome +# this whole change exists to avoid (Bugbot on #434). So we require positive +# evidence, in order: +# * a '$' anywhere in the dir -> never ours. We always write a literal, +# already-expanded path; "$HOME/mytools" is the user's own idiom. +# * the dir we are installing to right now -> safe to replace whoever wrote it, +# since we are about to write that very line. +# * the marker recorded this same dir -> we wrote the comment AND the line. +# Existence is irrelevant here: a recorded prefix that has been deleted is +# exactly the stale cruft #433 reported. +# * a legacy marker (recorded nothing) -> the only proof left is a tracebloc +# binary still sitting in the dir. A legacy marker over a VANISHED dir is +# deliberately NOT claimed: it is indistinguishable from the user's own entry +# for a directory they haven't created yet, so we keep their line and accept +# that one pre-#433 dead entry may survive. +_tb_owns_dir() { + _tow_rec="$1" + _tow_dir="$2" + case "$_tow_dir" in + ''|-|*'$'*) return 1 ;; + esac + if [ "$_tow_dir" = "$PREFIX" ]; then return 0; fi + if [ "$_tow_rec" != "-" ] && [ "$_tow_rec" = "$_tow_dir" ]; then return 0; fi + if [ "$_tow_rec" = "-" ] && [ -d "$_tow_dir" ] && [ -e "$_tow_dir/$BINARY_NAME" ]; then return 0; fi + return 1 +} + +# strip_tb_path_block : echo minus every block we can PROVE we wrote +# — the marker line, the PATH op on the line after it, and the blank separator on +# the line before, but only when _tb_owns_dir vouches for the directory named. +# +# A block we cannot claim is left completely intact, comment and all. Two reasons: +# we must never delete a PATH line we didn't write, and a pre-#433 block we can't +# claim is far more useful to the user still labelled "Added by the tracebloc CLI +# installer" (so they can see what it is and delete it) than reduced to a bare, +# unexplained PATH line. Every other line is passed through unchanged and in +# order: this is a file we do NOT own. +strip_tb_path_block() { + _stb_file="$1" + _stb_owned="" + _stb_tab="$(printf '\t')" + + # Emit one record per marker: its line number, the prefix that marker recorded + # ("-" for a legacy marker that recorded none), and the directory named on the + # line below it ("-" when that line isn't a PATH op shaped like one we write). + # Via a temp file rather than a pipe so the ownership verdicts land in THIS + # shell, and rather than a heredoc so the awk program needs no + # nested-expansion escaping. + # + # A marker is any line STARTING with $PATH_MARKER (index(...) == 1 is a + # literal test, so a path full of regex metacharacters can't misfire). + _stb_pairs="$TMP/tb.pairs" + awk -v marker="$PATH_MARKER" ' + function recorded_prefix(line, rest) { + rest = substr(line, length(marker) + 1) + # " (prefix: )" -> ; anything else (including the bare + # legacy marker) records nothing. + if (rest ~ /^ \(prefix: .*\)$/) return substr(rest, 11, length(rest) - 11) + return "-" + } + NR > 1 && prev_is_marker { + dir = "-" + if ($0 ~ /^[[:space:]]*export[[:space:]]+PATH="[^"]+:\$PATH"[[:space:]]*$/) { + dir = $0 + sub(/^[^"]*"/, "", dir) + sub(/:\$PATH".*$/, "", dir) + } else if ($0 ~ /^[[:space:]]*fish_add_path[[:space:]]/) { + dir = $0 + sub(/^[[:space:]]*fish_add_path[[:space:]]+/, "", dir) + sub(/[[:space:]]+$/, "", dir) + gsub(/^["\047]|["\047]$/, "", dir) + } + printf "%d\t%s\t%s\n", NR - 1, prev_rec, dir + } + { + prev_is_marker = (index($0, marker) == 1) + prev_rec = prev_is_marker ? recorded_prefix($0) : "-" + } + END { if (NR >= 1 && prev_is_marker) printf "%d\t%s\t-\n", NR, prev_rec } + ' "$_stb_file" > "$_stb_pairs" + + while IFS="$_stb_tab" read -r _stb_no _stb_rec _stb_dir; do + [ -n "$_stb_no" ] || continue + if _tb_owns_dir "$_stb_rec" "$_stb_dir"; then + _stb_owned="$_stb_owned,$_stb_no" + fi + done < "$_stb_pairs" + + awk -v owned="$_stb_owned" ' + BEGIN { + n = split(owned, a, ","); for (i = 1; i <= n; i++) if (a[i] != "") own[a[i] + 0] = 1 + } + { line[NR] = $0 } + END { + for (i = 1; i <= NR; i++) { + if (!(i in own)) continue + drop[i] = 1 # the marker + drop[i + 1] = 1 # the PATH op under it (vouched for) + if (i > 1 && line[i - 1] == "") drop[i - 1] = 1 # our blank separator + } + for (i = 1; i <= NR; i++) if (!(i in drop)) print line[i] + } + ' "$_stb_file" +} + +# rc_lists_dir : 0 if a non-comment PATH op in already puts +# on PATH. Only an actual PATH op counts — a bare comment or an unrelated +# line that merely mentions the dir must NOT pass, or we'd claim success while a +# new shell still can't find the binary (#61). Handles PATH= / PATH+= / zsh's +# path+=() / fish_add_path. +# +# It compares whole path COMPONENTS. The old test was `grep -F "$PREFIX"`, a +# substring match: installing --prefix /opt/tb after /opt/tb2 matched the +# /opt/tb2 line and reported "already in your PATH config" for a directory that +# was on nobody's PATH (#433). Quotes and trailing slashes are tolerated on +# either side. +rc_lists_dir() { + awk -v want="$1" ' + # exit 1, not a bare exit. A bare exit in BEGIN still runs END, so + # the status would be the END rule below -- 1 when nothing was found -- + # but saying it outright means "no component to look for" can never be + # read as "already listed" by a later reader or a stricter awk. + BEGIN { sub(/\/+$/, "", want); if (want == "") exit 1 } + /^[[:space:]]*#/ { next } + { + n = 0 + if ($0 ~ /(^|[^A-Za-z_])fish_add_path([^A-Za-z_]|$)/) { + for (i = 1; i <= NF; i++) if ($i !~ /^-/ && $i !~ /fish_add_path/) cand[++n] = $i + } else if ($0 ~ /(^|[^A-Za-z_])[Pp][Aa][Tt][Hh][+]?=/) { + value = $0 + sub(/^[^=]*=/, "", value) + n = split(value, cand, ":") + } else { + next + } + for (i = 1; i <= n; i++) { + dir = cand[i] + gsub(/[\047"()]/, "", dir) + sub(/\/+$/, "", dir) + if (dir == want) { found = 1; exit } + } + } + END { exit(found ? 0 : 1) } + ' "$2" +} + +# rc_same : 0 if the two files hold the same text. Used to skip +# the write entirely when the rc already says what we were going to say — +# `tracebloc upgrade` re-execs this installer, so the same prefix comes back +# around on every upgrade and a file we don't own must not be rewritten to no +# effect. +# +# Deliberately not cmp(1): cmp ships in diffutils, which a minimal container +# image can lack, and a missing tool would silently turn "leave the file alone" +# into "rewrite it every run". $(...) strips trailing newlines on both sides +# equally, which is harmless here — a missing block is a far bigger difference +# than a final newline. +rc_same() { + [ "$(cat "$1")" = "$(cat "$2")" ] +} + +# replace_rc : make $rc's contents exactly 's. Truncates the +# existing path rather than mv'ing a temp over it, so the inode, mode and owner +# survive — and so an rc that is a SYMLINK into a dotfiles repo is written +# THROUGH instead of being silently replaced by a regular file. +replace_rc() { + if { cat "$1" > "$rc"; } 2>/dev/null; then + return 0 + fi + # The redirection truncates before cat writes, so a write that dies partway + # (no space left, a vanishing mount) would leave the user's rc in pieces. + # Put the original contents back — best effort, but far better than leaving a + # file we don't own half-written. The caller then reports `failed` and prints + # the line to add by hand. + { cat "$rc_now" > "$rc"; } 2>/dev/null || true + return 1 +} + if [ "$persist" = "yes" ]; then shell_name="$(basename "${SHELL:-sh}")" case "$shell_name" in @@ -503,32 +713,74 @@ if [ "$persist" = "yes" ]; then esac if [ "$shell_name" = "fish" ]; then - path_line="fish_add_path $PREFIX" + # Quoted, like the client installer's fish hint: a --prefix containing a + # space must survive into the rc as one argument. + path_line="fish_add_path \"$PREFIX\"" else path_line="export PATH=\"$PREFIX:\$PATH\"" fi - # Track three outcomes precisely so the message can neither over- nor - # under-claim: already configured / freshly added / couldn't write. - state=failed + # fish's rc lives in ~/.config/fish/, which may not exist yet; the others sit + # directly in $HOME. Create the parent or the write below has nowhere to go. mkdir -p "$(dirname "$rc")" 2>/dev/null || true - # Idempotency: only an actual, non-comment PATH op that references $PREFIX - # counts as "already configured" — a bare comment or an unrelated line that - # merely mentions the dir must NOT pass, or we'd claim success while a new - # shell still can't find the binary (#61). Match PATH= / PATH+= / - # fish_add_path / zsh's path+=() (case-insensitive); the [^A-Za-z_] guard - # keeps PYTHONPATH=/MYPATH= out. - if grep -v '^[[:space:]]*#' "$rc" 2>/dev/null \ - | grep -iE '(^|[^A-Za-z_])path[+]?=|fish_add_path' \ - | grep -qF "$PREFIX"; then - state=present # rc already persists it — leave it alone - # Group the append so the redirection-open error (e.g. a read-only rc, or - # an unwritable parent dir) is suppressed too: `cmd >> "$rc" 2>/dev/null` - # leaks the shell's "Permission denied" because the >> open is attempted - # before 2>/dev/null applies. Wrapping in { ... } 2>/dev/null puts the - # stderr redirect in scope first. - elif { printf '\n# Added by the tracebloc CLI installer\n%s\n' "$path_line" >> "$rc"; } 2>/dev/null; then - state=added + + # Read the rc as it stands (it may not exist yet) and compute what it looks + # like with our block removed. Deciding from the STRIPPED copy is what makes + # this idempotent: whatever we wrote on an earlier run is out of the way, so + # "is $PREFIX already handled?" is answered by the user's own lines only, and + # our block gets rewritten rather than duplicated. + rc_now="$TMP/rc.current" + rc_next="$TMP/rc.next" + rc_want="$TMP/rc.want" + : > "$rc_now" + if [ -f "$rc" ]; then cat "$rc" > "$rc_now" 2>/dev/null || : > "$rc_now"; fi + strip_tb_path_block "$rc_now" > "$rc_next" + + # Build the contents the rc SHOULD have. If a line we don't own already puts + # $PREFIX on PATH we add nothing — the stripped copy is already the answer, + # and any block of ours it removed was redundant. + cat "$rc_next" > "$rc_want" + if rc_lists_dir "$PREFIX" "$rc_next"; then + persisted=yes + else + persisted=no + printf '\n%s\n%s\n' "$PATH_MARKER_LINE" "$path_line" >> "$rc_want" + fi + + # Six outcomes, tracked precisely so the closing message can neither over- nor + # under-claim. The two failure states are distinct on purpose: only one of them + # means the user's PATH is actually wrong (Bugbot on #434). + # present — the rc already says this; nothing written + # added — our block appended to an rc that had none of ours + # replaced — our stale block rewritten to name $PREFIX + # tidied — $PREFIX was already persisted by the user; we removed a + # redundant block of ours + # tidy_failed — as `tidied`, but the rewrite failed. PATH is still correct, + # so this is cosmetic and must NOT ask for a manual edit. + # failed — $PREFIX is not persisted and we could not write. The only + # case that warrants manual instructions. + state=failed + if rc_same "$rc_want" "$rc_now"; then + # Never touch a file we don't own to no effect — the re-install and + # `tracebloc upgrade` path lands here. + state=present + elif rc_same "$rc_next" "$rc_now"; then + # Nothing of ours was stripped, so the only difference is our new block: + # append rather than rewriting the whole file. (Reachable only when + # $persisted is no — otherwise rc_want would equal rc_now above.) + # + # Group the append so the redirection-open error (e.g. a read-only rc, or + # an unwritable parent dir) is suppressed too: `cmd >> "$rc" 2>/dev/null` + # leaks the shell's "Permission denied" because the >> open is attempted + # before 2>/dev/null applies. Wrapping in { ... } 2>/dev/null puts the + # stderr redirect in scope first. + if { printf '\n%s\n%s\n' "$PATH_MARKER_LINE" "$path_line" >> "$rc"; } 2>/dev/null; then + state=added + fi + elif replace_rc "$rc_want"; then + if [ "$persisted" = yes ]; then state=tidied; else state=replaced; fi + elif [ "$persisted" = yes ]; then + state=tidy_failed fi echo "" @@ -544,13 +796,40 @@ if [ "$persist" = "yes" ]; then echo "Open a new terminal — or load it now: . \"$rc\"" fi ;; - present) + replaced) + # An earlier install left a PATH line for a different prefix. We + # updated that one line in place instead of stacking another (#433). + echo "Updated the tracebloc PATH entry in $rc to $PREFIX." + if [ "$on_path" = yes ]; then + echo "tracebloc is ready to use now." + else + echo "Open a new terminal — or load it now: . \"$rc\"" + fi + ;; + present|tidied) + # `tidied` differs only in that we also dropped a redundant block of + # ours; either way the rc already persists $PREFIX, so the user has + # nothing to do. + if [ "$on_path" = yes ]; then + echo "tracebloc is ready to use now ($PREFIX is on your PATH)." + else + echo "$PREFIX is already in your PATH config ($rc) — nothing to add." + echo "If a new terminal can't find it yet, open one — or load it now: . \"$rc\"" + fi + ;; + tidy_failed) + # $PREFIX IS persisted — by a line we don't own — and all we failed to + # do is remove a now-redundant block of ours. Telling the user to add a + # PATH line by hand here would be plain wrong: their PATH is correct. + # Say what actually happened and leave it at that (Bugbot on #434). if [ "$on_path" = yes ]; then echo "tracebloc is ready to use now ($PREFIX is on your PATH)." else echo "$PREFIX is already in your PATH config ($rc) — nothing to add." echo "If a new terminal can't find it yet, open one — or load it now: . \"$rc\"" fi + echo "Note: couldn't remove a leftover tracebloc PATH line from $rc" + echo " (not writable). Harmless — your PATH is already correct." ;; *) # Usable in THIS shell already ($PREFIX on PATH) — say so even though diff --git a/scripts/tests/install-verify.sh b/scripts/tests/install-verify.sh index f499394..1b42ca7 100755 --- a/scripts/tests/install-verify.sh +++ b/scripts/tests/install-verify.sh @@ -32,8 +32,13 @@ _sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{pr # Build a sandbox release + mock bin. Args: COSIGN_PRESENT(yes/no) make_sandbox() { SBX="$(mktemp -d)" - BIN="$SBX/bin"; REL="$SBX/release"; DEST="$SBX/dest" - mkdir -p "$BIN" "$REL" "$DEST" + BIN="$SBX/bin"; REL="$SBX/release"; DEST="$SBX/dest"; HOMEDIR="$SBX/home" + # Every run gets its OWN $HOME. The installer persists a PATH line to the + # shell rc, so without this the harness appends to the *developer's* real + # ~/.bash_profile — once per successful case, with a fresh mktemp --prefix + # each time. That is how the v0.10.1 validation box ended up with ten + # tracebloc PATH blocks, all naming temp dirs that no longer existed (#433). + mkdir -p "$BIN" "$REL" "$DEST" "$HOMEDIR" # The "binary" and its SHA256SUMS, named exactly as resolve_tag/detect_* expect. os="$(uname -s | tr '[:upper:]' '[:lower:]')"; [ "$os" = darwin ] || os=linux @@ -77,8 +82,31 @@ EOF drop_sandbox() { rm -rf "$SBX"; } -# Run installer with PATH=$BIN only (host cosign can't shadow), into $DEST. -run_installer() { PATH="$BIN" "$BIN/bash" "$INSTALLER" --prefix "$DEST" "$@" >"$SBX/out" 2>&1; echo $?; } +# Run installer with PATH=$BIN only (host cosign can't shadow), into $DEST, with +# $HOME pointed at the sandbox so the rc write can never touch real dotfiles. +# FAKE_SHELL selects which rc the installer targets; /bin/sh → $HOME/.profile, +# which is the same answer on Linux and macOS (bash differs between them), so the +# assertions below don't have to branch per platform. +run_installer() { + PATH="$BIN" HOME="$HOMEDIR" SHELL="${FAKE_SHELL:-/bin/sh}" \ + "$BIN/bash" "$INSTALLER" --prefix "$DEST" "$@" >"$SBX/out" 2>&1 + echo $? +} + +# Same, but installs into an explicit prefix (and optionally with that prefix +# pre-seeded onto PATH) so the profile-write behaviour can be exercised across +# several installs that share one $HOME. +run_installer_at() { + local prefix="$1" extra_path="${2:-}" + PATH="$BIN${extra_path:+:$extra_path}" HOME="$HOMEDIR" SHELL="${FAKE_SHELL:-/bin/sh}" \ + "$BIN/bash" "$INSTALLER" --prefix "$prefix" >"$SBX/out" 2>&1 + echo $? +} + +# How many PATH blocks the installer owns in the sandbox rc. Prefix match, not a +# whole-line one: the marker we write now carries a " (prefix: )" suffix, +# while blocks from older installers are the bare comment. +tb_blocks() { grep -c '^# Added by the tracebloc CLI installer' "$1" 2>/dev/null || true; } # ── 1. cosign present + valid signature → installs ────────────────────────── make_sandbox yes @@ -209,6 +237,238 @@ else fi drop_sandbox +# ── 11. the rc PATH block is idempotent and never accumulates (#433) ───────── +# Pre-#433 the append was guarded only by "does the rc mention $PREFIX", so an +# install with a DIFFERENT prefix appended another block every time and the +# profile grew without bound. Assert exactly ONE block whatever the history: +# same prefix repeated, then a run of distinct prefixes. +make_sandbox yes +RC="$HOMEDIR/.profile" +COSIGN_RESULT=0 run_installer >/dev/null +COSIGN_RESULT=0 run_installer >/dev/null +COSIGN_RESULT=0 run_installer >/dev/null +if [ "$(tb_blocks "$RC")" = 1 ]; then + ok "same prefix installed 3x leaves one PATH block" +else + bad "same prefix installed 3x leaves one PATH block (got $(tb_blocks "$RC"))"; sed 's/^/ /' "$RC" +fi + +# …and the repeat runs must not even rewrite the file. `tracebloc upgrade` +# re-execs this installer, so the same prefix comes back on every upgrade; the rc +# is the user's file and an install that changes nothing must touch nothing. +cp "$RC" "$SBX/rc.before" +COSIGN_RESULT=0 run_installer >/dev/null +if diff -q "$RC" "$SBX/rc.before" >/dev/null && grep -q 'already in your PATH config' "$SBX/out"; then + ok "re-install with the same prefix leaves the rc byte-identical" +else + bad "re-install with the same prefix leaves the rc byte-identical"; diff "$SBX/rc.before" "$RC" | sed 's/^/ /' +fi + +# Distinct prefixes — the exact shape that produced ten blocks on the v0.10.1 box. +p1="$SBX/p1"; p2="$SBX/p2"; p3="$SBX/p3" +for p in "$p1" "$p2" "$p3"; do COSIGN_RESULT=0 run_installer_at "$p" >/dev/null; done +if [ "$(tb_blocks "$RC")" = 1 ] && grep -qF "$p3" "$RC" && ! grep -qF "$p1" "$RC"; then + ok "three different prefixes leave one block, naming the newest" +else + bad "three different prefixes leave one block, naming the newest (got $(tb_blocks "$RC"))"; sed 's/^/ /' "$RC" +fi +drop_sandbox + +# ── 12. a prefix already on PATH is not persisted at all ──────────────────── +# Nothing to fix, so the rc must not be created or touched — the common +# /usr/local/bin case used to get a line that changed nothing (#433). +make_sandbox yes +COSIGN_RESULT=0 run_installer_at "$DEST" "$DEST" >/dev/null +if [ ! -e "$HOMEDIR/.profile" ]; then + ok "prefix already on PATH writes no rc at all" +else + bad "prefix already on PATH writes no rc at all"; sed 's/^/ /' "$HOMEDIR/.profile" +fi +drop_sandbox + +# ── 13. the user's own rc content survives byte-for-byte ──────────────────── +# We are editing a file we don't own: replacing our block must not reorder, +# rewrite or drop a single unrelated line. +make_sandbox yes +RC="$HOMEDIR/.profile" +cat > "$RC" <<'PROF' +# my profile +export EDITOR=vim +export PATH="$HOME/mytools:$PATH" + +alias ll='ls -la' +PROF +cp "$RC" "$SBX/rc.orig" +COSIGN_RESULT=0 run_installer_at "$SBX/q1" >/dev/null +COSIGN_RESULT=0 run_installer_at "$SBX/q2" >/dev/null +# Strip our block back out; what remains must equal the original exactly. +grep -vF '# Added by the tracebloc CLI installer' "$RC" | grep -vF "$SBX/q2" | sed '${/^$/d;}' > "$SBX/rc.stripped" +if [ "$(tb_blocks "$RC")" = 1 ] && diff -q "$SBX/rc.stripped" "$SBX/rc.orig" >/dev/null; then + ok "unrelated rc lines preserved across a block replacement" +else + bad "unrelated rc lines preserved across a block replacement"; diff "$SBX/rc.orig" "$SBX/rc.stripped" | sed 's/^/ /' +fi +drop_sandbox + +# ── 14. a marker left dangling above unrelated content can't eat it ───────── +# Removal keys off our marker, so it only takes the next line when that line is +# shaped like a PATH op we wrote — never arbitrary user content. +make_sandbox yes +RC="$HOMEDIR/.profile" +printf '# Added by the tracebloc CLI installer\nalias precious="keep me"\n' > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/r1" >/dev/null +if grep -q 'precious' "$RC"; then + ok "dangling marker doesn't consume the line below it" +else + bad "dangling marker doesn't consume the line below it"; sed 's/^/ /' "$RC" +fi +drop_sandbox + +# The nastier variant: a dangling marker directly above the user's OWN PATH +# export. The marker is not proof of ownership, so the line only goes if we can +# vouch for the directory it names — otherwise we'd silently delete a PATH entry +# we never added (Bugbot on #434). +make_sandbox yes +RC="$HOMEDIR/.profile" +mkdir -p "$SBX/mytools" +printf '# Added by the tracebloc CLI installer\nexport PATH="%s:$PATH"\n' "$SBX/mytools" > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/t1" >/dev/null +if grep -qF "$SBX/mytools" "$RC"; then + ok "dangling marker above the user's own PATH export keeps that export" +else + bad "dangling marker above the user's own PATH export keeps that export"; sed 's/^/ /' "$RC" +fi +# Same, with an unexpanded $HOME — a '$' can never appear in a path we wrote. +printf '# Added by the tracebloc CLI installer\nexport PATH="$HOME/mytools:$PATH"\n' > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/t2" >/dev/null +if grep -qF '$HOME/mytools' "$RC"; then + ok "dangling marker above an unexpanded \$HOME PATH line keeps that line" +else + bad "dangling marker above an unexpanded \$HOME PATH line keeps that line"; sed 's/^/ /' "$RC" +fi +drop_sandbox + +# The nastiest variant of all, and why the marker now records its prefix: a +# dangling marker above the user's own literal-path line for a directory they have +# NOT created yet. "The directory vanished" used to be taken as proof the block was +# ours, which deleted that line (Bugbot on #434). A legacy marker — one that +# recorded no prefix — can no longer claim a missing directory. +make_sandbox yes +RC="$HOMEDIR/.profile" +printf '# Added by the tracebloc CLI installer\nexport PATH="%s/not-created-yet:$PATH"\n' "$SBX" > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/t3" >/dev/null +if grep -qF 'not-created-yet' "$RC"; then + ok "legacy marker can't claim a missing dir — user's line survives" +else + bad "legacy marker can't claim a missing dir — user's line survives"; sed 's/^/ /' "$RC" +fi +# Nor an existing directory that holds no tracebloc binary. +printf '# Added by the tracebloc CLI installer\nexport PATH="%s/mytools:$PATH"\n' "$SBX" > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/t4" >/dev/null +if grep -qF '/mytools:' "$RC"; then + ok "legacy marker can't claim a dir without our binary" +else + bad "legacy marker can't claim a dir without our binary"; sed 's/^/ /' "$RC" +fi +drop_sandbox + +# …but a block whose marker RECORDS the prefix is provably ours, so a vanished +# directory is still reclaimed — that is the #433 cruft, and cleaning it is the +# whole point. This is what a post-fix installer writes. +make_sandbox yes +RC="$HOMEDIR/.profile" +printf '\n# Added by the tracebloc CLI installer (prefix: %s/gone)\nexport PATH="%s/gone:$PATH"\n' "$SBX" "$SBX" > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/t5" >/dev/null +if [ "$(tb_blocks "$RC")" = 1 ] && ! grep -qF '/gone:' "$RC"; then + ok "a recorded-prefix block naming a vanished dir is cleaned up" +else + bad "a recorded-prefix block naming a vanished dir is cleaned up"; sed 's/^/ /' "$RC" +fi +# Ten of them — the shape the v0.10.1 box was in — collapse to one. +: > "$RC" +i=1 +while [ "$i" -le 10 ]; do + printf '\n# Added by the tracebloc CLI installer (prefix: /tmp/dead-%s)\nexport PATH="/tmp/dead-%s:$PATH"\n' "$i" "$i" >> "$RC" + i=$((i+1)) +done +COSIGN_RESULT=0 run_installer_at "$SBX/t6" >/dev/null +if [ "$(tb_blocks "$RC")" = 1 ] && ! grep -qF '/tmp/dead-' "$RC"; then + ok "ten recorded-prefix blocks collapse to one" +else + bad "ten recorded-prefix blocks collapse to one (got $(tb_blocks "$RC"))"; sed 's/^/ /' "$RC" +fi +drop_sandbox + +# An unclaimable legacy block must not cause write CHURN either: we leave it +# alone, add ours once, and every later run with the same prefix is a no-op. +make_sandbox yes +RC="$HOMEDIR/.profile" +mkdir -p "$SBX/legacy" +printf '# Added by the tracebloc CLI installer\nexport PATH="%s/legacy:$PATH"\n' "$SBX" > "$RC" +COSIGN_RESULT=0 run_installer_at "$SBX/t7" >/dev/null +cp "$RC" "$SBX/rc.after1" +COSIGN_RESULT=0 run_installer_at "$SBX/t7" >/dev/null +COSIGN_RESULT=0 run_installer_at "$SBX/t7" >/dev/null +if diff -q "$RC" "$SBX/rc.after1" >/dev/null && grep -qF '/legacy:' "$RC"; then + ok "an unclaimable legacy block is kept without churning the rc" +else + bad "an unclaimable legacy block is kept without churning the rc"; diff "$SBX/rc.after1" "$RC" | sed 's/^/ /' +fi +drop_sandbox + +# Finding 2: when the user's OWN line already persists the prefix and all we fail +# to do is remove a redundant block of ours, the rc is already correct — so the +# installer must NOT tell them to add a PATH line by hand. +make_sandbox yes +RC="$HOMEDIR/.profile" +printf 'export PATH="%s:$PATH"\n\n# Added by the tracebloc CLI installer (prefix: %s)\nexport PATH="%s:$PATH"\n' \ + "$DEST" "$DEST" "$DEST" > "$RC" +chmod 444 "$RC" +COSIGN_RESULT=0 run_installer >/dev/null +chmod 644 "$RC" +if ! grep -q 'Add this line to it' "$SBX/out" \ + && ! grep -q "couldn't update your shell config" "$SBX/out" \ + && grep -q 'already in your PATH config' "$SBX/out" \ + && grep -q "couldn't remove a leftover tracebloc PATH line" "$SBX/out"; then + ok "failed tidy-up of a redundant block never asks for a manual PATH line" +else + bad "failed tidy-up of a redundant block never asks for a manual PATH line"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# …while a genuine failure — prefix NOT persisted anywhere, rc unwritable — must +# still give the manual instruction, because the user's PATH really is wrong. +make_sandbox yes +RC="$HOMEDIR/.profile" +printf '# nothing of ours here\n' > "$RC" +chmod 444 "$RC" +COSIGN_RESULT=0 run_installer >/dev/null +chmod 644 "$RC" +if grep -q "couldn't update your shell config" "$SBX/out" && grep -q 'Add this line to it' "$SBX/out"; then + ok "a real failure to persist still prints the manual instruction" +else + bad "a real failure to persist still prints the manual instruction"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 15. each shell's rc is the one a fresh interactive shell reads ────────── +# zsh → ~/.zshrc, fish → ~/.config/fish/config.fish (and fish gets +# fish_add_path, not a bash `export`), anything else → ~/.profile. Dedupe has to +# hold on whichever file we picked, so re-run and re-assert per shell. +for spec in "zsh:.zshrc:export PATH=" "fish:.config/fish/config.fish:fish_add_path"; do + sh_name="${spec%%:*}"; rest="${spec#*:}"; rc_rel="${rest%%:*}"; want_op="${rest#*:}" + make_sandbox yes + RC="$HOMEDIR/$rc_rel" + FAKE_SHELL="/bin/$sh_name" COSIGN_RESULT=0 run_installer_at "$SBX/s1" >/dev/null + FAKE_SHELL="/bin/$sh_name" COSIGN_RESULT=0 run_installer_at "$SBX/s2" >/dev/null + if [ "$(tb_blocks "$RC")" = 1 ] && grep -qF "$want_op" "$RC" && grep -qF "$SBX/s2" "$RC"; then + ok "$sh_name: one block in $rc_rel using $want_op" + else + bad "$sh_name: one block in $rc_rel using $want_op (got $(tb_blocks "$RC"))"; sed 's/^/ /' "$RC" 2>/dev/null + fi + drop_sandbox +done + echo echo "install-verify: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] From 19f9189c5922de50094f7562e84a0cb48d7237ce Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:59:43 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(release):=20rebuilds=20must=20run=20at?= =?UTF-8?q?=20the=20tag=20ref=20=E2=80=94=20keyless=20identity=20integrity?= =?UTF-8?q?=20(#429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(release): rebuilds must run at the tag ref (Bugbot on promotion #428) Cosign's keyless identity embeds the RUN's ref. workflow_dispatch took an inputs.ref and could build a tag from a branch run, publishing signatures (@refs/heads/...) that the tag-anchored installers reject on every customer machine. inputs.ref removed; dispatch runs now hard-fail unless started at a v* tag ref; rebuild paths = rerun the tag run or dispatch at the tag. Co-Authored-By: Claude Opus 4.8 * review: pre-flight guard job + refs via env (Asad's nits on #429) Guard moved out of the 8-way matrix into a tiny job that release needs: a branch-misdispatch now fails once in seconds instead of burning eight runners' setup. Refs passed via env, never interpolated -- git permits $/backticks in tag names, so a crafted v* tag would otherwise execute on the runner (R8, same rule as the client installer workflows). Co-Authored-By: Claude Opus 4.8 * fix(release): pass the tag ref via env in Determine-release-tag (R8) The one step this PR's hardening missed: it still did REF="${{ github.ref_name }}", interpolating an attacker-controllable tag name straight into the shell, so a crafted v* tag with backticks or $() would execute on the publish runner before the release is created. Now passed as env REF_NAME and read as $REF_NAME, matching the guard and version steps. --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb79a56..0a74be6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,12 +31,12 @@ on: push: tags: - 'v*.*.*' - workflow_dispatch: - inputs: - ref: - description: 'Tag to build (e.g. v0.1.0). Must already exist on origin.' - required: true - type: string + # Rebuilds: dispatch the workflow AT the tag ref (Actions -> Run workflow -> + # pick the v* tag), or gh run rerun a previous tag run. Cosign embeds the + # RUN's ref in the keyless identity; the installers only trust + # @refs/tags/v.*, so a branch-dispatched "rebuild of a tag" would publish + # signatures every customer install rejects (Bugbot on promotion #428). + workflow_dispatch: {} permissions: contents: write # create / update the GitHub Release @@ -47,9 +47,27 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Pre-flight: reject a branch-misdispatch ONCE, in seconds, before the 8-way + # matrix spins up (review nit on #429). Ref passed via env, never inline. + guard: + name: Ref guard + runs-on: ubuntu-latest + env: + REF_FULL: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + steps: + - name: Dispatch rebuilds must run at a v* tag ref + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ] && ! printf '%s' "$REF_FULL" | grep -qE '^refs/tags/v'; then + echo "::error::rebuilds must be dispatched from the v* tag itself (Actions -> Run workflow -> select the tag), not a branch -- signatures would embed $REF_FULL and fail every customer verification." + exit 1 + fi + echo "ref ok: $REF_FULL" + release: timeout-minutes: 20 name: Build + sign + publish + needs: guard runs-on: ubuntu-latest strategy: fail-fast: false @@ -78,7 +96,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ github.ref }} fetch-depth: 0 - name: Set up Go @@ -94,10 +112,14 @@ jobs: - name: Determine release version id: version + env: + # Passed via env, never interpolated into the script: git permits $/ + # backticks in tag names and a crafted v* tag would otherwise execute + # on the runner (R8; same rule as the client installer workflows). + REF_NAME: ${{ github.ref_name }} run: | - # On a tag push: github.ref_name = "v0.1.0" - # On workflow_dispatch: inputs.ref = "v0.1.0" - REF="${{ inputs.ref || github.ref_name }}" + # github.ref_name = "v0.1.0" (tag push, or dispatch AT the tag ref) + REF="$REF_NAME" # Strip the leading v for use in -X main.version VERSION="${REF#v}" # The VERSION file declares the next release (read by the release @@ -186,7 +208,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ github.ref }} - name: Download all matrix artifacts uses: actions/download-artifact@v7 @@ -215,8 +237,15 @@ jobs: - name: Determine release tag id: tag + env: + # Pass the ref through the environment, never interpolate it into the + # script. A tag name is attacker-controllable, so a crafted v* tag + # carrying backticks or $() would otherwise execute on the publish + # runner before the release is even created (R8). Same treatment the + # guard and version steps above already got. + REF_NAME: ${{ github.ref_name }} run: | - REF="${{ inputs.ref || github.ref_name }}" + REF="$REF_NAME" # STRICT stability rule: only a plain vX.Y.Z tag is a stable release. # Anything else (v1.2.3-rc.1, and typos like v1.2.3rc1) is marked # prerelease, so it can never become 'latest' -- which is what the