From 9762cd3a3c0b12b328d5ba3ee98de9018c136435 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Fri, 21 Aug 2026 15:04:50 -0700 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(install):=20sudo-free=20in?= =?UTF-8?q?stalls=20into=20a=20user-owned=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install location moves to ~/.local/bin, and with it the last routine sudo leaves the tool. A fresh install is now ordinary user file I/O end to end: mkdir, download, install. What /usr/local/bin bought — presence on every default PATH — is replaced by a guarded export written into a sentinel-marked block in the user's rc file: guarded on PATH membership so nested shells don't stack duplicates, guarded on the binary existing so a block that outlives an uninstall is silent, and idempotent across re-installs because the block is rewritten, never duplicated. Without the export an install into ~/.local/bin is unreachable, so this is not optional polish; it is what makes the install an install. The marker text names tapesctl while keeping the shared glyph. Both this script and the Rust remover that follows compare whole lines, so a paperctl block in the same rc file is neither matched nor disturbed — one dotfile can carry both without either installer touching the other's block. Existing users migrate by re-running the installer. When it finds the old root-owned binary it says plainly that this is the last time tapesctl will ever ask for sudo, and removes it — leaving it would fork behavior by context forever, with scripts, CI, and GUI-launched tools silently running the stale binary that default PATHs still find first. Declining is fine: the new install still lands, the manual removal command is printed, and the install exits successfully. The same-directory guard compares canonicalized paths, so a trailing slash or a symlinked spelling of the install dir cannot turn the migration into a delete of the binary this very run installed. A read-only rc file — nix, home-manager, a dotfiles manager — must not fail the install: the binary is already on disk, it just is not on PATH yet. That case prints the line to add by hand, the way Homebrew does, and keeps going. The rc rewrite classifies a file the same three ways the Rust remover does — no block, well-formed, malformed — and refuses to rewrite a malformed one: dropping everything below an orphaned begin marker is worse than leaving the block. Classification is whole-line and byte-exact, so a marker differing by a trailing space or CRLF reads as malformed rather than slipping past a substring guard the stripper cannot match. An absent HOME with an explicit TAPESCTL_INSTALL_DIR degrades to printed PATH instructions — the binary is installed; only the rc convenience is impossible. Absent both is refused before anything is downloaded. The script's whole executable body runs through a main() invoked on the last line. bash executes a piped script incrementally as bytes arrive; without the wrapper, a transfer dying between the old binary's rm and the new one's install left no tapesctl at all. A truncated stream now fails to parse and executes nothing. --- README.md | 21 ++- install.sh | 440 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 441 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7aaaa9f..af9f073 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,26 @@ curl -sSfL https://download.tapes.dev/tapesctl/install | bash Every published artifact carries a `.sha256` sidecar. Where `sha256sum` or `shasum` is available, the installer verifies the download against that sidecar before installing, and a missing sidecar is a hard failure rather than a skipped -check; with neither tool present it warns and installs unverified. Binaries land -in `/usr/local/bin` (via `sudo` only if that directory is not writable). Set -`TAPESCTL_VERSION` to install a specific release or nightly, and +check; with neither tool present it warns and installs unverified. + +The binary lands in `$HOME/.local/bin` — a directory you own, so a normal +install never asks for `sudo`. That directory is not on every default `PATH`, +so the installer also writes a guarded `PATH` export into your shell's rc file +(`.bashrc`, `.zshrc`, or `config.fish`), inside a sentinel-marked block it +rewrites in place rather than duplicating on re-install. If your rc file is +read-only — managed by nix or a dotfiles manager — the install still succeeds +and prints the line to add yourself. + +Set `TAPESCTL_VERSION` to install a specific release or nightly, and `TAPESCTL_INSTALL_DIR` to install somewhere else. +Installs predating this layout put the binary in `/usr/local/bin`. Re-running +the installer migrates them: it removes the old root-owned binary with a single +announced `sudo` — the last one tapesctl will ever ask for — because a binary +left there would shadow the new one in every context that still has the default +`PATH` order. Declining is fine; the install still succeeds and prints the +removal command. + Confirm it landed: ```bash diff --git a/install.sh b/install.sh index 6a6f9b7..74f7f45 100755 --- a/install.sh +++ b/install.sh @@ -1,39 +1,102 @@ #!/bin/bash - -# tapesctl install script for Linux and macOS. +# +# tapesctl install script — downloads a pre-built `tapesctl` binary from +# https://download.tapes.dev and drops it in a user-owned directory. +# This is the "curl | bash" entry point uploaded to the bucket by the +# release pipeline's UploadInstallSh step. +# +# curl -sSfL https://download.tapes.dev/tapesctl/install | bash +# +# Installs to $HOME/.local/bin by default — plain user file I/O end to +# end, no sudo. Because that directory is not on every default PATH, the +# installer writes a guarded PATH export into the detected shell's rc +# file, inside a `[|o=o|]` sentinel block. The one place sudo can still +# appear: when an old root-owned install is found in /usr/local/bin, it +# is removed with a single announced sudo — declining is fine, the +# install still succeeds and the manual removal command is printed. +# # Requirements: # * curl # * uname -# * install -# * sudo (when the install directory is not writable) # * /tmp directory +# * sudo (only to clear an old root-owned install) +# +# Env knobs: +# TAPESCTL_VERSION — "latest" (default), "nightly", or "vX.Y.Z" +# TAPESCTL_INSTALL_DIR — install target (default: $HOME/.local/bin) +# TAPESCTL_BASE_URL — release bucket base URL +# (default: https://download.tapes.dev) +# TAPESCTL_MIGRATE_FROM — old install dir to clear (default: /usr/local/bin) + +set -euo pipefail -set -e +# Everything below runs through main(), defined here and invoked on the very +# last line. bash executes a piped script incrementally, command by command, +# as bytes arrive — without this wrapper a transfer dying between the old +# binary's `rm -f` and the new one's `install` would leave no tapesctl at +# all. A truncated stream now fails to parse the unterminated function and +# executes nothing instead of half an install. +main() { VERSION="${TAPESCTL_VERSION:-latest}" BASE_URL="${TAPESCTL_BASE_URL:-https://download.tapes.dev}" -# Detect OS +# Detect OS — `linux` or `darwin`. Anything else is unsupported; the +# release pipeline only emits these two. OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$OS" in linux*) OS="linux" ;; darwin*) OS="darwin" ;; - *) echo "Unsupported OS: $OS"; exit 1 ;; + *) echo "Unsupported OS: $OS" >&2; exit 1 ;; esac -# Detect architecture +# Detect arch and normalize to the bucket layout names (amd64 / arm64). +# On macOS Apple Silicon `uname -m` returns "arm64"; on Linux ARM hosts +# it is usually "aarch64" (some distros still emit "arm64") — both map to +# the same bucket directory. ARCH="$(uname -m)" case "$ARCH" in - x86_64) ARCH="amd64" ;; + x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; esac -INSTALL_DIR="${TAPESCTL_INSTALL_DIR:-/usr/local/bin}" +# $HOME is dereferenced under `set -u`, and a login shell is not the only +# thing that runs this script — systemd units, minimal containers, and some CI +# runners have no HOME at all. Refuse here, before anything is downloaded, +# rather than aborting after a successful install with an "unbound variable" +# that reads like a bug in the installer. +if [ -z "${TAPESCTL_INSTALL_DIR:-}" ] && [ -z "${HOME:-}" ]; then + echo "Neither TAPESCTL_INSTALL_DIR nor HOME is set; nowhere to install." >&2 + echo "Set one and re-run, e.g. TAPESCTL_INSTALL_DIR=/opt/bin" >&2 + exit 1 +fi +INSTALL_DIR="${TAPESCTL_INSTALL_DIR:-${HOME:-}/.local/bin}" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT DOWNLOAD_URL="$BASE_URL/tapesctl/$VERSION/$OS/$ARCH/tapesctl" +# Pick a sudo prefix only if the install dir isn't user-writable — which +# the $HOME/.local/bin default never is, so a stock install creates its +# directory with a plain mkdir and no privilege at all. The check walks +# to the nearest *existing* ancestor because the target (and its parent) +# may not exist yet; `mkdir -p` succeeds exactly when that ancestor is +# writable. +needs_privilege_for() { + local dir="$1" + while [ ! -d "$dir" ]; do + dir="$(dirname "$dir")" + done + [ ! -w "$dir" ] +} + +SUDO="" +if needs_privilege_for "$INSTALL_DIR"; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi +fi + echo "Downloading tapesctl $VERSION for $OS/$ARCH ..." curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/tapesctl" @@ -61,13 +124,356 @@ if [ -n "$SHA_TOOL" ]; then fi echo "Installing to $INSTALL_DIR ..." -if [ -w "$INSTALL_DIR" ]; then - install -m 0755 "$TMP_DIR/tapesctl" "$INSTALL_DIR/tapesctl" -else - sudo install -m 0755 "$TMP_DIR/tapesctl" "$INSTALL_DIR/tapesctl" -fi +$SUDO mkdir -p "$INSTALL_DIR" +# Unlink before install: writing onto a live binary's inode fails with +# ETXTBSY on Linux while a tapesctl process is running. Removing the old +# entry first makes the install create a fresh inode; the running process +# keeps its old one until it exits. +$SUDO rm -f "$INSTALL_DIR/tapesctl" +$SUDO install -m 0755 "$TMP_DIR/tapesctl" "$INSTALL_DIR/tapesctl" + +############################################################################### +# PATH block — the sentinel-guarded rc-file write +############################################################################### +# +# $HOME/.local/bin is not on the default PATH (never on macOS, sometimes +# on Linux), so without this block the install would be unreachable. +# Everything written to the user's rc file lives between the `[|o=o|]` +# sentinel markers, and the block is rewritten in place on re-install, +# never duplicated. The markers must match the Rust rc-block remover +# byte-for-byte; a test in crates/tapesctl reads this script to pin them +# together. +# +# The marker text names tapesctl. Both this script and the Rust remover +# compare whole lines, so a paperctl block in the same rc file — same +# glyph, different text — is neither matched nor disturbed. + +RC_BLOCK_BEGIN='# > [|o=o|] > tapesctl path > [|o=o|] >' +RC_BLOCK_END='# < [|o=o|] < tapesctl path < [|o=o|] <' + +detect_shell() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + case "$shell_name" in + bash|zsh|fish) echo "$shell_name" ;; + *) return 1 ;; + esac +} + +# The rc file for shell $1, or failure when there is no home directory to +# resolve one under — reachable when TAPESCTL_INSTALL_DIR was given but HOME +# was not. With no HOME there is no rc file to edit either, so the caller +# degrades to printing the PATH line instead of failing the install. +shell_rc_file() { + local shell_name="$1" + [ -n "${HOME:-}" ] || return 1 + case "$shell_name" in + bash) echo "$HOME/.bashrc" ;; + zsh) echo "$HOME/.zshrc" ;; + fish) echo "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;; + *) return 1 ;; + esac +} + +# The PATH lines are double-guarded so the block is inert in every state +# it can be encountered in: the `case ":$PATH:"` membership check makes +# re-sourcing (nested shells, repeated logins) a no-op, and the -x +# binary-existence check keeps a block that outlives an uninstall silent. +posix_path_block_body() { + local dir_expr="$1" + printf '%s\n' \ + "if [ -x \"$dir_expr/tapesctl\" ]; then" \ + " case \":\$PATH:\" in" \ + " *\":$dir_expr:\"*) ;;" \ + " *) export PATH=\"$dir_expr:\$PATH\" ;;" \ + " esac" \ + "fi" +} + +fish_path_block_body() { + local dir_expr="$1" + printf '%s\n' \ + "if test -x \"$dir_expr/tapesctl\"" \ + " if not contains -- \"$dir_expr\" \$PATH" \ + " set -gx PATH \"$dir_expr\" \$PATH" \ + " end" \ + "end" +} + +# Classify rc file $1: `none`, `ok`, or `malformed`. +# +# The three answers the Rust remover gives (`rc_block.rs`), computed the same +# way — exact whole-line comparison against both markers. This has to be its +# own step rather than a `grep` for the begin marker: grep matches substrings, +# while the stripper below matches whole lines, and that disagreement is how a +# marker line carrying a trailing space or a CRLF ending ends up "found" by the +# guard, stripped by nothing, and then appended a second time on every install. +rc_block_state() { + awk -v b="$RC_BLOCK_BEGIN" -v e="$RC_BLOCK_END" ' + $0 == b { seen = 1; inblock = 1; next } + inblock && $0 == e { inblock = 0; next } + END { + if (!seen) { print "none" } + else if (inblock) { print "malformed" } + else { print "ok" } + } + ' "$1" +} + +# Print rc file $1 with the sentinel block (markers included) removed. +# +# Only ever called on a file `rc_block_state` called `ok`. A begin marker with +# no matching end would make this discard every line after it — the caller +# refuses that case rather than rewriting, exactly as the Rust remover does. +rc_block_strip() { + awk -v b="$RC_BLOCK_BEGIN" -v e="$RC_BLOCK_END" ' + $0 == b { inblock = 1; next } + inblock { if ($0 == e) inblock = 0; next } + { print } + ' "$1" +} + +# Write body $2 into the sentinel block of rc file $1: an existing block +# is removed first (rewrite, never a second copy). Lines outside the +# markers are preserved (awk normalizes a missing trailing newline on the +# final line — content is untouched, only that byte can appear). +write_rc_block() { + local rc_file="$1" body="$2" tmp state + mkdir -p "$(dirname "$rc_file")" + touch "$rc_file" + state="$(rc_block_state "$rc_file")" + if [ "$state" = "malformed" ]; then + # A begin marker with no matching end. Rewriting would drop everything + # after it — someone's aliases, exports, and whatever else lives below — + # so the file is left exactly as it is and the user is told what to fix. + # Refusing here is what keeps the promise that this script only ever + # touches the lines between its own markers. + echo + echo "warning: $rc_file has a tapesctl begin marker with no matching end." >&2 + echo "Left it untouched rather than risk dropping what follows it." >&2 + echo "Remove the stray line and re-run, or add this to your shell config:" >&2 + echo >&2 + echo " $(manual_path_line "$3" "$4")" >&2 + return 1 + fi + if [ "$state" = "ok" ]; then + tmp="$rc_file.tapesctl-tmp.$$" + rc_block_strip "$rc_file" >"$tmp" + # cat-over rather than mv: an rc file that is a symlink (dotfiles + # managers) must stay a symlink — the redirection writes through it + # to the target and keeps the file's owner/mode, where mv would + # replace the symlink with a plain file. + cat "$tmp" >"$rc_file" + rm -f "$tmp" + fi + # Separate the block from existing content without stacking blank + # lines across re-installs. + if [ -s "$rc_file" ] && [ -n "$(tail -c 1 "$rc_file")" ]; then + echo >>"$rc_file" + fi + if [ -s "$rc_file" ] && [ -n "$(tail -n 1 "$rc_file")" ]; then + echo >>"$rc_file" + fi + { + printf '%s\n' "$RC_BLOCK_BEGIN" + printf '%s\n' "$body" + printf '%s\n' "$RC_BLOCK_END" + } >>"$rc_file" +} + +# Whether the rc file can be written (or created). A file managed by nix +# or a dotfiles manager is typically a symlink to a read-only store path; +# `-w` follows the symlink and reports the target unwritable, so we can +# fall back to printing manual instructions instead of failing. +rc_file_writable() { + local rc_file="$1" + if [ -e "$rc_file" ] || [ -L "$rc_file" ]; then + [ -w "$rc_file" ] + else + mkdir -p "$(dirname "$rc_file")" 2>/dev/null && [ -w "$(dirname "$rc_file")" ] + fi +} +# One line the user can paste to put $1 on PATH in shell $2. The `$PATH` +# stays literal on purpose — the user pastes the line and their own shell +# expands it. +manual_path_line() { + local dir_expr="$1" shell_name="$2" + # shellcheck disable=SC2016 + case "$shell_name" in + fish) printf 'fish_add_path %s\n' "$dir_expr" ;; + *) printf 'export PATH="%s:$PATH"\n' "$dir_expr" ;; + esac +} + +install_path_block() { + local shell_name rc_file dir_expr body + if ! shell_name="$(detect_shell)"; then + echo "Could not detect a supported shell (bash/zsh/fish)." + echo "Add $INSTALL_DIR to your PATH manually to use tapesctl." + return 0 + fi + # Checked, not bare: under `set -e` an unchecked assignment from a failing + # command substitution aborts the script — and this one fails whenever there + # is no HOME to resolve an rc path under, which is precisely the case that + # must degrade to printed instructions rather than a failed install. + if ! rc_file="$(shell_rc_file "$shell_name")"; then + echo + echo "No home directory, so there is no shell config to update." + echo "To use tapesctl, add $INSTALL_DIR to your PATH:" + echo + echo " $(manual_path_line "$INSTALL_DIR" "$shell_name")" + return 0 + fi + # Keep the block portable when installing to the default location: + # reference $HOME symbolically instead of baking in today's value. + if [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then + # Unexpanded by design: the rc block should track $HOME at source + # time, not bake in today's value. + # shellcheck disable=SC2016 + dir_expr='$HOME/.local/bin' + else + dir_expr="$INSTALL_DIR" + fi + + # A read-only rc file (nix/home-manager, a dotfiles manager) must not + # fail the install: tapesctl is already on disk, it just is not on PATH + # yet. Tell the user exactly what to add, the way Homebrew does, and + # keep going. + if ! rc_file_writable "$rc_file"; then + echo + echo "Could not update $rc_file — it looks read-only (managed by nix or a dotfiles manager?)." + echo "To put tapesctl on your PATH, add this to your shell config:" + echo + echo " $(manual_path_line "$dir_expr" "$shell_name")" + echo + echo "Then restart your shell." + return 0 + fi + + case "$shell_name" in + fish) body="$(fish_path_block_body "$dir_expr")" ;; + *) body="$(posix_path_block_body "$dir_expr")" ;; + esac + if write_rc_block "$rc_file" "$body" "$dir_expr" "$shell_name"; then + echo "Ensured $dir_expr is on PATH via $rc_file" + echo "(restart your shell or source $rc_file to pick it up)" + fi +} + +# Point out any OTHER `tapesctl` on PATH that this install now takes over. +# `~/.local/bin` is prepended, so this install wins once the shell +# reloads; the user should know we quietly shadowed something rather than +# be surprised by a stray `tapesctl` from a different location. `-ef` +# compares by inode, so the just-installed binary (reachable under any +# PATH spelling, e.g. a trailing-slash duplicate) is never flagged as a +# collision. +warn_shadowing_tapesctl() { + local installed="$INSTALL_DIR/tapesctl" dir cand found="" + local IFS=: + for dir in $PATH; do + [ -n "$dir" ] || continue + cand="$dir/tapesctl" + if [ -x "$cand" ] && ! [ "$cand" -ef "$installed" ]; then + case "$found" in + *"$cand"*) ;; + *) found="${found:+$found }$cand" ;; + esac + fi + done + [ -n "$found" ] || return 0 + echo + echo "Note: another 'tapesctl' is already on your PATH:" + local p + for p in $found; do echo " $p"; done + echo "tapesctl installed to $INSTALL_DIR and put it first on your PATH, so" + echo "'tapesctl' runs this build once your shell reloads. The other command" + echo "stays available at its full path." +} + +############################################################################### +# Migration — remove an old root-owned install +############################################################################### +# +# A binary left in /usr/local/bin would shadow the new install in every +# context that still has the default PATH order — scripts, CI, +# GUI-launched tools — forking behavior by context forever. Removing it +# needs root (the directory is root-owned), so this is the one announced +# sudo left in tapesctl; declining degrades to a printed manual command, +# never a failed install. + +# Resolve $1 to its physical path when the directory exists (following +# symlinks, collapsing `..` and duplicate slashes); otherwise just strip +# trailing slashes. Keeps the same-directory guard below a real +# comparison instead of a defeatable string match. +canonical_dir() { + if [ -d "$1" ]; then + (cd "$1" >/dev/null 2>&1 && pwd -P) + else + printf '%s\n' "$1" | sed 's:/*$::' + fi +} + +migrate_old_install() { + local old_dir="${TAPESCTL_MIGRATE_FROM:-/usr/local/bin}" + # Normalized comparison: TAPESCTL_INSTALL_DIR=/usr/local/bin/ (trailing + # slash, symlinked spelling, …) must still be recognized as the old + # directory itself — a string mismatch here would sudo-rm the binary + # this very run just installed. + if [ "$(canonical_dir "$old_dir")" = "$(canonical_dir "$INSTALL_DIR")" ]; then + return 0 + fi + if [ ! -e "$old_dir/tapesctl" ] && [ ! -L "$old_dir/tapesctl" ]; then + return 0 + fi + + echo + echo "Found an old tapesctl install in $old_dir." + + # Only escalate when the directory actually requires it. /usr/local/bin is + # root-owned on stock macOS and most Linux, but Homebrew on Intel macOS owns + # it as the user — prompting for a password there would be exactly the + # gratuitous sudo this whole change set exists to remove. unlink(2) needs + # write permission on the containing directory, not on the file. + if [ -w "$old_dir" ] && rm -f "$old_dir/tapesctl"; then + echo "Removed the old install from $old_dir." + return 0 + fi + + echo "Removing it needs root — this is the last sudo tapesctl will ever request." + if command -v sudo >/dev/null 2>&1 && sudo rm -f "$old_dir/tapesctl"; then + echo "Removed the old install from $old_dir." + else + echo "warning: could not remove the old install. Remove it manually with:" >&2 + echo " sudo rm -f $old_dir/tapesctl" >&2 + fi + return 0 +} + +install_path_block +migrate_old_install + +echo echo "Installed tapesctl:" # `version`, not a bare invocation: bare tapesctl prints help and exits 2, # which under `set -e` would report every successful install as a failure. -"$INSTALL_DIR/tapesctl" version +# +# Advisory, hence `|| true`: the bytes are on disk and checksum-verified by +# now, so a binary that installs correctly but cannot *run* — an older glibc +# than the release was built against, a noexec install dir — must still reach +# the shadow warning and the next-steps below. Aborting here would report a +# successful install as a failure. +"$INSTALL_DIR/tapesctl" version || \ + echo "warning: the installed binary did not run here; see the note above." >&2 +warn_shadowing_tapesctl +echo +cat <<'EOF' +Next: + + tapesctl config set api-url # name your server once + tapesctl start claude # capture your first session + tapesctl upgrade # replace this binary with the newest release +EOF +} + +main "$@" From 2627e90c58597a01767ae89690b4034effe1f5a5 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Fri, 21 Aug 2026 15:11:27 -0700 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(uninstall):=20remove=20the?= =?UTF-8?q?=20binary=20and=20shell=20block=20=E2=80=94=20zero=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer now writes into two places the user did not choose by hand: a directory on their PATH and a sentinel block in their shell rc file. Neither should be something only a hand-edit can take back, so this is the command that takes it back. An install layout is derived, never assumed: canonicalize the running executable (so a PATH invocation, an absolute one, and one through a symlink all resolve to the same place), take its directory, and derive the binary and staging paths from that. A writability probe on the directory is the gate every mutating operation checks first — directory write permission is what unlink and rename actually require on Unix, and file permissions are irrelevant to both. The probe creates a file rather than reading mode bits, so ACLs and read-only mounts are respected too. When the directory is not user-writable — the old root-owned layout — the binary refuses with the installer command and the manual rm. It never escalates. rc files get a surgical primitive: remove exactly the lines between the sentinel markers, byte-preserving everything outside them, and refuse to rewrite at all when a begin marker has no matching end — dropping everything after an orphaned marker is worse than leaving the block. Working on bytes rather than str keeps a latin-1 comment or a stray byte elsewhere in the file undamaged. The markers are the contract that makes touching user dotfiles defensible, so the Rust constants are pinned against the installer by a test that reads it: marker drift is a red build, not a block nothing can ever remove again. Whole-line comparison is also what lets a paperctl block share the file untouched. Uninstall runs state, then the rc block, then its own binary, then exits. Destruction is sequenced so every failure leaves a tool that can retry — the self-unlink is dead last, and it is safe because the inode outlives the running process. Each step warns and continues; a read-only rc file is a warning line, not an abort. Harness-side plugin registrations are deliberately left alone: those live in a config file the harness owns, and `plugin uninstall` is the command that speaks that contract, so the report names it rather than reaching into it. A prompt that reaches EOF counts as a decline. An uninstall that proceeds because nobody was there to say no is the one outcome the prompt exists to prevent. State removal is contained to the cache path this crate derives for itself. An overridden TAPESCTL_CACHE_DIR names a directory the user chose — routinely one holding more than this cache — so it is reported and left in place rather than handed to remove_dir_all. An executable path that will not resolve degrades rather than aborts: the rest of the uninstall proceeds and the report names the manual step. The prompt writes to stderr and enumerates every path it is about to remove, two of which are recursive deletes. The unwritable-directory tests probe their own precondition and skip when mode bits cannot construct it — root writes through 0o555, and containerized CI runs as root. Upgrade residue goes with the binary: the staging file an interrupted upgrade can leave behind and the pipeline lock file are removed best-effort — their sweep otherwise runs only on the next upgrade, and after an uninstall there is not one. --- Cargo.lock | 33 +- Cargo.toml | 20 +- crates/tapesctl/Cargo.toml | 5 +- crates/tapesctl/src/cassette/cache.rs | 38 +- crates/tapesctl/src/cli.rs | 12 + crates/tapesctl/src/error.rs | 11 + crates/tapesctl/src/install_layout.rs | 271 +++++++++++ crates/tapesctl/src/lib.rs | 7 + crates/tapesctl/src/rc_block.rs | 374 +++++++++++++++ crates/tapesctl/src/uninstall.rs | 637 ++++++++++++++++++++++++++ 10 files changed, 1399 insertions(+), 9 deletions(-) create mode 100644 crates/tapesctl/src/install_layout.rs create mode 100644 crates/tapesctl/src/rc_block.rs create mode 100644 crates/tapesctl/src/uninstall.rs diff --git a/Cargo.lock b/Cargo.lock index 6b5379b..93eec3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,7 +165,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -231,7 +231,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -931,6 +931,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1170,6 +1179,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1898,6 +1916,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "versions", "wiremock", "zstd", ] @@ -2366,6 +2385,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "versions" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a7e511ce1795821207a837b7b1c8d8aca0c648810966ad200446ae58f6667f" +dependencies = [ + "itertools 0.14.0", + "nom 8.0.0", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 6d386fc..4357be3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -199,12 +199,24 @@ dirs = "5" time = { version = "0.3", features = ["formatting", "parsing", "serde", "macros"] } uuid = { version = "1", features = ["v4", "serde"] } -# --- test --- -# Recomputing the vendored fixture corpora's DIGEST seals, which is how a stale -# or hand-edited vendored copy fails in this repo's CI rather than silently -# testing cases nobody upstream still has. Test-only: nothing shipped hashes. +# Two callers, one of them shipped: recomputing the vendored fixture corpora's +# DIGEST seals (how a stale or hand-edited vendored copy fails in this repo's CI +# rather than silently testing cases nobody upstream still has), and verifying a +# downloaded artifact against its published `.sha256` in `tapesctl upgrade`. sha2 = "0.10" +# Also shipped: `upgrade` and `uninstall` probe install-directory writability by +# creating a file there, which respects ACLs and read-only mounts where a +# mode-bit inspection would not. tempfile = "3" + +# Version comparison for `tapesctl upgrade`, and deliberately not `semver`: +# release labels here are compared, not validated, and `versions` accepts the +# version-like-but-not-SemVer forms a release process can produce while still +# ordering them sensibly. It also ignores build metadata for equality, which is +# what lets a stamped `v0.7.0+3f2a1b9` compare equal to the bucket's `v0.7.0`. +versions = "7" + +# --- test --- test-case = "3" wiremock = "0.6" diff --git a/crates/tapesctl/Cargo.toml b/crates/tapesctl/Cargo.toml index c9d5801..09730c1 100644 --- a/crates/tapesctl/Cargo.toml +++ b/crates/tapesctl/Cargo.toml @@ -36,7 +36,9 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } +sha2 = { workspace = true } snafu = { workspace = true } +tempfile = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } toml = { workspace = true } @@ -45,12 +47,11 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } +versions = { workspace = true } zstd = { workspace = true } [dev-dependencies] -sha2 = { workspace = true } test-case = { workspace = true } -tempfile = { workspace = true } wiremock = { workspace = true } [lints] diff --git a/crates/tapesctl/src/cassette/cache.rs b/crates/tapesctl/src/cassette/cache.rs index c75f819..8a78324 100644 --- a/crates/tapesctl/src/cassette/cache.rs +++ b/crates/tapesctl/src/cassette/cache.rs @@ -33,16 +33,52 @@ pub const REVALIDATE_AFTER: Duration = Duration::from_secs(600); /// location in CI. pub const CACHE_DIR_ENV: &str = "TAPESCTL_CACHE_DIR"; +/// Directory name under the platform cache dir, when no override is set. +const APP_DIR_NAME: &str = "tapesctl/cassettes"; + /// tapesctl's cache parameterization for one base URL. fn config(key: &str) -> CacheConfig<'_> { CacheConfig { - app_dir_name: "tapesctl/cassettes", + app_dir_name: APP_DIR_NAME, env_override_var: CACHE_DIR_ENV, revalidate_after: REVALIDATE_AFTER, key, } } +/// The directory this crate *chose* for cached surfaces — the platform cache +/// directory plus [`APP_DIR_NAME`] — ignoring [`CACHE_DIR_ENV`] entirely. +/// `None` when the platform names no cache directory. +/// +/// The override is deliberately not honored here, and this is the only reason +/// the function exists separately from the resolution +/// [`tapes_client::cassettes::cache`] performs internally: the one caller is +/// `uninstall`, which passes what it gets to `remove_dir_all`. An override +/// names a directory the *user* picked, which may hold anything — +/// `TAPESCTL_CACHE_DIR=$HOME` would turn an uninstall into a recursive delete +/// of the home directory. A path this crate derived itself is one it owns and +/// may remove; a path the environment supplied is not. +/// +/// Callers that want the location actually in use want +/// [`tapes_client::cassettes::cache`] via [`read`]/[`write`], not this. +#[must_use] +pub fn owned_cache_dir() -> Option { + Some(dirs::cache_dir()?.join(APP_DIR_NAME)) +} + +/// The override's value, when it is set to something non-empty. +/// +/// `uninstall` names it in its report rather than deleting it: leaving a cache +/// behind is a nuisance, deleting a directory the user pointed us at is not +/// recoverable. +#[must_use] +pub fn cache_dir_override() -> Option { + std::env::var(CACHE_DIR_ENV) + .ok() + .filter(|raw| !raw.trim().is_empty()) + .map(std::path::PathBuf::from) +} + /// Read the cached surface for a base URL, if there is a usable one. #[must_use] pub fn read(base: &str) -> Option { diff --git a/crates/tapesctl/src/cli.rs b/crates/tapesctl/src/cli.rs index 343af57..ce8ce2b 100644 --- a/crates/tapesctl/src/cli.rs +++ b/crates/tapesctl/src/cli.rs @@ -337,6 +337,18 @@ pub enum Command { #[command(subcommand)] Config(ConfigCommand), + /// Remove tapesctl: the binary, this tool's local state, and the PATH + /// block the installer wrote into your shell's rc file. + /// + /// Harness-side capture plugins are left alone — a plugin registration + /// lives in the harness's own config file, and `tapesctl plugin uninstall` + /// is the command that speaks that contract. + Uninstall { + /// Skip the interactive confirmation prompt. + #[arg(short = 'y', long = "yes")] + assume_yes: bool, + }, + /// Print version information. Version, } diff --git a/crates/tapesctl/src/error.rs b/crates/tapesctl/src/error.rs index 92ab39d..636ad85 100644 --- a/crates/tapesctl/src/error.rs +++ b/crates/tapesctl/src/error.rs @@ -775,6 +775,17 @@ pub enum Error { /// Underlying IO failure. source: std::io::Error, }, + + /// `tapesctl uninstall` could not run. + /// + /// Only the steps that make the command impossible to start reach here; + /// every individual removal warns and continues, so a partial teardown is + /// a report, not an error. + #[snafu(display("uninstall failed"))] + Uninstall { + /// Underlying uninstall failure. + source: crate::uninstall::UninstallError, + }, } /// Map the shared client's errors onto the variants this CLI surfaced when the diff --git a/crates/tapesctl/src/install_layout.rs b/crates/tapesctl/src/install_layout.rs new file mode 100644 index 0000000..fef9209 --- /dev/null +++ b/crates/tapesctl/src/install_layout.rs @@ -0,0 +1,271 @@ +//! Resolves the install layout from the running executable's real location. +//! +//! [`InstallLayout`] canonicalizes `std::env::current_exe()` so a PATH +//! invocation, an absolute one, and one through a symlink all collapse to the +//! directory the real binary lives in. `upgrade` and `uninstall` both derive +//! every install path from this one type, so the two commands can never +//! disagree about where "the install" is — and no runtime code needs to +//! hardcode an install directory. + +use std::path::{Path, PathBuf}; + +use snafu::{OptionExt, ResultExt, Snafu}; + +/// Basename of the staged replacement binary written by `tapesctl upgrade`. +/// +/// Dotted so a crashed run cannot leave a PATH-visible half-binary behind. +const STAGING_FILE_NAME: &str = ".tapesctl.new"; + +/// Every install path upgrade and uninstall touch, derived from one +/// canonicalized executable path. +/// +/// Constructed, never assumed: the paths describe the directory the running +/// binary actually resides in — not a guessed install prefix — so operations +/// act on the binary the user is really running. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallLayout { + /// Directory containing the canonicalized binary. + install_dir: PathBuf, + /// The canonicalized `tapesctl` binary itself. + tapesctl_path: PathBuf, + /// Staging file for upgrade downloads. Lives in `install_dir` so the final + /// rename over `tapesctl_path` stays on a single filesystem. + staging_path: PathBuf, +} + +impl InstallLayout { + /// Resolve the layout from `std::env::current_exe()`. + pub fn from_current_exe() -> Result { + use install_layout_error::*; + + let exe = std::env::current_exe().context(CurrentExeSnafu)?; + Self::from_exe_path(exe) + } + + /// Resolve the layout from an explicit executable path. + /// + /// Canonicalizes `exe` (following symlinks, so an invocation through one + /// lands on the real file) and derives every other path from the result. + /// Test-friendly entry point: production code goes through + /// [`InstallLayout::from_current_exe`]. + pub fn from_exe_path(exe: impl AsRef) -> Result { + use install_layout_error::*; + + let exe = exe.as_ref(); + let tapesctl_path = exe + .canonicalize() + .context(CanonicalizeSnafu { path: exe })?; + let install_dir = + tapesctl_path + .parent() + .map(Path::to_path_buf) + .context(NoInstallDirSnafu { + path: &tapesctl_path, + })?; + Ok(Self { + staging_path: install_dir.join(STAGING_FILE_NAME), + tapesctl_path, + install_dir, + }) + } + + /// Directory containing the real binary. + #[must_use] + pub fn install_dir(&self) -> &Path { + &self.install_dir + } + + /// The canonicalized `tapesctl` binary path. + #[must_use] + pub fn tapesctl_path(&self) -> &Path { + &self.tapesctl_path + } + + /// The upgrade staging file (`.tapesctl.new`) inside the install directory. + #[must_use] + pub fn staging_path(&self) -> &Path { + &self.staging_path + } + + /// Probe whether the invoking user can mutate the install directory. + /// + /// `unlink(2)` and `rename(2)` require write permission on the *containing + /// directory*, so this one check gates both upgrade's swap and uninstall's + /// binary removal. No escalation is ever attempted; on failure the caller + /// prints remediation (the installer command) and exits nonzero. + pub fn ensure_writable(&self) -> Result<(), EnsureWritableError> { + use ensure_writable_error::*; + + // Creating (and immediately dropping, which deletes) a uniquely named + // file exercises exactly the directory-write permission that unlink and + // rename need — unlike a mode-bit inspection, it also respects ACLs and + // read-only mounts. + match tempfile::Builder::new() + .prefix(".tapesctl-writable-probe.") + .tempfile_in(&self.install_dir) + { + Ok(_probe) => Ok(()), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem + ) => + { + NotWritableSnafu { + dir: &self.install_dir, + } + .fail() + } + Err(source) => Err(source).context(ProbeSnafu { + dir: &self.install_dir, + }), + } + } +} + +/// Failure modes for constructing an [`InstallLayout`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum InstallLayoutError { + /// `std::env::current_exe()` itself failed (exe deleted mid-run, procfs + /// unavailable). + #[snafu(display("could not resolve the current executable path"))] + CurrentExe { + /// Underlying I/O failure. + source: std::io::Error, + }, + /// Canonicalizing the executable path failed (dangling symlink, unreadable + /// path component). + #[snafu(display("could not canonicalize executable path '{}'", path.display()))] + Canonicalize { + /// The path we tried to canonicalize. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + /// The canonicalized executable has no containing directory (`/`). + #[snafu(display("executable path '{}' has no containing directory", path.display()))] + NoInstallDir { + /// The canonicalized executable path. + path: PathBuf, + }, +} + +/// Failure modes for [`InstallLayout::ensure_writable`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum EnsureWritableError { + /// The install directory rejects mutation by the invoking user — the + /// unmigrated root-owned-install state. + #[snafu(display("install directory '{}' is not writable", dir.display()))] + NotWritable { + /// The unwritable install directory. + dir: PathBuf, + }, + /// Probing the directory failed outright (an I/O error distinct from a + /// clean permission refusal). + #[snafu(display("could not probe install directory '{}'", dir.display()))] + Probe { + /// The directory being probed. + dir: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn install_layout_canonicalizes_a_symlinked_invocation() { + // Given a real binary with a symlink pointing at it — the shape a + // shim, or a package manager's bin directory, produces + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + let link = dir.path().join("tapesctl-link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // When the layout is built from the symlink path + let layout = InstallLayout::from_exe_path(&link).unwrap(); + + // Then every path resolves to the real file's directory + // (canonicalized, so the tempdir's own symlinkness — /var → + // /private/var on macOS — collapses too) + let real_dir = dir.path().canonicalize().unwrap(); + assert_eq!(layout.install_dir(), real_dir); + assert_eq!(layout.tapesctl_path(), real_dir.join("tapesctl")); + assert_eq!(layout.staging_path(), real_dir.join(STAGING_FILE_NAME)); + } + + #[test] + fn the_staging_file_shares_the_install_directory() { + // The invariant the whole crash-safe swap rests on: `rename(2)` is + // only atomic within one filesystem, so the staged file must be a + // sibling of the binary it will replace — never in /tmp. + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + + let layout = InstallLayout::from_exe_path(&real).unwrap(); + + assert_eq!(layout.staging_path().parent(), Some(layout.install_dir())); + } + + #[test] + fn a_writable_directory_passes_the_probe_and_leaves_nothing_behind() { + // Given an ordinary user-owned install directory + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&real).unwrap(); + + // When writability is probed + assert!(layout.ensure_writable().is_ok()); + + // Then the probe file is gone — only the binary remains + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("tapesctl")]); + } + + #[test] + fn an_unwritable_directory_refuses_by_name() { + use std::os::unix::fs::PermissionsExt; + + // Given an install directory the user cannot write — what an + // unmigrated root-owned install looks like from inside the binary + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("bin"); + std::fs::create_dir(&nested).unwrap(); + let real = nested.join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&real).unwrap(); + std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root writes through 0o555 (CAP_DAC_OVERRIDE), so the unwritable + // precondition cannot be constructed from mode bits alone. Skip + // rather than assert a refusal the kernel will never produce — + // containerized CI runs as root. + if std::fs::write(nested.join(".root-probe"), b"").is_ok() { + let _ = std::fs::remove_file(nested.join(".root-probe")); + return; + } + + // When writability is probed + let result = layout.ensure_writable(); + + // Then it is a clean, typed refusal naming the directory — never an + // escalation attempt + std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + matches!(result, Err(EnsureWritableError::NotWritable { .. })), + "got: {result:?}" + ); + } +} diff --git a/crates/tapesctl/src/lib.rs b/crates/tapesctl/src/lib.rs index d3ab7cf..46748a2 100644 --- a/crates/tapesctl/src/lib.rs +++ b/crates/tapesctl/src/lib.rs @@ -9,14 +9,18 @@ pub mod cli; pub mod codex_app; pub mod config; pub mod error; +pub mod install_layout; pub mod logging; pub mod machine; pub mod plugin; pub mod ports; +pub mod rc_block; pub mod start; pub mod transcript; +pub mod uninstall; use clap::{ArgMatches, CommandFactory, FromArgMatches}; +use snafu::ResultExt; use tapes_client::DirectHttp; use url::Url; @@ -303,6 +307,9 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Plugin(PluginCommand::Uninstall(args)) => plugin::uninstall(args), Command::Plugin(PluginCommand::Hook(args)) => codex_app::hook::run(&args).await, Command::Config(command) => config::run(&command), + Command::Uninstall { assume_yes } => { + uninstall::run(assume_yes).context(error::error::UninstallSnafu) + } } } diff --git a/crates/tapesctl/src/rc_block.rs b/crates/tapesctl/src/rc_block.rs new file mode 100644 index 0000000..6110ced --- /dev/null +++ b/crates/tapesctl/src/rc_block.rs @@ -0,0 +1,374 @@ +//! Surgically removes the installer-written sentinel block from shell rc files. +//! +//! `install.sh` wraps everything it adds to a user's rc file between the +//! [`BLOCK_BEGIN`] and [`BLOCK_END`] marker lines. That sentinel contract is +//! what makes touching user dotfiles defensible: removal deletes exactly the +//! marked block and preserves every byte outside it — no regex over user +//! content, no whole-file reformatting. +//! +//! The marker strings are deliberately duplicated in the bash installer +//! (single-sourcing them through the binary would put binary execution on the +//! installer's critical path before PATH exists); a consistency test reads the +//! script so drift turns into a red build instead of a silent contract break. +//! +//! The markers name tapesctl. paperctl writes a block with the same glyph and +//! different text into the same rc files, and both removers compare whole +//! lines — so the two blocks coexist and neither tool disturbs the other's. + +use std::path::{Path, PathBuf}; + +use snafu::{ResultExt, Snafu}; + +/// First line of the installer-written rc block. Must match `install.sh` +/// byte-for-byte. +pub const BLOCK_BEGIN: &str = "# > [|o=o|] > tapesctl path > [|o=o|] >"; + +/// Last line of the installer-written rc block. Must match `install.sh` +/// byte-for-byte. +pub const BLOCK_END: &str = "# < [|o=o|] < tapesctl path < [|o=o|] <"; + +/// The rc files the installer may have written a block into, resolved against +/// the current user's home directory and `XDG_CONFIG_HOME`. +/// +/// Empty when no home directory can be resolved — with no home there is no rc +/// file the installer could have edited either. The environment is read here, +/// at the command boundary; the path construction itself is the injectable +/// [`rc_files_under`]. +#[must_use] +pub fn known_rc_files() -> Vec { + dirs::home_dir() + .map(|home| { + let xdg = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); + rc_files_under(&home, xdg.as_deref()) + }) + .unwrap_or_default() +} + +/// The rc-file set under an explicit home directory: `.bashrc`, `.zshrc`, and +/// fish's `config.fish` — the same set the installer targets. +/// +/// The fish path honors `xdg_config_home` exactly like the installer's +/// `${XDG_CONFIG_HOME:-$HOME/.config}` expression (an empty value falls back +/// like an unset one), so uninstall removes the block from the file the +/// installer actually wrote. Pure path construction; nothing is checked for +/// existence. Split out so tests can drive a tempdir home. +#[must_use] +pub fn rc_files_under(home: &Path, xdg_config_home: Option<&Path>) -> Vec { + let config_dir = xdg_config_home + .filter(|p| !p.as_os_str().is_empty()) + .map_or_else(|| home.join(".config"), Path::to_path_buf); + vec![ + home.join(".bashrc"), + home.join(".zshrc"), + config_dir.join("fish").join("config.fish"), + ] +} + +/// What [`remove_block`] did to the rc file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoveOutcome { + /// A block was found and removed; the file was rewritten with every byte + /// outside the markers intact. + Removed, + /// The file does not exist or contains no block; it was not rewritten at + /// all. + NoBlock, + /// A begin marker with no matching end marker. Rewriting would drop + /// everything after the orphaned marker, so the file was left untouched — + /// callers surface this so the user knows the block is still there. + Malformed, +} + +/// Remove the sentinel block from the rc file at `rc_path`. +/// +/// The file is only rewritten on [`RemoveOutcome::Removed`]; content outside +/// the markers survives byte-for-byte. +pub fn remove_block(rc_path: &Path) -> Result { + use remove_block_error::*; + + let contents = match std::fs::read(rc_path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RemoveOutcome::NoBlock), + Err(source) => return Err(source).context(ReadSnafu { path: rc_path }), + }; + let remaining = match strip_block(&contents) { + StripOutcome::Stripped(remaining) => remaining, + StripOutcome::NoBlock => return Ok(RemoveOutcome::NoBlock), + StripOutcome::Malformed => return Ok(RemoveOutcome::Malformed), + }; + std::fs::write(rc_path, remaining).context(WriteSnafu { path: rc_path })?; + Ok(RemoveOutcome::Removed) +} + +/// What [`strip_block`] found in raw file contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StripOutcome { + /// A well-formed block was present; the payload is the file with the block + /// removed and every byte outside it intact. + Stripped(Vec), + /// No block is present — the signal that the file must not be rewritten. + NoBlock, + /// A begin marker without a matching end marker — the file must not be + /// rewritten (see [`RemoveOutcome::Malformed`]). + Malformed, +} + +/// Pure core of [`remove_block`]: strip the marked block from raw file +/// contents. Operates on bytes so rc files with non-UTF-8 content pass through +/// undamaged. +#[must_use] +pub fn strip_block(contents: &[u8]) -> StripOutcome { + let begin = BLOCK_BEGIN.as_bytes(); + let end = BLOCK_END.as_bytes(); + let mut remaining = Vec::with_capacity(contents.len()); + let mut inside_block = false; + let mut removed = false; + let mut rest = contents; + while !rest.is_empty() { + let line_len = rest + .iter() + .position(|&b| b == b'\n') + .map_or(rest.len(), |i| i + 1); + let (line, tail) = rest.split_at(line_len); + rest = tail; + // Exact whole-line comparison against the markers — the trailing + // newline is the only byte stripped before comparing, so nothing + // resembling a pattern ever runs over user content, and a paperctl + // block's marker line (same glyph, different text) never matches. + let body = line.strip_suffix(b"\n").unwrap_or(line); + if inside_block { + if body == end { + inside_block = false; + } + continue; + } + if body == begin { + inside_block = true; + removed = true; + continue; + } + remaining.extend_from_slice(line); + } + // A begin marker with no matching end marker means the block is malformed; + // refusing to rewrite preserves the user's bytes instead of dropping + // everything after the orphaned marker. + match (removed, inside_block) { + (true, false) => StripOutcome::Stripped(remaining), + (true, true) => StripOutcome::Malformed, + (false, _) => StripOutcome::NoBlock, + } +} + +/// Failure modes for [`remove_block`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum RemoveBlockError { + /// Reading the rc file failed (a missing file is + /// [`RemoveOutcome::NoBlock`], not this variant). + #[snafu(display("could not read rc file '{}'", path.display()))] + Read { + /// The rc file being read. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + /// Writing the edited rc file back failed (read-only file, full disk). + #[snafu(display("could not write rc file '{}'", path.display()))] + Write { + /// The rc file being rewritten. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn rc_block_removal_preserves_bytes_outside_markers() { + // Given an rc file whose tapesctl sentinel block sits between user + // content: aliases and another tool's block before it, more user + // content after it + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".zshrc"); + let before = "# user aliases\n\ + alias ll='ls -al'\n\ + # >>> conda initialize >>>\n\ + . /opt/conda/etc/profile.d/conda.sh\n\ + # <<< conda initialize <<<\n\ + export EDITOR=vim\n"; + let after = "\n# added by another tool\neval \"$(direnv hook zsh)\"\n"; + let block = format!("{BLOCK_BEGIN}\nexport PATH=\"$HOME/.local/bin:$PATH\"\n{BLOCK_END}\n"); + std::fs::write(&rc, format!("{before}{block}{after}")).unwrap(); + + // When the sentinel block is removed + let removed = remove_block(&rc).unwrap(); + + // Then a block was found, and every byte outside the markers survives + // untouched + assert_eq!(removed, RemoveOutcome::Removed); + let remaining = std::fs::read(&rc).unwrap(); + assert_eq!(remaining, format!("{before}{after}").into_bytes()); + } + + #[test] + fn a_paperctl_block_in_the_same_file_is_left_alone() { + // Given an rc file carrying both CLIs' blocks — the state of anyone + // who installed paperctl and tapesctl. The glyph is shared; only the + // marker text differs, and removal compares whole lines. + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".zshrc"); + let paper_block = "# > [|o=o|] > paper shell init script > [|o=o|] >\n\ + export PATH=\"$HOME/.local/bin:$PATH\"\n\ + # < [|o=o|] < paper shell init < [|o=o|] <\n"; + let ours = format!("{BLOCK_BEGIN}\nexport PATH=\"$HOME/.local/bin:$PATH\"\n{BLOCK_END}\n"); + std::fs::write(&rc, format!("{paper_block}{ours}")).unwrap(); + + // When tapesctl removes its block + assert_eq!(remove_block(&rc).unwrap(), RemoveOutcome::Removed); + + // Then paperctl's block survives byte-for-byte + assert_eq!(std::fs::read(&rc).unwrap(), paper_block.as_bytes()); + } + + #[test] + fn malformed_block_reports_malformed_and_leaves_file_untouched() { + // Given an rc file whose block has a begin marker but no end — a + // hand-edited or truncated file + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".zshrc"); + let contents = format!("# user content\n{BLOCK_BEGIN}\nexport PATH=oops\n"); + std::fs::write(&rc, &contents).unwrap(); + + // When removal runs + let outcome = remove_block(&rc).unwrap(); + + // Then the malformed state is reported and the file's bytes are + // untouched — rewriting would drop everything after the marker + assert_eq!(outcome, RemoveOutcome::Malformed); + assert_eq!(std::fs::read(&rc).unwrap(), contents.into_bytes()); + } + + #[test] + fn a_missing_file_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let outcome = remove_block(&dir.path().join("nothing-here")).unwrap(); + assert_eq!(outcome, RemoveOutcome::NoBlock); + } + + #[test] + fn non_utf8_content_outside_the_block_survives() { + // rc files are shell, not necessarily UTF-8 — a latin-1 comment or a + // stray byte must pass through a removal undamaged, which is why the + // stripper works on bytes. + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".bashrc"); + let mut contents = b"# caf\xe9 alias\n".to_vec(); + contents + .extend_from_slice(format!("{BLOCK_BEGIN}\nexport PATH=x\n{BLOCK_END}\n").as_bytes()); + contents.extend_from_slice(b"# \xff\xfe tail\n"); + std::fs::write(&rc, &contents).unwrap(); + + assert_eq!(remove_block(&rc).unwrap(), RemoveOutcome::Removed); + + let mut expected = b"# caf\xe9 alias\n".to_vec(); + expected.extend_from_slice(b"# \xff\xfe tail\n"); + assert_eq!(std::fs::read(&rc).unwrap(), expected); + } + + #[test] + fn rc_files_honor_xdg_config_home_for_fish() { + // Given a home dir and a distinct XDG_CONFIG_HOME — the state in which + // the installer writes fish's config under the XDG dir + let home = Path::new("/home/u"); + let xdg = Path::new("/xdg/config"); + + // When the rc-file set is derived + let files = rc_files_under(home, Some(xdg)); + + // Then the fish path lives under XDG_CONFIG_HOME while bash/zsh stay + // under home + assert!(files.contains(&home.join(".bashrc"))); + assert!(files.contains(&home.join(".zshrc"))); + assert!(files.contains(&xdg.join("fish").join("config.fish"))); + assert!(!files.iter().any(|f| f.starts_with(home.join(".config")))); + } + + #[test] + fn rc_files_fall_back_to_dot_config_when_xdg_unset_or_empty() { + // Given no XDG_CONFIG_HOME (or an empty one — the shell `:-` + // expansion treats both alike) + let home = Path::new("/home/u"); + for xdg in [None, Some(Path::new(""))] { + let files = rc_files_under(home, xdg); + assert!( + files.contains(&home.join(".config").join("fish").join("config.fish")), + "fish path missing for xdg={xdg:?}: {files:?}" + ); + } + } + + /// The value of a single-quoted shell assignment `name='...'`, from the + /// first line that makes one. + fn shell_assignment(contents: &str, name: &str) -> Option { + contents.lines().find_map(|line| { + let rest = line.trim_start().strip_prefix(name)?.strip_prefix("='")?; + rest.strip_suffix('\'').map(str::to_owned) + }) + } + + #[test] + fn sentinel_markers_match_the_installer_script() { + // Given the installer — the one writer of the sentinel block — it must + // agree with the Rust remover byte-for-byte. + // + // Equality against the parsed assignment, NOT `contents.contains(..)`: + // a substring check passes for every drift that matters. Append one + // space to the shell's marker and `contains` still succeeds, while + // both the shell's whole-line awk and the Rust stripper below stop + // matching it — the exact state that leaves a block in a user's + // dotfile that neither tool can ever remove. It would also happily + // match a stale marker sitting in a comment while the live assignment + // had moved on. + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); + let script = repo_root.join("install.sh"); + let contents = std::fs::read_to_string(&script) + .unwrap_or_else(|e| panic!("could not read {}: {e}", script.display())); + + let begin = shell_assignment(&contents, "RC_BLOCK_BEGIN") + .unwrap_or_else(|| panic!("{} has no RC_BLOCK_BEGIN='..'", script.display())); + let end = shell_assignment(&contents, "RC_BLOCK_END") + .unwrap_or_else(|| panic!("{} has no RC_BLOCK_END='..'", script.display())); + + assert_eq!( + begin, + BLOCK_BEGIN, + "begin marker drifted from {}", + script.display() + ); + assert_eq!( + end, + BLOCK_END, + "end marker drifted from {}", + script.display() + ); + } + + #[test] + fn the_marker_pin_catches_a_trailing_space() { + // The pin's own regression test: the drift it exists to catch is + // invisible to a substring check, so prove the parse-and-compare + // notices it. + let drifted = "RC_BLOCK_BEGIN='# > [|o=o|] > tapesctl path > [|o=o|] > '\n"; + let parsed = shell_assignment(drifted, "RC_BLOCK_BEGIN").unwrap(); + assert!( + drifted.contains(BLOCK_BEGIN), + "a substring check would pass here" + ); + assert_ne!(parsed, BLOCK_BEGIN, "the parse-and-compare must not"); + } +} diff --git a/crates/tapesctl/src/uninstall.rs b/crates/tapesctl/src/uninstall.rs new file mode 100644 index 0000000..28415b1 --- /dev/null +++ b/crates/tapesctl/src/uninstall.rs @@ -0,0 +1,637 @@ +//! `tapesctl uninstall` — remove the binary, the installer's rc block, and +//! this tool's local state. +//! +//! The installer writes into two places a user did not choose by hand: a +//! directory on their `PATH` and a sentinel block in their shell rc file. +//! Neither should be something only a hand-edit can take back, which is what +//! this command exists for. +//! +//! Destruction is sequenced so an *interruption* leaves a tool that can +//! retry: the self-unlink is dead last — safe on Unix because the running +//! process keeps its inode until it exits. A step that *fails* is a warning +//! naming the leftover path, not an abort: the run continues to the end, +//! trading a guaranteed retry tool for finishing everything that can finish. +//! +//! What is deliberately *not* removed is anything a harness owns: a +//! Codex plugin registration lives in the harness's own config file, and +//! `tapesctl plugin uninstall` is the command that speaks that contract. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use snafu::{ResultExt, Snafu}; + +use crate::install_layout::InstallLayout; +use crate::rc_block::{self, RemoveOutcome}; + +/// The installer command, printed as the remediation when the install +/// directory cannot be mutated. Matches the documented one-liner. +pub const INSTALL_COMMAND: &str = "curl -sSfL https://download.tapes.dev/tapesctl/install | bash"; + +/// The local state directories uninstall removes, injected rather than +/// resolved. +/// +/// `Machine::resolve` refuses to read the real environment under `cfg(test)` — +/// for good reason, see `crate::machine` — so the ambient lookup happens once +/// at the command boundary in [`run`] and every step below takes explicit +/// paths. A `None` means "this machine names no such location", which is a +/// nothing-to-do, not a failure. +#[derive(Debug, Clone, Default)] +pub struct PurgePaths { + /// `~/.tapes` — the configuration directory. + pub config_dir: Option, + /// The cassette surface cache directory — the one this crate derived, never + /// an environment-supplied one (see [`crate::cassette::cache::owned_cache_dir`]). + pub cache_dir: Option, + /// A cache location the environment overrode us to. Reported, never + /// removed: it names a directory the user chose, which may hold anything. + pub cache_dir_override: Option, + /// Shell rc files that may carry the installer's sentinel block. + pub rc_files: Vec, +} + +/// Run `tapesctl uninstall`, resolving every ambient location first. +pub fn run(assume_yes: bool) -> Result<(), UninstallError> { + // Optional, not a precondition. `from_current_exe` fails when + // `current_exe()` errors (no /proc in a minimal container) or when + // canonicalization does (an unreadable path component; on Linux, a binary + // whose file was already unlinked). None of that is a reason to leave the + // config, the cache, and the user's rc block in place — there is simply no + // binary location to act on, so the rest of the teardown still runs. + let layout = InstallLayout::from_current_exe().ok(); + let paths = PurgePaths { + config_dir: crate::machine::Machine::resolve() + .ok() + .and_then(|machine| machine.tapes_config_path().parent().map(Path::to_path_buf)), + cache_dir: crate::cassette::cache::owned_cache_dir(), + cache_dir_override: crate::cassette::cache::cache_dir_override(), + rc_files: rc_block::known_rc_files(), + }; + run_with( + &mut std::io::stdout(), + &mut std::io::stderr(), + &mut std::io::stdin().lock(), + layout.as_ref(), + &paths, + assume_yes, + ) +} + +/// Command core with every seam injected: the output writer, the confirmation +/// reader, the install layout, and the state paths. +/// +/// Step order is deliberate — state first, dotfile next, binary last — so an +/// interruption at any point leaves a working `tapesctl` that can be run again +/// to finish the job. +// Six clears clippy's stock threshold (7) but not the forest dev +// environment's clippy.toml (too-many-arguments-threshold = 5). Every +// parameter is a deliberate test seam — the two output streams are separate +// precisely so a test can assert the prompt is not on stdout — and a struct +// would only rename them. +#[allow(clippy::too_many_arguments)] +pub fn run_with( + out: &mut W, + err: &mut E, + input: &mut R, + layout: Option<&InstallLayout>, + paths: &PurgePaths, + assume_yes: bool, +) -> Result<(), UninstallError> +where + W: Write, + E: Write, + R: std::io::BufRead, +{ + use uninstall_error::WriteSnafu; + + if !assume_yes && !confirm(err, input, layout, paths)? { + writeln!(err, "Nothing was removed.").context(WriteSnafu)?; + return Ok(()); + } + + remove_dir_step(out, paths.config_dir.as_deref(), "configuration")?; + remove_dir_step(out, paths.cache_dir.as_deref(), "cassette cache")?; + if let Some(overridden) = paths.cache_dir_override.as_deref() { + // Named, not removed. The variable points at a directory the user + // chose; recursively deleting whatever it happens to name is not a + // liberty an uninstall gets to take. + writeln!( + out, + "Note: {} is set, so a cache may also live at {}.\n\ + Left alone — remove it yourself if you want it gone.", + crate::cassette::cache::CACHE_DIR_ENV, + overridden.display() + ) + .context(WriteSnafu)?; + } + remove_rc_blocks_step(out, &paths.rc_files)?; + writeln!( + out, + "\nHarness-side capture plugins are not touched — a plugin\n\ + registration lives in the harness's own config. Remove one with:\n\ + \x20 tapesctl plugin uninstall " + ) + .context(WriteSnafu)?; + match layout { + Some(layout) => remove_own_binary_step(out, layout)?, + // No resolvable binary location. Everything else is gone, and saying + // so is better than a silent partial teardown the user cannot see. + None => writeln!( + out, + "! could not work out where this binary lives, so it was left in \ + place; remove it yourself." + ) + .context(WriteSnafu)?, + } + writeln!(out, "\nAll local tapesctl state removed.").context(WriteSnafu) +} + +/// Ask before destroying anything, naming the binary that will go. +/// +/// A reader that is at EOF (a pipe, CI) yields no line, which is treated as a +/// decline: an uninstall that proceeds because nobody was there to say no is +/// the one outcome this prompt exists to prevent. +fn confirm( + err: &mut E, + input: &mut R, + layout: Option<&InstallLayout>, + paths: &PurgePaths, +) -> Result +where + E: Write, + R: std::io::BufRead, +{ + use uninstall_error::WriteSnafu; + + // Every target enumerated, because two of them are recursive deletes and + // "tapesctl's local state" is not a description anyone can check before + // typing y. + writeln!(err, "This will remove:").context(WriteSnafu)?; + for (what, path) in [ + ("binary", layout.map(InstallLayout::tapesctl_path)), + ("configuration", paths.config_dir.as_deref()), + ("cassette cache", paths.cache_dir.as_deref()), + ] { + if let Some(path) = path { + writeln!(err, " {what:<15} {}", path.display()).context(WriteSnafu)?; + } + } + for rc in &paths.rc_files { + writeln!( + err, + " {:<15} the tapesctl block in {}", + "PATH block", + rc.display() + ) + .context(WriteSnafu)?; + } + write!(err, "Continue? [y/N] ").context(WriteSnafu)?; + err.flush().context(WriteSnafu)?; + + let mut answer = String::new(); + if input.read_line(&mut answer).unwrap_or(0) == 0 { + writeln!(err).context(WriteSnafu)?; + return Ok(false); + } + Ok(matches!( + answer.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +/// Remove one state directory, warn-and-continue. +/// +/// A missing directory is silent success — that is the state uninstall is +/// trying to reach, and reporting it as a removal would be a lie. +fn remove_dir_step(out: &mut W, dir: Option<&Path>, what: &str) -> Result<(), UninstallError> +where + W: Write, +{ + use uninstall_error::WriteSnafu; + + let Some(dir) = dir else { + return Ok(()); + }; + match std::fs::remove_dir_all(dir) { + Ok(()) => writeln!(out, "Removed {what} at {}", dir.display()).context(WriteSnafu), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => { + writeln!(out, "! could not remove {what} at {}: {e}", dir.display()).context(WriteSnafu) + } + } +} + +/// Remove the installer's sentinel block from every candidate rc file. +/// +/// A read-only rc file (nix, home-manager) is a warning line, not an abort: the +/// binary still goes, and the block is inert once the binary it guards on is +/// gone. A malformed block is reported specifically, because leaving it is the +/// deliberate choice — rewriting would drop everything after the orphaned +/// marker. +fn remove_rc_blocks_step(out: &mut W, rc_files: &[PathBuf]) -> Result<(), UninstallError> +where + W: Write, +{ + use uninstall_error::WriteSnafu; + + for rc_path in rc_files { + match rc_block::remove_block(rc_path) { + Ok(RemoveOutcome::Removed) => { + writeln!(out, "Removed the PATH block from {}", rc_path.display()) + .context(WriteSnafu)?; + } + Ok(RemoveOutcome::NoBlock) => {} + Ok(RemoveOutcome::Malformed) => { + writeln!( + out, + "! {} has a begin marker with no matching end; left it alone \ + rather than risk dropping what follows it", + rc_path.display() + ) + .context(WriteSnafu)?; + } + Err(e) => { + writeln!(out, "! could not edit {}: {e}", rc_path.display()).context(WriteSnafu)?; + } + } + } + Ok(()) +} + +/// Unlink the running `tapesctl` binary itself — the final destructive act. +/// +/// When the install directory is not writable (an unmigrated root-owned +/// install), this refuses without escalating and prints both the installer +/// command and the manual `rm`. Reinstalling must not be the only path out. +fn remove_own_binary_step(out: &mut W, layout: &InstallLayout) -> Result<(), UninstallError> +where + W: Write, +{ + use uninstall_error::WriteSnafu; + + let binary = layout.tapesctl_path(); + // unlink needs write permission on the containing directory, not the file — + // an unwritable directory means an unmigrated root-owned install, and the + // fix is the installer, never sudo from inside the binary. + if let Err(e) = layout.ensure_writable() { + writeln!( + out, + "! could not remove tapesctl at {}: {e}", + binary.display() + ) + .context(WriteSnafu)?; + writeln!( + out, + " Re-run the installer to migrate to a user-owned install: {INSTALL_COMMAND}" + ) + .context(WriteSnafu)?; + writeln!(out, " Or remove the binary yourself:").context(WriteSnafu)?; + writeln!(out, " sudo rm {}", binary.display()).context(WriteSnafu)?; + return Ok(()); + } + // The upgrade machinery's residue goes with the binary: a staging file an + // interrupted upgrade left behind (its sweep runs only on the *next* + // upgrade, and there will not be one) and the pipeline lock file. Both + // removals are quiet best-effort — neither file existing is the normal + // case. + for residue in [ + layout.staging_path().to_path_buf(), + crate::upgrade::artifact::upgrade_lock_path(layout), + ] { + match std::fs::remove_file(&residue) { + Ok(()) => writeln!(out, "Removed upgrade residue at {}", residue.display()) + .context(WriteSnafu)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => writeln!(out, "! could not remove {}: {e}", residue.display()) + .context(WriteSnafu)?, + } + } + match std::fs::remove_file(binary) { + Ok(()) => writeln!(out, "Removed tapesctl at {}", binary.display()).context(WriteSnafu), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => writeln!( + out, + "! could not remove tapesctl at {}: {e}", + binary.display() + ) + .context(WriteSnafu), + } +} + +/// Failure modes for [`run`]. +/// +/// Deliberately short: every removal step warns and continues, so only failures +/// that make the command impossible to *start* — resolving the layout — or that +/// make its report unreadable are representable here. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum UninstallError { + /// The install layout could not be resolved from the running executable. + #[snafu(display("could not resolve the install layout"))] + ResolveLayout { + /// Underlying layout-construction failure. + source: crate::install_layout::InstallLayoutError, + }, + /// The output writer rejected our bytes. + #[snafu(display("could not write uninstall output"))] + Write { + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::rc_block::{BLOCK_BEGIN, BLOCK_END}; + + /// Drive `run_with` with the streams tests care about, returning + /// `(stdout, stderr)`. + fn run_capturing( + layout: Option<&InstallLayout>, + paths: &PurgePaths, + input: &str, + assume_yes: bool, + ) -> (String, String) { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let mut input = std::io::Cursor::new(input.as_bytes().to_vec()); + run_with(&mut out, &mut err, &mut input, layout, paths, assume_yes).unwrap(); + ( + String::from_utf8(out).unwrap(), + String::from_utf8(err).unwrap(), + ) + } + + /// A tempdir install: a real binary file plus the layout over it. + fn layout_in(dir: &Path) -> InstallLayout { + let binary = dir.join("tapesctl"); + std::fs::write(&binary, b"#!/bin/sh\n").unwrap(); + InstallLayout::from_exe_path(&binary).unwrap() + } + + fn rc_with_block(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + format!("# mine\n{BLOCK_BEGIN}\nexport PATH=x\n{BLOCK_END}\n"), + ) + .unwrap(); + } + + #[test] + fn a_full_uninstall_removes_state_the_block_and_the_binary() { + // Given an install with configuration, a cache, and a shell rc file + // carrying the installer's block + let home = tempfile::tempdir().unwrap(); + let install = tempfile::tempdir().unwrap(); + let layout = layout_in(install.path()); + let config_dir = home.path().join(".tapes"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("config.toml"), b"api-url = 'x'").unwrap(); + let cache_dir = home.path().join("cache").join("tapesctl").join("cassettes"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let rc = home.path().join(".zshrc"); + rc_with_block(&rc); + // Plus the upgrade machinery's residue: the staging and lock files an + // interrupted upgrade could leave in the install directory. + let staging = layout.staging_path().to_path_buf(); + let lock = crate::upgrade::artifact::upgrade_lock_path(&layout); + std::fs::write(&staging, b"partial").unwrap(); + std::fs::write(&lock, b"").unwrap(); + let paths = PurgePaths { + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache_dir.clone()), + cache_dir_override: None, + rc_files: vec![rc.clone()], + }; + + // When uninstall runs with confirmation skipped + let (out, _) = run_capturing(Some(&layout), &paths, "", /* assume_yes */ true); + + // Then every artifact is gone, the user's own rc content survives, and + // the report names the harness-plugin caveat + assert!(!config_dir.exists(), "config dir should be gone"); + assert!(!cache_dir.exists(), "cache dir should be gone"); + assert!(!layout.tapesctl_path().exists(), "binary should be gone"); + assert!(!staging.exists(), "staging residue should be gone"); + assert!(!lock.exists(), "lock residue should be gone"); + assert_eq!(std::fs::read_to_string(&rc).unwrap(), "# mine\n"); + let s = out; + assert!(s.contains("plugin uninstall"), "stdout: {s}"); + assert!( + s.contains("All local tapesctl state removed"), + "stdout: {s}" + ); + } + + #[test] + fn declining_the_prompt_removes_nothing() { + // Given an install and a user who answers no + let install = tempfile::tempdir().unwrap(); + let layout = layout_in(install.path()); + let home = tempfile::tempdir().unwrap(); + let rc = home.path().join(".zshrc"); + rc_with_block(&rc); + let paths = PurgePaths { + rc_files: vec![rc.clone()], + ..PurgePaths::default() + }; + + let (_, err) = run_capturing(Some(&layout), &paths, "n\n", false); + + // Then the binary and the block are both still there + assert!(layout.tapesctl_path().exists()); + assert!(std::fs::read_to_string(&rc).unwrap().contains(BLOCK_BEGIN)); + assert!(err.contains("Nothing was removed"), "stderr: {err}"); + } + + #[test] + fn no_answer_at_all_is_a_decline() { + // A pipe with nothing in it — CI, or a ` Date: Fri, 21 Aug 2026 15:19:58 -0700 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20tapesctl=20upgrade?= =?UTF-8?q?=20=E2=80=94=20one=20command=20replaces=20the=20curl=20ritual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tapesctl upgrade checks the published version, reports "already up to date" with a clean exit when there is nothing to do, and otherwise downloads, verifies, and atomically replaces this binary, printing old → new. --version pins an exact release (older included — a bad release needs an escape hatch) and --nightly always fetches, because nightly builds are deliberately outside version ordering. The core is a pipeline whose failure modes all converge on "the binary you had still works." The new artifact is downloaded to a dotfile staged in the install directory itself — same directory means same filesystem, which is what makes the final rename atomic; a staging file in /tmp would cross devices and degrade to copy-plus-delete, reopening exactly the corruption window this exists to close. The dotted name keeps a crashed run from leaving a PATH-visible half-binary, and any stale staging file is swept at the start of the next attempt, so repeated failures converge instead of accumulating. Nothing touches the live binary until the staged bytes have earned it, in a fixed order: the published sha256 is fetched and compared first — before the staged file is made executable, before it is run. Executing an unverified download would make the updater an arbitrary-code- execution primitive against itself, so the sanity probe runs strictly after the digest passes; it catches what a correct digest cannot, a faithfully published wrong-arch artifact. The probe's answer is then checked against the resolved version, which catches what neither can: a prefix serving the wrong build. Only then does rename swap it over the running executable — safe on Unix because the current process keeps its inode. A missing .sha256 aborts rather than degrading to an unverified install. Checksum mismatch, truncated download, failed probe, unwritable directory: each is a typed error that aborts with the original untouched and the staging file cleaned. The unwritable case names the installer — an unmigrated root-owned install cannot self-upgrade by design, and the message routes it to the script that migrates it, before a byte is downloaded that it could never apply. Unlike paperctl's, the pipeline ends at the swap: tapesctl runs no daemon and installs no symlink, so there is nothing to re-point or bounce. A capture already in flight keeps running on its old inode. The comparison has one wrinkle paperctl does not: a tapesctl build stamps its commit as semver build metadata, so `nightly` arrives as `nightly+3f2a1b9` and never matches a bare sentinel. The name is split from its metadata before that check, rather than leaving the answer to depend on whether the version parser happens to reject the whole string. main now prints the source chain rather than the outermost message alone. Every error here is a typed wrapper around the one beneath it and the outermost is deliberately the least specific — "upgrade failed" is a category, while the cause a user acts on lives one or two links down. Printing only the top discarded exactly the half that says what to do. A hidden env override for the download base URL exists so the end-to-end test can point the real compiled binary at a localhost bucket and watch it replace itself on disk — including the abort paths, where the assertion is that the installed bytes are identical afterwards. The staging file is created create_new with mode 0600: no truncate of an existing path, no symlink follow, and no executable window before the digest passes. That is what makes the stale-staging sweep load-bearing rather than incidental — disabling it fails a test. The probe is time-bounded with captured output capped, because it is the one step that executes code the bucket supplied. The swap inherits the mode of the binary being replaced, execute bits forced on, so a deliberately private install stays private across upgrades. Redirects are pinned to https, the download carries a size ceiling in place of an overall timeout, and a sidecar behind any non-success status is treated as the absent checksum it is. The unwritable-directory test probes its own precondition and skips when mode bits cannot construct it — root writes through 0o555, and containerized CI runs as root. A probe that outlives its timeout is killed, not abandoned — and killed as a process group, not a single pid: the probe leads its own group, so descendants a forking artifact leaves behind go with it. The child handle is still held when the timeout fires, which is what makes the group id trustworthy: an unreaped child's pid cannot be recycled. Output is read to a cap and the rest drained, so a chatty artifact can neither exhaust the updater's memory nor deadlock the probe on a full pipe. One pipeline at a time: an exclusive flock on a lock file in the install directory serializes concurrent upgrades — the kernel drops it however the process ends. Two runs sharing one staging path could otherwise unlink each other's verified bytes and commit a partial download as the live binary while printing success. The probe is a sanity check, not a sandbox. Its containment of the staged binary is best-effort by design: an adversarial bucket able to serve hostile verified bytes defeats the pipeline at the swap, not at the probe — the security boundary is the digest gate. --- Cargo.lock | 1 + crates/tapesctl/Cargo.toml | 1 + crates/tapesctl/src/cli.rs | 26 + crates/tapesctl/src/error.rs | 11 + crates/tapesctl/src/lib.rs | 4 + crates/tapesctl/src/main.rs | 14 +- crates/tapesctl/src/upgrade/artifact.rs | 924 ++++++++++++++++++++++++ crates/tapesctl/src/upgrade/http.rs | 431 +++++++++++ crates/tapesctl/src/upgrade/mod.rs | 527 ++++++++++++++ crates/tapesctl/tests/self_upgrade.rs | 547 ++++++++++++++ 10 files changed, 2484 insertions(+), 2 deletions(-) create mode 100644 crates/tapesctl/src/upgrade/artifact.rs create mode 100644 crates/tapesctl/src/upgrade/http.rs create mode 100644 crates/tapesctl/src/upgrade/mod.rs create mode 100644 crates/tapesctl/tests/self_upgrade.rs diff --git a/Cargo.lock b/Cargo.lock index 93eec3a..7096a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1897,6 +1897,7 @@ dependencies = [ "http", "http-body", "http-body-util", + "libc", "reqwest", "serde", "serde_json", diff --git a/crates/tapesctl/Cargo.toml b/crates/tapesctl/Cargo.toml index 09730c1..ecd842f 100644 --- a/crates/tapesctl/Cargo.toml +++ b/crates/tapesctl/Cargo.toml @@ -17,6 +17,7 @@ name = "tapesctl" path = "src/main.rs" [dependencies] +libc = { workspace = true } tapes-harnesses = { workspace = true } tapes-capture = { workspace = true } tapes-client = { workspace = true } diff --git a/crates/tapesctl/src/cli.rs b/crates/tapesctl/src/cli.rs index ce8ce2b..d6ad715 100644 --- a/crates/tapesctl/src/cli.rs +++ b/crates/tapesctl/src/cli.rs @@ -337,6 +337,32 @@ pub enum Command { #[command(subcommand)] Config(ConfigCommand), + /// Upgrade tapesctl in place: download the requested build, verify its + /// published checksum, and atomically replace this binary. + /// + /// With no flags, installs the newest release; exits successfully without + /// downloading when already up to date. Nothing touches the installed + /// binary until the download's sha256 matches the published one and the + /// staged file has answered `version` sensibly, so every failure leaves the + /// binary you had still working. + /// + /// Deliberately named `upgrade` with no `update` alias: `upgrade` means + /// "replace the binary with another build", and `update` is reserved for + /// possible future semantics such as refreshing configuration or a + /// discovered cassette surface. + Upgrade { + /// Release version to install (e.g. `v0.7.0` or `0.7.0`). + /// + /// Defaults to the newest published release; older versions are + /// allowed, because a bad release needs an escape hatch. + #[arg(long, conflicts_with = "nightly")] + version: Option, + + /// Install the rolling nightly build instead of a release. + #[arg(long)] + nightly: bool, + }, + /// Remove tapesctl: the binary, this tool's local state, and the PATH /// block the installer wrote into your shell's rc file. /// diff --git a/crates/tapesctl/src/error.rs b/crates/tapesctl/src/error.rs index 636ad85..08dfd0f 100644 --- a/crates/tapesctl/src/error.rs +++ b/crates/tapesctl/src/error.rs @@ -776,6 +776,17 @@ pub enum Error { source: std::io::Error, }, + /// `tapesctl upgrade` could not replace the binary. + /// + /// Every variant beneath this one means the installed binary is still the + /// pre-upgrade file: nothing destructive happens before the final atomic + /// rename. + #[snafu(display("upgrade failed"))] + Upgrade { + /// Underlying upgrade failure. + source: crate::upgrade::UpgradeCliError, + }, + /// `tapesctl uninstall` could not run. /// /// Only the steps that make the command impossible to start reach here; diff --git a/crates/tapesctl/src/lib.rs b/crates/tapesctl/src/lib.rs index 46748a2..6e4b890 100644 --- a/crates/tapesctl/src/lib.rs +++ b/crates/tapesctl/src/lib.rs @@ -18,6 +18,7 @@ pub mod rc_block; pub mod start; pub mod transcript; pub mod uninstall; +pub mod upgrade; use clap::{ArgMatches, CommandFactory, FromArgMatches}; use snafu::ResultExt; @@ -310,6 +311,9 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Uninstall { assume_yes } => { uninstall::run(assume_yes).context(error::error::UninstallSnafu) } + Command::Upgrade { version, nightly } => upgrade::run(version, nightly) + .await + .context(error::error::UpgradeSnafu), } } diff --git a/crates/tapesctl/src/main.rs b/crates/tapesctl/src/main.rs index 6c7d3f4..047752e 100644 --- a/crates/tapesctl/src/main.rs +++ b/crates/tapesctl/src/main.rs @@ -38,9 +38,19 @@ async fn main() -> ExitCode { match tapesctl::dispatch(invocation).await { Ok(()) => ExitCode::SUCCESS, Err(err) => { - // The daemon/proxy work will grow structured error reporting; for - // now a single line to stderr is enough and keeps `main` panic-free. + // The whole chain, not just the outermost message. Every error in + // this crate is a typed wrapper around the one beneath it, and the + // outermost is deliberately the least specific — "upgrade failed" + // is a category, while the cause a user acts on ("sha256 mismatch", + // "install directory is not writable; re-run the installer") lives + // one or two links down. Printing only the top discards exactly the + // half that says what to do about it. eprintln!("tapesctl: {err}"); + let mut source = std::error::Error::source(&err); + while let Some(cause) = source { + eprintln!(" caused by: {cause}"); + source = cause.source(); + } ExitCode::FAILURE } } diff --git a/crates/tapesctl/src/upgrade/artifact.rs b/crates/tapesctl/src/upgrade/artifact.rs new file mode 100644 index 0000000..8627341 --- /dev/null +++ b/crates/tapesctl/src/upgrade/artifact.rs @@ -0,0 +1,924 @@ +//! Artifact-facing mechanics for `tapesctl upgrade`: the target type, the +//! bucket's platform/URL layout, and the local file steps — staging, digest +//! verification, probing, and the atomic swap. +//! +//! The pipeline orchestration — step ordering, refusal-before-network, outcome +//! reporting — lives in [`super`], and the network side (client construction, +//! target resolution, object transfer) lives in [`super::http`]; this module +//! owns the rest. Every function upholds the pipeline's abort contract: a +//! failure leaves the installed binary byte-identical, and the staging file's +//! lifetime is owned by the caller's [`StagingGuard`]. + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use snafu::{OptionExt, ResultExt, Snafu}; +use versions::Versioning; + +use super::UpgradeError; +use super::http::parse_comparable_version; +use crate::install_layout::InstallLayout; + +/// Basename of the released binary object inside `///`. +const BINARY_OBJECT: &str = "tapesctl"; + +/// Suffix appended to [`BINARY_OBJECT`] for its published digest object. +const CHECKSUM_SUFFIX: &str = ".sha256"; + +/// Bound on retries when exec of the freshly staged binary races an overlay +/// filesystem's post-close writeback (see [`probe_staged_binary`]). Capped so a +/// genuinely un-execable artifact still fails promptly. +const MAX_PROBE_SPAWN_ATTEMPTS: u32 = 50; + +/// Delay between [`probe_staged_binary`] spawn retries — 50 × 20ms bounds the +/// transient-ETXTBSY wait at ~1s. +const PROBE_SPAWN_RETRY_DELAY: Duration = Duration::from_millis(20); + +/// Wall-clock bound on the sanity probe. +/// +/// The probe is the one step that *executes code the bucket supplied*, so it is +/// the one step that must not be allowed to run forever: an artifact that +/// blocks — an infinite loop, a `version` path that dials a black-holed host — +/// would otherwise hang the upgrade with an executable staging file on disk, +/// where a Ctrl-C skips `Drop` and leaves it behind. `version` is a constant +/// print, so anything beyond a couple of seconds is already pathological. +const PROBE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Cap on probe output retained for the version check and error messages. +/// +/// `Command::output()` buffers without limit, so a chatty artifact could +/// exhaust memory in the updater. The version line is the first thing printed; +/// 64 KiB is far past any honest `version` output. +const MAX_PROBE_OUTPUT_BYTES: usize = 64 * 1024; + +/// Which published build `tapesctl upgrade` should install. +/// +/// Parse-don't-validate: a pinned version travels as a parsed [`Versioning`], +/// never as a raw string, so downstream code can compare and format it without +/// re-validating. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpgradeTarget { + /// The newest published release (`latest/` prefix). The default when the + /// user names no target. + Latest, + /// The rolling nightly build (`nightly/` prefix). Nightly is not an + /// orderable version, so this target skips version comparison and always + /// downloads. + Nightly, + /// An explicit release version (`v/` prefix). Downgrades are + /// allowed — the pipeline is direction-agnostic. + Pinned(Versioning), +} + +impl UpgradeTarget { + /// Parse a user-supplied target spec. + /// + /// `latest` and `nightly` select the corresponding rolling targets; + /// anything else must parse as a version (a leading `v` is accepted and + /// stripped) and becomes [`UpgradeTarget::Pinned`]. Non-version strings are + /// a typed parse error, so an [`UpgradeTarget`] can never hold junk. + pub fn parse(spec: &str) -> Result { + use parse_target_error::*; + + let trimmed = spec.trim(); + match trimmed { + "latest" => return Ok(Self::Latest), + "nightly" => return Ok(Self::Nightly), + _ => {} + } + // `versions` would hold junk like "not-a-version" as a `Mess`, so + // require a numeric first component before treating the spec as a + // version. + let bare = trimmed.strip_prefix('v').unwrap_or(trimmed); + let version = Versioning::new(bare) + .filter(|v| v.nth(0).is_some()) + .context(InvalidSpecSnafu { spec })?; + Ok(Self::Pinned(version)) + } + + /// The bucket prefix this target downloads from: `latest`, `nightly`, or + /// `v`. + /// + /// The `v` is re-added after parsing, matching how release artifacts are + /// uploaded — so `--version 0.7.0` and `--version v0.7.0` reach the same + /// objects. + pub(super) fn bucket_prefix(&self) -> String { + match self { + Self::Latest => "latest".to_owned(), + Self::Nightly => "nightly".to_owned(), + Self::Pinned(version) => format!("v{version}"), + } + } +} + +/// Build the [`UpgradeTarget`] the clap arguments select: `--nightly` → +/// [`UpgradeTarget::Nightly`], `--version ` → the parsed spec, neither → +/// [`UpgradeTarget::Latest`]. clap's `conflicts_with` keeps both from arriving +/// together. +pub(super) fn target_from_args( + version: Option<&str>, + nightly: bool, +) -> Result { + match version { + Some(spec) => UpgradeTarget::parse(spec), + None if nightly => Ok(UpgradeTarget::Nightly), + None => Ok(UpgradeTarget::Latest), + } +} + +/// Failure modes for [`UpgradeTarget::parse`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum ParseTargetError { + /// The spec was neither `latest`, `nightly`, nor a parseable version. + #[snafu(display("'{spec}' is not 'latest', 'nightly', or a version like 'v0.7.0'"))] + InvalidSpec { + /// The offending user input. + spec: String, + }, +} + +/// The bucket's normalized `/` directory names for the running host +/// (`linux`/`darwin`, `amd64`/`arm64`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BucketPlatform { + /// Bucket OS directory name. + os: &'static str, + /// Bucket architecture directory name. + arch: &'static str, +} + +/// Map the running platform onto [`BucketPlatform`], normalizing Rust's +/// `x86_64`/`aarch64` arch names to the bucket's `amd64`/`arm64`. Hosts the +/// release pipeline does not publish for are a typed error. +pub(super) fn bucket_platform() -> Result { + use super::upgrade_error::*; + + let os = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + other => { + return UnsupportedPlatformSnafu { + os: other, + arch: std::env::consts::ARCH, + } + .fail(); + } + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + // `os`, not `std::env::consts::OS`: the OS arm above reports the + // bucket's spelling (`darwin`), so reporting Rust's (`macos`) here + // would give one host two different names depending on which half was + // unsupported. + other => return UnsupportedPlatformSnafu { os, arch: other }.fail(), + }; + Ok(BucketPlatform { os, arch }) +} + +/// Build the URL of one published object: +/// `////`. +fn artifact_url(base_url: &str, prefix: &str, platform: BucketPlatform, object: &str) -> String { + format!( + "{}/{prefix}/{}/{}/{object}", + base_url.trim_end_matches('/'), + platform.os, + platform.arch, + ) +} + +/// The `(binary, digest)` URL pair for one published build: the `tapesctl` +/// object and its `.sha256` beside it. +pub(super) fn artifact_urls( + base_url: &str, + prefix: &str, + platform: BucketPlatform, +) -> (String, String) { + ( + artifact_url(base_url, prefix, platform, BINARY_OBJECT), + artifact_url( + base_url, + prefix, + platform, + &format!("{BINARY_OBJECT}{CHECKSUM_SUFFIX}"), + ), + ) +} + +/// Name of the advisory lock file that serializes upgrade pipelines, inside +/// the install directory next to the staging file. +pub(crate) const UPGRADE_LOCK_FILE_NAME: &str = ".tapesctl.upgrade.lock"; + +/// The upgrade lock file's path for `layout`'s install directory. +pub(crate) fn upgrade_lock_path(layout: &InstallLayout) -> PathBuf { + layout.install_dir().join(UPGRADE_LOCK_FILE_NAME) +} + +/// Held for the lifetime of one upgrade pipeline: an exclusive advisory +/// `flock(2)` on [`UPGRADE_LOCK_FILE_NAME`]. +/// +/// Two pipelines sharing one staging path must never interleave: each run's +/// stale-staging sweep would unlink the other run's live staging file, and the +/// path-resolved commit could then rename a partial, unverified download over +/// the installed binary while printing success. The kernel releases the lock +/// when the holding process exits — however it exits — so a crashed upgrade +/// can never leave the lock stuck. +/// +/// The lock file itself is never unlinked here: removing a lock file another +/// process may be about to open reintroduces the race the lock closes (holder +/// A keeps the old inode while process B creates and locks a fresh one). +/// Uninstall removes it along with the binary. +#[derive(Debug)] +pub(super) struct UpgradeLock { + _file: std::fs::File, +} + +/// Take the exclusive upgrade lock, or refuse because another upgrade holds +/// it. Non-blocking on purpose: the holding run will either finish the job +/// this run was asked to do or leave a state worth looking at — queueing +/// behind it silently helps nobody. +pub(super) fn acquire_upgrade_lock(layout: &InstallLayout) -> Result { + use super::upgrade_error::*; + use snafu::IntoError; + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt; + + let path = upgrade_lock_path(layout); + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(&path) + .context(LockSnafu { path: &path })?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + return AnotherUpgradeRunningSnafu { path }.fail(); + } + return Err(LockSnafu { path }.into_error(err)); + } + Ok(UpgradeLock { _file: file }) +} + +/// Remove a stale staging file left behind by a previous crashed run, so +/// repeated failed upgrades converge instead of accumulating debris. A missing +/// staging file is the normal case and not an error. +pub(super) fn clean_stale_staging(layout: &InstallLayout) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + // remove_file, not remove_dir_all: the staging path is a file this pipeline + // owns. Anything else squatting there (a directory, say) is not ours to + // destroy recursively — fail instead. + match std::fs::remove_file(layout.staging_path()) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(source).context(CleanStagingSnafu { + path: layout.staging_path(), + }), + } +} + +/// Hash the staged file with SHA-256 and compare against `expected` (lowercase +/// hex). A mismatch is the integrity gate tripping: the staged file is never +/// executed or installed, and the caller's staging guard removes it. +pub(super) fn verify_staged_digest(staging: &Path, expected: &str) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let mut file = std::fs::File::open(staging).context(ReadStagingSnafu { path: staging })?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).context(ReadStagingSnafu { path: staging })?; + let actual: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + snafu::ensure!( + actual == expected, + ChecksumMismatchSnafu { expected, actual } + ); + Ok(()) +} + +/// Mark the verified staged file executable. Called strictly after +/// [`verify_staged_digest`] succeeds — unverified bytes never gain execute +/// permission. +/// +/// The mode is inherited from the binary being replaced, with the execute bits +/// forced on, rather than hardcoded: the rename installs whatever mode the +/// staged file carries, so a fixed `0o755` would silently widen a deliberately +/// private install (`0o700` on a shared host) to world-readable on every +/// upgrade. `0o755` remains the fallback when the current mode cannot be read, +/// which is what a fresh install would have had anyway. +pub(super) fn make_executable(path: &Path, installed: &Path) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let mode = std::fs::metadata(installed) + .map(|meta| meta.permissions().mode() & 0o7777) + .map_or(0o755, |mode| mode | 0o100 | (mode & 0o044) >> 2); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .context(MakeExecutableSnafu { path }) +} + +/// Read a probe pipe to [`MAX_PROBE_OUTPUT_BYTES`], then drain the rest +/// without buffering it. +/// +/// The cap is what bounds the updater's memory against a chatty artifact; the +/// drain is what keeps a chatty-but-terminating artifact from blocking on a +/// full pipe the probe stopped reading. +async fn read_capped(pipe: Option) -> Vec +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + + let mut captured = Vec::new(); + if let Some(mut pipe) = pipe { + let mut limited = (&mut pipe).take(MAX_PROBE_OUTPUT_BYTES as u64); + let _ = limited.read_to_end(&mut captured).await; + let _ = tokio::io::copy(&mut pipe, &mut tokio::io::sink()).await; + } + captured +} + +/// Run the staged binary with the `version` argument and return its trimmed +/// stdout. +/// +/// The digest proves the bytes match what was published; the probe catches what +/// the digest can't — a correctly-published artifact for the wrong OS or +/// architecture, which fails to exec or exits nonzero here instead of after it +/// has replaced the install. +pub(super) async fn probe_staged_binary(path: &Path) -> Result { + use super::upgrade_error::*; + use snafu::IntoError; + + // A file that was written, closed, made executable, and immediately exec'd + // can transiently fail with ETXTBSY on overlay filesystems: the kernel + // briefly still counts a writer reference after the descriptor closed. + // Container/CI layers hit this; a real install onto a normal filesystem + // never does. Retry the spawn under a bounded delay so the probe is robust + // either way, while a genuinely un-execable artifact still fails once the + // bound is exhausted. Only the spawn is retried here; the running probe + // is bounded separately below. + let mut attempt = 0; + let mut child = loop { + // The probe runs as the leader of its own process group. SIGKILLing + // the direct child reaches exactly one process; an artifact that + // forked would leave descendants running unsupervised after the + // upgrade reported failure and swept the staging file. Group + // membership is inherited, so the group signal in the timeout arm + // reaches them too. (A descendant that re-groups itself — setsid, a + // double fork — is beyond what any parent can contain without + // OS-level isolation; the group kill is the strongest containment a + // CLI has. Leading its own group also keeps the probe out of the + // terminal's foreground group, so a Ctrl-C reaches tapesctl but not + // the artifact — the cost of a group the parent can kill without + // killing itself.) `kill_on_drop` stays on as the backstop for paths that + // drop the child without reaching that arm, e.g. the whole upgrade + // future being cancelled. + let spawned = tokio::process::Command::new(path) + .arg("version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .process_group(0) + .kill_on_drop(true) + .spawn(); + match spawned { + Ok(child) => break child, + Err(source) + if source.kind() == std::io::ErrorKind::ExecutableFileBusy + && attempt + 1 < MAX_PROBE_SPAWN_ATTEMPTS => + { + attempt += 1; + tokio::time::sleep(PROBE_SPAWN_RETRY_DELAY).await; + } + Err(source) => return Err(ProbeSpawnSnafu { path }.into_error(source)), + } + }; + + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + let ran = tokio::time::timeout(PROBE_TIMEOUT, async { + let (stdout, stderr) = tokio::join!(read_capped(stdout_pipe), read_capped(stderr_pipe)); + (stdout, stderr, child.wait().await) + }) + .await; + let (stdout, stderr, status) = match ran { + Ok((stdout, stderr, Ok(status))) => (stdout, stderr, status), + // A wait that itself errors is as good as a binary that would not + // run; it shares the spawn variant rather than growing one for a + // path no platform is known to take. + Ok((_, _, Err(source))) => return Err(ProbeSpawnSnafu { path }.into_error(source)), + Err(_) => { + // The child is still owned here, unreaped, so its pid — which + // `process_group(0)` made the group id — cannot have been + // recycled. Kill the whole group, then reap the direct child so + // no zombie outlives the probe; SIGKILL cannot be ignored, so + // the reap completes promptly. A negative pid addresses the + // process group. + if let Some(pid) = child.id() { + unsafe { libc::kill(-(pid as i32), libc::SIGKILL) }; + } + let _ = child.start_kill(); + let _ = child.wait().await; + return ProbeTimedOutSnafu { + path, + timeout: PROBE_TIMEOUT, + } + .fail(); + } + }; + let truncate = |bytes: &[u8]| { + String::from_utf8_lossy(&bytes[..bytes.len().min(MAX_PROBE_OUTPUT_BYTES)]) + .trim() + .to_owned() + }; + snafu::ensure!( + status.success(), + ProbeFailedSnafu { + path, + status: status.code(), + stderr: truncate(&stderr), + } + ); + Ok(truncate(&stdout)) +} + +/// Check that the probed binary reports the version the pipeline resolved. +/// +/// The digest proves the bytes match what the bucket published; this catches +/// what the digest can't — a prefix serving the wrong build (a stale `latest/`, +/// a mispublished pinned version). The probe output is scanned by whitespace +/// token because `tapesctl version` prints a multi-line block — +/// `tapesctl `, the sha, the build date, then the canary — and any +/// token that parses to the same version as `expected` passes. The stamped +/// version carries the commit as build metadata (`v0.7.0+3f2a1b9`), which +/// `versions` ignores for equality, so it matches the bucket's bare `v0.7.0`. +/// +/// Callers skip this for Nightly, which carries no orderable version to compare +/// against — and if `expected` itself is unorderable (impossible for today's +/// Latest/Pinned targets, both parse-gated upstream) there is likewise nothing +/// to compare, so the check passes. +pub(super) fn verify_probed_version( + path: &Path, + probed: &str, + expected: &str, +) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let Some(expected_version) = parse_comparable_version(expected) else { + return Ok(()); + }; + let matched = probed + .split_whitespace() + .any(|token| parse_comparable_version(token).is_some_and(|v| v == expected_version)); + snafu::ensure!( + matched, + ProbeVersionMismatchSnafu { + path, + expected, + probed: probed.trim(), + } + ); + Ok(()) +} + +/// Flush the staged file to disk, then atomically rename it over the installed +/// binary. +/// +/// The fsync-before-rename ordering means a crash at any point leaves either +/// the old binary or the complete new one — never a torn file. A running +/// process keeps executing its old inode through the swap. +pub(super) fn commit_staged_binary(layout: &InstallLayout) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let staging = layout.staging_path(); + let file = std::fs::File::open(staging).context(FsyncSnafu { path: staging })?; + file.sync_all().context(FsyncSnafu { path: staging })?; + drop(file); + std::fs::rename(staging, layout.tapesctl_path()).context(RenameSnafu { + from: staging, + to: layout.tapesctl_path(), + }) +} + +/// Removes the staging file on drop unless the upgrade committed it. +/// +/// Constructed right after the download begins and disarmed only once the +/// staged file has been renamed over the target, so every abort path — early +/// return, `?`, panic — funnels through one cleanup and can never leave a stale +/// `.tapesctl.new` behind. Removal is best-effort: cleanup failure must not +/// mask the error that aborted the upgrade. +#[derive(Debug)] +pub(super) struct StagingGuard { + /// The staging file to remove on drop. + path: PathBuf, + /// Whether drop should still remove the file. + armed: bool, +} + +impl StagingGuard { + /// Guard the staging file at `path`, armed. + pub(super) fn new(path: impl Into) -> Self { + Self { + path: path.into(), + armed: true, + } + } + + /// Disarm after the staged file has been renamed over the target — the file + /// no longer exists under the staging name, and the rename's destination + /// must not be touched. + pub(super) fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for StagingGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + const PLATFORM: BucketPlatform = BucketPlatform { + os: "darwin", + arch: "arm64", + }; + + #[test] + fn the_upgrade_lock_is_exclusive_and_released_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("tapesctl"); + std::fs::write(&exe, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&exe).unwrap(); + + let held = acquire_upgrade_lock(&layout).unwrap(); + let contested = acquire_upgrade_lock(&layout); + assert!( + matches!(contested, Err(UpgradeError::AnotherUpgradeRunning { .. })), + "got: {contested:?}" + ); + + drop(held); + // Reacquisition can transiently fail under a parallel test run: a + // sibling test's fork duplicates this process's descriptors between + // our flock and the close, and the duplicated description holds the + // lock until that child execs (CLOEXEC then closes it). Bounded + // retry, because the property under test is eventual release, which + // that microsecond window does not contradict. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match acquire_upgrade_lock(&layout) { + Ok(_) => break, + Err(UpgradeError::AnotherUpgradeRunning { .. }) + if std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(other) => panic!("lock not released after drop: {other:?}"), + } + } + } + + #[test] + fn the_rolling_targets_parse_by_name() { + assert_eq!( + UpgradeTarget::parse("latest").unwrap(), + UpgradeTarget::Latest + ); + assert_eq!( + UpgradeTarget::parse("nightly").unwrap(), + UpgradeTarget::Nightly + ); + } + + #[test] + fn a_version_parses_with_or_without_its_leading_v() { + // Both spellings must reach the same bucket objects — a user who types + // the tag as they see it in a release page and one who types the bare + // number are asking for the same build. + let with = UpgradeTarget::parse("v0.7.0").unwrap(); + let without = UpgradeTarget::parse("0.7.0").unwrap(); + assert_eq!(with, without); + assert_eq!(with.bucket_prefix(), "v0.7.0"); + } + + #[test] + fn junk_is_refused_rather_than_held_as_a_mess() { + // `versions` would happily keep "not-a-version" as a Mess and let it + // travel all the way to a 404. The numeric-first-component gate is what + // turns it into an error the user can read. + assert!(UpgradeTarget::parse("not-a-version").is_err()); + assert!(UpgradeTarget::parse("").is_err()); + } + + #[test] + fn the_arguments_select_the_target() { + assert_eq!( + target_from_args(None, false).unwrap(), + UpgradeTarget::Latest + ); + assert_eq!( + target_from_args(None, true).unwrap(), + UpgradeTarget::Nightly + ); + assert_eq!( + target_from_args(Some("v0.6.0"), false).unwrap(), + UpgradeTarget::parse("v0.6.0").unwrap() + ); + } + + #[test] + fn artifact_urls_address_the_binary_and_its_digest_sidecar() { + let (binary, digest) = + artifact_urls("https://download.tapes.dev/tapesctl", "latest", PLATFORM); + assert_eq!( + binary, + "https://download.tapes.dev/tapesctl/latest/darwin/arm64/tapesctl" + ); + assert_eq!(format!("{binary}.sha256"), digest); + } + + #[test] + fn a_trailing_slash_on_the_base_url_does_not_double_up() { + let (binary, _) = + artifact_urls("https://download.tapes.dev/tapesctl/", "nightly", PLATFORM); + assert_eq!( + binary, + "https://download.tapes.dev/tapesctl/nightly/darwin/arm64/tapesctl" + ); + } + + #[test] + fn a_digest_mismatch_is_reported_with_both_sides() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write(&staged, b"the wrong bytes").unwrap(); + + let err = verify_staged_digest(&staged, &"0".repeat(64)).unwrap_err(); + + assert!( + matches!(err, UpgradeError::ChecksumMismatch { .. }), + "got: {err:?}" + ); + // Both digests belong in the message: "mismatch" alone leaves the user + // unable to tell a corrupted download from a mispublished sidecar. + let rendered = err.to_string(); + assert!(rendered.contains(&"0".repeat(64)), "got: {rendered}"); + } + + #[test] + fn a_matching_digest_passes() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write(&staged, b"payload").unwrap(); + let expected: String = Sha256::digest(b"payload") + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + assert!(verify_staged_digest(&staged, &expected).is_ok()); + } + + #[test] + fn the_probed_version_matches_through_its_build_metadata() { + // What a stamped release actually prints: the version carries the + // commit as semver build metadata, and the block continues with the + // sha, the date, and the canary. The bucket publishes the bare tag. + let probed = "tapesctl v0.7.0+3f2a1b9\nSha: 3f2a1b9c0d\nBuilt at: unknown\n\ + All in all, just another tape in the stereo"; + assert!(verify_probed_version(Path::new("/staged"), probed, "v0.7.0").is_ok()); + } + + #[test] + fn a_prefix_serving_the_wrong_build_is_caught() { + // The failure the digest cannot see: the bytes are exactly what the + // bucket published, but the bucket published the wrong build behind + // this prefix. + let probed = "tapesctl v0.6.0+aaaaaaa"; + let err = verify_probed_version(Path::new("/staged"), probed, "v0.7.0").unwrap_err(); + assert!( + matches!(err, UpgradeError::ProbeVersionMismatch { .. }), + "got: {err:?}" + ); + } + + #[test] + fn an_unorderable_expectation_skips_the_comparison() { + // Nightly never reaches here, but an expectation that cannot be parsed + // has nothing to compare against — passing is the only honest answer. + assert!(verify_probed_version(Path::new("/staged"), "anything at all", "nightly").is_ok()); + } + + #[test] + fn a_stale_staging_file_is_swept_and_a_missing_one_is_fine() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("tapesctl"); + std::fs::write(&binary, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&binary).unwrap(); + + // Given debris from a previous crashed run + std::fs::write(layout.staging_path(), b"half a download").unwrap(); + clean_stale_staging(&layout).unwrap(); + assert!(!layout.staging_path().exists()); + + // And sweeping again, with nothing there, is not an error — repeated + // failures have to converge rather than accumulate. + assert!(clean_stale_staging(&layout).is_ok()); + } + + #[test] + fn the_staging_guard_cleans_up_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join(".tapesctl.new"); + + // An aborted run: the guard drops armed and takes the file with it + std::fs::write(&staged, b"partial").unwrap(); + drop(StagingGuard::new(&staged)); + assert!(!staged.exists(), "an armed guard should remove the file"); + + // A committed run: the name now belongs to the rename's destination, + // so a disarmed guard must not touch it + std::fs::write(&staged, b"committed").unwrap(); + let mut guard = StagingGuard::new(&staged); + guard.disarm(); + drop(guard); + assert!(staged.exists(), "a disarmed guard must leave the file"); + } + + #[test] + fn committing_replaces_the_binary_atomically() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("tapesctl"); + std::fs::write(&binary, b"old").unwrap(); + let layout = InstallLayout::from_exe_path(&binary).unwrap(); + std::fs::write(layout.staging_path(), b"new").unwrap(); + + commit_staged_binary(&layout).unwrap(); + + assert_eq!(std::fs::read(layout.tapesctl_path()).unwrap(), b"new"); + assert!( + !layout.staging_path().exists(), + "the staged name is consumed by the rename" + ); + } + + #[test] + fn the_swap_keeps_a_private_install_private() { + // Given a deliberately private install — 0o700 on a shared host + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("tapesctl"); + std::fs::write(&installed, b"old").unwrap(); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o700)).unwrap(); + let staged = dir.path().join(".tapesctl.new"); + std::fs::write(&staged, b"new").unwrap(); + + // When the staged replacement is marked executable + make_executable(&staged, &installed).unwrap(); + + // Then it carries the install's mode, not a hardcoded 0o755 — the + // rename installs this mode, so inventing one would quietly publish a + // private binary to every user on the box. + let mode = std::fs::metadata(&staged).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o700, "got {mode:o}"); + } + + #[test] + fn a_readable_install_gains_the_matching_execute_bits() { + // The ordinary case: 0o644 on disk (a download that was never chmod'd) + // must come out executable for everyone who can read it. + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("tapesctl"); + std::fs::write(&installed, b"old").unwrap(); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o644)).unwrap(); + let staged = dir.path().join(".tapesctl.new"); + std::fs::write(&staged, b"new").unwrap(); + + make_executable(&staged, &installed).unwrap(); + + let mode = std::fs::metadata(&staged).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o755, "got {mode:o}"); + } + + #[tokio::test] + async fn a_hanging_artifact_does_not_hang_the_upgrade() { + // The probe executes bucket-supplied code. An artifact that blocks + // forever must not park the upgrade with an executable staging file on + // disk, where a Ctrl-C would skip Drop and leave it behind. + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + let pid_file = dir.path().join("probe.pid"); + let descendant_file = dir.path().join("descendant.pid"); + std::fs::write( + &staged, + format!( + "#!/bin/sh\necho $$ > \"{}\"\nsleep 300 &\necho $! > \"{}\"\nexec sleep 300\n", + pid_file.display(), + descendant_file.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let started = tokio::time::Instant::now(); + let err = tokio::time::timeout( + std::time::Duration::from_secs(60), + probe_staged_binary(&staged), + ) + .await + .expect("the probe must return on its own, not via this outer bound") + .unwrap_err(); + + assert!( + matches!(err, UpgradeError::ProbeTimedOut { .. }), + "got: {err:?}" + ); + assert!(started.elapsed() < std::time::Duration::from_secs(30)); + + // And the bucket-supplied processes must be dead, not merely + // abandoned: the dropped future only borrowed the child, so nothing + // dies with the drop — both the direct child and the backgrounded + // descendant die because the timeout arm SIGKILLs the process group + // the probe was spawned into. Reaping is asynchronous, so poll until + // both pids are gone rather than asserting on the first look. + let read_pid = |file: &std::path::Path, who: &str| -> i32 { + std::fs::read_to_string(file) + .unwrap_or_else(|_| panic!("{who} should have started")) + .trim() + .parse() + .unwrap() + }; + let pid = read_pid(&pid_file, "the probe child"); + let descendant = read_pid(&descendant_file, "the probe child's descendant"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + // Signal 0 probes existence without an external `kill` binary, + // which a minimal container may not ship. A zombie still counts + // as existing until it is reaped. + let alive: Vec = [pid, descendant] + .into_iter() + .filter(|&p| unsafe { libc::kill(p, 0) } == 0) + .collect(); + if alive.is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "probe processes {alive:?} still running after the probe timed out" + ); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + + #[tokio::test] + async fn a_chatty_artifact_is_capped_and_drained_not_deadlocked() { + // Past the cap, output is discarded rather than buffered or left in + // the pipe: a probe that stopped reading would fill the pipe and + // deadlock a chatty-but-terminating artifact into the timeout path. + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write( + &staged, + "#!/bin/sh\nyes chatty | head -c 200000\nprintf 'and then some'\n", + ) + .unwrap(); + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let out = probe_staged_binary(&staged).await.unwrap(); + + assert!(out.len() <= MAX_PROBE_OUTPUT_BYTES, "len: {}", out.len()); + assert!(out.starts_with("chatty"), "unexpected start of output"); + } + + #[tokio::test] + async fn a_binary_that_will_not_execute_fails_the_probe() { + // The wrong-architecture signature: correctly published bytes that + // this host cannot run. Caught before the swap, not after. + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write(&staged, b"\x7fELF not really").unwrap(); + make_executable(&staged, &staged).unwrap(); + + let err = probe_staged_binary(&staged).await.unwrap_err(); + + assert!( + matches!( + err, + UpgradeError::ProbeSpawn { .. } | UpgradeError::ProbeFailed { .. } + ), + "got: {err:?}" + ); + } +} diff --git a/crates/tapesctl/src/upgrade/http.rs b/crates/tapesctl/src/upgrade/http.rs new file mode 100644 index 0000000..593ff80 --- /dev/null +++ b/crates/tapesctl/src/upgrade/http.rs @@ -0,0 +1,431 @@ +//! HTTP-facing mechanics for `tapesctl upgrade`: the timeout-configured +//! client, target resolution against the release bucket, and the +//! streamed/capped object transfers. +//! +//! Split from [`super::artifact`], which owns the local file mechanics +//! (staging, digest verification, probing, the swap). Every function upholds +//! the pipeline's abort contract: a failure leaves the installed binary +//! byte-identical. + +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::time::Duration; + +use reqwest::Client; +use snafu::{OptionExt, ResultExt}; +use versions::Versioning; + +use super::artifact::UpgradeTarget; +use super::{LATEST_VERSION_PATH, UpgradeError}; + +/// Connect timeout for every bucket request. The bucket is a CDN, and a connect +/// that has not completed within 2 s is down, not slow. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); + +/// Overall per-request timeout for the small metadata objects — the +/// latest-version lookup and the `.sha256` digest, both well under a kilobyte. +const METADATA_TIMEOUT: Duration = Duration::from_secs(2); + +/// Per-read stall timeout, applied client-wide. The binary download +/// deliberately carries NO overall timeout — artifact size and link speed vary +/// too much for one number to be honest, and a hard cap would false-abort big +/// downloads on slow links. A stalled transfer is caught here instead: a +/// connection that delivers no bytes for this long is dead, while a +/// slow-but-moving multi-minute download never trips it. +const READ_STALL_TIMEOUT: Duration = Duration::from_secs(30); + +/// Cap on the latest-version response body. A version label is a handful of +/// bytes; anything larger is not one. +const MAX_VERSION_BODY_BYTES: u64 = 128; + +/// Hops a bucket redirect may take before the transfer is refused. +const MAX_REDIRECTS: usize = 5; + +/// Sanity ceiling on the downloaded artifact. +/// +/// The transfer carries no overall *timeout* by design (see +/// [`READ_STALL_TIMEOUT`]), which leaves size as the only bound — without one, +/// a server streaming indefinitely fills the filesystem the install directory +/// lives on, usually `/` or `$HOME`. Well past any honest artifact: the +/// released binary is tens of megabytes. +const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; + +/// Cap on the `.sha256` response body. sha256sum format is ~75 bytes; 1 KiB is +/// generous headroom while still refusing to buffer a misconfigured endpoint's +/// unbounded junk. +const MAX_CHECKSUM_BODY_BYTES: u64 = 1024; + +/// Build the HTTP client the pipeline uses for every bucket request, with the +/// connect and per-read stall timeouts baked in (see [`CONNECT_TIMEOUT`] / +/// [`READ_STALL_TIMEOUT`]). +pub(super) fn build_client() -> Result { + use super::upgrade_error::*; + + Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_STALL_TIMEOUT) + // Redirects are followed only while they stay on https. reqwest's + // default policy would happily follow https → http, and the digest + // gate is no defense there: anything able to redirect the binary can + // redirect the `.sha256` beside it, since both travel this same + // client. The rest of this crate already refuses redirects outright on + // the tapes API for a related reason — a silently followed redirect + // destroys the diagnosis — so downgrading the transport here would be + // the odd one out. + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.url().scheme() != "https" { + attempt.error("refusing to follow a redirect off https") + } else if attempt.previous().len() >= MAX_REDIRECTS { + attempt.error("too many redirects") + } else { + attempt.follow() + } + })) + .build() + .context(BuildHttpClientSnafu) +} + +/// Read at most `cap` bytes of `response`'s body; `Ok(None)` when the declared +/// or streamed length exceeds the cap. +async fn read_body_capped( + mut response: reqwest::Response, + cap: u64, +) -> Result>, reqwest::Error> { + if response.content_length().is_some_and(|len| len > cap) { + return Ok(None); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + // Checked before appending: growing past the cap and then noticing + // would buffer one whole frame beyond what the cap permits. + if body.len() as u64 + chunk.len() as u64 > cap { + return Ok(None); + } + body.extend_from_slice(&chunk); + } + Ok(Some(body)) +} + +/// What resolving an [`UpgradeTarget`] against the bucket decided: the install +/// is already current, or the pipeline should download from `prefix` and +/// announce `version` on success. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Resolution { + /// The installed version already satisfies the target. + AlreadyCurrent { + /// The version that is both installed and targeted. + version: String, + }, + /// A download is warranted. + Download { + /// Bucket prefix to fetch from (`latest`, `nightly`, `v`). + prefix: String, + /// Version label to report as the upgrade's destination. + version: String, + }, +} + +/// Resolve `target` against the bucket at `base_url`. +/// +/// `Latest` fetches the plain-text `latest/version` object and compares it with +/// `current_version`; `Pinned` compares without any network call; `Nightly` +/// always resolves to a download because nightly builds carry no orderable +/// version. +pub(super) async fn resolve_target( + client: &Client, + base_url: &str, + current_version: &str, + target: &UpgradeTarget, +) -> Result { + use super::upgrade_error::*; + + let current = parse_comparable_version(current_version); + match target { + UpgradeTarget::Nightly => Ok(Resolution::Download { + prefix: target.bucket_prefix(), + version: "nightly".to_owned(), + }), + UpgradeTarget::Pinned(version) => { + if current.as_ref() == Some(version) { + // `v`-prefixed like the Latest arm's raw body, so the same + // state does not print two different spellings depending on + // how the user asked for it. + Ok(Resolution::AlreadyCurrent { + version: format!("v{version}"), + }) + } else { + Ok(Resolution::Download { + prefix: target.bucket_prefix(), + version: version.to_string(), + }) + } + } + UpgradeTarget::Latest => { + let url = format!("{}/{LATEST_VERSION_PATH}", base_url.trim_end_matches('/')); + let response = client + .get(&url) + .timeout(METADATA_TIMEOUT) + .send() + .await + .context(FetchLatestVersionSnafu { url: &url })?; + snafu::ensure!( + response.status().is_success(), + LatestVersionStatusSnafu { + url: &url, + status: response.status(), + } + ); + // Capped read: a version object is a handful of bytes, so a bigger + // body is not a version — never buffer it whole. + let body = read_body_capped(response, MAX_VERSION_BODY_BYTES) + .await + .context(FetchLatestVersionSnafu { url: &url })? + .context(MalformedLatestVersionSnafu { + value: format!("(body larger than {MAX_VERSION_BODY_BYTES} bytes)"), + })?; + let raw = String::from_utf8_lossy(&body).trim().to_owned(); + let latest = parse_comparable_version(&raw) + .context(MalformedLatestVersionSnafu { value: &raw })?; + if current == Some(latest) { + Ok(Resolution::AlreadyCurrent { version: raw }) + } else { + Ok(Resolution::Download { + prefix: target.bucket_prefix(), + version: raw, + }) + } + } + } +} + +/// Parse a version label for comparison: trimmed, optional leading `v`, and a +/// required numeric first component. +/// +/// `None` for the labels that identify no orderable version — an unstamped +/// build, a nightly, junk. A `None` on the *installed* side means version +/// comparison cannot rule the download out, so the pipeline downloads, which is +/// the right answer for both a dev build and a nightly. +/// +/// The name is split from its build metadata before the sentinel check, because +/// what a stamped build reports is `nightly+3f2a1b9`, not a bare `nightly` — the +/// commit is what distinguishes one nightly from the next, so it is always +/// there. Without the split the sentinel would never match and the answer would +/// fall through to whether `versions` happens to reject the whole string. +pub(super) fn parse_comparable_version(label: &str) -> Option { + let trimmed = label.trim(); + let name = trimmed.split('+').next().unwrap_or(trimmed); + if matches!(name, "" | "dev" | "nightly") { + return None; + } + Versioning::new(trimmed.strip_prefix('v').unwrap_or(trimmed)).filter(|v| v.nth(0).is_some()) +} + +/// Stream the binary at `url` into the staging file. +/// +/// The staging file is created non-executable: execute permission is only +/// granted after the digest verifies, so a half-written or tampered download is +/// never a runnable file. +pub(super) async fn download_to_staging( + client: &Client, + url: &str, + staging: &Path, +) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let mut response = client + .get(url) + .send() + .await + .context(DownloadSnafu { url })?; + snafu::ensure!( + response.status().is_success(), + DownloadStatusSnafu { + url, + status: response.status(), + } + ); + // `create_new` + 0o600, not `File::create`, for three reasons that all bite + // the same file: + // + // * `create_new` fails rather than following a symlink planted at the + // staging path, which would otherwise send the download to the link's + // target and hand the subsequent chmod to that target instead. + // * It also fails when a concurrent upgrade already owns the staging + // name — two runs sharing one fixed path would otherwise interleave, + // letting one verify bytes the other has since replaced. + // * 0o600 keeps the partially-downloaded, not-yet-verified bytes + // unreadable by anyone else and unexecutable by everyone, closing the + // window that `File::create`'s truncate-in-place left open when a + // previous run died between chmod and rename. + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(staging) + .context(WriteStagingSnafu { path: staging })?; + let mut written: u64 = 0; + while let Some(chunk) = response.chunk().await.context(DownloadSnafu { url })? { + written += chunk.len() as u64; + snafu::ensure!( + written <= MAX_ARTIFACT_BYTES, + ArtifactTooLargeSnafu { + url, + cap: MAX_ARTIFACT_BYTES + } + ); + file.write_all(&chunk) + .context(WriteStagingSnafu { path: staging })?; + } + Ok(()) +} + +/// Fetch the published `.sha256` object at `url` and return the expected digest +/// as lowercase hex. +/// +/// The object body is sha256sum format (` `); only the digest +/// field is used. A 404 is [`UpgradeError::MissingChecksum`] — an absent +/// checksum aborts the upgrade rather than skipping verification. +pub(super) async fn fetch_expected_digest( + client: &Client, + url: &str, +) -> Result { + use super::upgrade_error::*; + + let response = client + .get(url) + .timeout(METADATA_TIMEOUT) + .send() + .await + .context(DownloadSnafu { url })?; + // Any non-success answer for the sidecar is treated as "no publishable + // checksum here", not just 404: an S3-compatible bucket without + // `ListBucket` answers 403 for a missing object, and a gateway may answer + // 5xx. All of them mean the same thing to us — we cannot verify — and the + // message that says so is the one worth printing. + snafu::ensure!( + response.status().is_success(), + MissingChecksumSnafu { + url, + status: response.status() + } + ); + // Capped read: a sha256sum line is under 100 bytes, so a bigger body is not + // a checksum — never buffer it whole. + let body = read_body_capped(response, MAX_CHECKSUM_BODY_BYTES) + .await + .context(DownloadSnafu { url })? + .context(MalformedChecksumSnafu { + value: format!("(body larger than {MAX_CHECKSUM_BODY_BYTES} bytes)"), + })?; + let body = String::from_utf8_lossy(&body).into_owned(); + let digest = body + .split_whitespace() + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + snafu::ensure!( + digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()), + MalformedChecksumSnafu { value: body.trim() } + ); + Ok(digest) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn a_stamped_release_compares_equal_to_the_bare_tag() { + // The bucket publishes `v0.7.0`; the binary reports `v0.7.0+3f2a1b9`. + // Build metadata does not affect precedence, so these are the same + // release — which is what makes "already up to date" work at all. + assert_eq!( + parse_comparable_version("v0.7.0+3f2a1b9"), + parse_comparable_version("v0.7.0") + ); + } + + #[test] + fn the_labels_that_name_no_release_are_unorderable() { + // Each of these means "comparison cannot rule the download out". The + // stamped forms carry a commit, which is exactly why the sentinel check + // has to look past the build metadata. + for label in ["", "dev", "nightly", "nightly+3f2a1b9", "not-a-version"] { + assert!( + parse_comparable_version(label).is_none(), + "{label:?} should not be orderable" + ); + } + } + + #[test] + fn an_unstamped_development_build_is_orderable_but_never_current() { + // `0.0.0-dev+sha` parses — it is a prerelease of 0.0.0 — and that is + // fine: it compares unequal to every real release, so a developer + // running `upgrade` downloads rather than being told they are current. + let dev = parse_comparable_version("0.0.0-dev+3f2a1b9").unwrap(); + assert_ne!(Some(dev), parse_comparable_version("v0.7.0")); + } + + #[tokio::test] + async fn a_pinned_target_matching_the_install_needs_no_network() { + // The client points nowhere reachable on purpose: resolving a pinned + // target that equals the installed version must not touch the network. + let client = build_client().unwrap(); + let target = UpgradeTarget::parse("v0.7.0").unwrap(); + + let resolution = resolve_target(&client, "http://127.0.0.1:1", "v0.7.0+abc1234", &target) + .await + .unwrap(); + + assert_eq!( + resolution, + Resolution::AlreadyCurrent { + version: "v0.7.0".to_owned() + } + ); + } + + #[tokio::test] + async fn a_pinned_downgrade_resolves_to_that_version_s_prefix() { + let client = build_client().unwrap(); + let target = UpgradeTarget::parse("v0.6.0").unwrap(); + + let resolution = resolve_target(&client, "http://127.0.0.1:1", "v0.7.0+abc1234", &target) + .await + .unwrap(); + + // Direction-agnostic: a bad release needs an escape hatch. + assert_eq!( + resolution, + Resolution::Download { + prefix: "v0.6.0".to_owned(), + version: "0.6.0".to_owned() + } + ); + } + + #[tokio::test] + async fn nightly_always_downloads_without_asking_the_bucket() { + let client = build_client().unwrap(); + + let resolution = resolve_target( + &client, + "http://127.0.0.1:1", + "nightly+abc1234", + &UpgradeTarget::Nightly, + ) + .await + .unwrap(); + + assert_eq!( + resolution, + Resolution::Download { + prefix: "nightly".to_owned(), + version: "nightly".to_owned() + } + ); + } +} diff --git a/crates/tapesctl/src/upgrade/mod.rs b/crates/tapesctl/src/upgrade/mod.rs new file mode 100644 index 0000000..6eb5946 --- /dev/null +++ b/crates/tapesctl/src/upgrade/mod.rs @@ -0,0 +1,527 @@ +//! Crash-safe self-update: download, verify, probe, and atomically swap the +//! running binary. +//! +//! Resolves an [`UpgradeTarget`] against the release bucket, downloads the +//! published binary and its `.sha256` digest into a staging dotfile, verifies +//! the digest, sanity-probes the staged file, and atomically renames it over +//! the running executable. Every path is derived from [`InstallLayout`], so the +//! pipeline always acts on the binary the user is actually running. +//! +//! Invariants the pipeline is built around: +//! +//! * The staging file lives **inside the install directory** +//! ([`InstallLayout::staging_path`]), never `/tmp`: `rename(2)` is only +//! atomic within one filesystem, and a cross-device "rename" degrades to the +//! corruptible copy+delete this design exists to close off. +//! * Nothing executes the staged file before its digest verifies, and the +//! staged file is only marked executable after verification. The installed +//! binary is only ever replaced by the atomic rename — no truncate-and-write, +//! no copy over the live inode. +//! * A missing published `.sha256` object aborts the upgrade. There is no +//! unverified-download fallback. +//! * Every abort leaves the installed binary byte-identical and the staging +//! file removed; a fresh run also sweeps any staging file a crashed previous +//! run left behind, so repeated failures converge. +//! * No escalation, ever. An unwritable install directory is a clean, typed +//! refusal whose message names the installer as the remediation. +//! * One pipeline at a time: an exclusive `flock(2)` on a lock file in the +//! install directory serializes concurrent `tapesctl upgrade` runs. Two +//! runs sharing one staging path would otherwise unlink each other's +//! verified bytes and could commit a partial download as the live binary. +//! * The probe is a sanity check, not a sandbox. Its containment of the +//! staged binary is best-effort by design: an adversarial bucket able to +//! serve hostile *verified* bytes defeats the pipeline at the swap, not at +//! the probe, so the security boundary is the digest gate — the probe's job +//! is catching honest mistakes (wrong arch, wrong build) before they are +//! installed. + +pub mod artifact; +mod http; + +use std::io::Write; +use std::path::PathBuf; + +use snafu::{ResultExt, Snafu}; + +use crate::install_layout::{EnsureWritableError, InstallLayout, InstallLayoutError}; +use crate::uninstall::INSTALL_COMMAND; +use artifact::{ + StagingGuard, acquire_upgrade_lock, artifact_urls, bucket_platform, clean_stale_staging, + commit_staged_binary, make_executable, probe_staged_binary, target_from_args, + verify_probed_version, verify_staged_digest, +}; +use http::{Resolution, build_client, download_to_staging, fetch_expected_digest, resolve_target}; + +pub use artifact::{ParseTargetError, UpgradeTarget}; + +/// Production release bucket, including the `tapesctl` namespace the artifacts +/// live under. +/// +/// Layout: `///tapesctl` and +/// `///tapesctl.sha256`, where `` is `latest`, +/// `nightly`, or `v`; `` is `linux` or `darwin`; `` is the +/// normalized `amd64` or `arm64`. `latest/version` is the plain-text newest +/// release version, published by the release pipeline in the same call as the +/// binaries. +const DOWNLOAD_BASE_URL: &str = "https://download.tapes.dev/tapesctl"; + +/// Env override for the release bucket base URL. Internal seam, not user +/// surface: it lets the end-to-end test point a real binary at a localhost +/// bucket. Deliberately read from the environment in the production entry +/// ([`run`]) and never a clap flag, so it stays out of help output and out of +/// the supported CLI contract. +pub const DOWNLOAD_BASE_URL_ENV: &str = "TAPESCTL_DOWNLOAD_BASE_URL"; + +/// Path under a base URL of the plain-text latest-version object. +pub(super) const LATEST_VERSION_PATH: &str = "latest/version"; + +/// Successful result of an upgrade run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpgradeOutcome { + /// The installed binary already matches the target; nothing was downloaded + /// and nothing on disk changed. + AlreadyCurrent { + /// The version that is both installed and targeted. + version: String, + }, + /// The installed binary was atomically replaced. + Upgraded { + /// Version label the binary reported before the swap. + from: String, + /// Version label now installed. + to: String, + }, +} + +/// Run `tapesctl upgrade`: resolve the target from the CLI arguments, drive the +/// swap pipeline, and report the outcome. +/// +/// The bucket base URL is [`DOWNLOAD_BASE_URL`] unless the hidden +/// [`DOWNLOAD_BASE_URL_ENV`] override is set; the current version is the one +/// this binary was stamped with. Already current → prints "already up to date", +/// exits 0, nothing touched. Upgraded → prints `old → new`. +/// +/// Unlike paperctl's, this pipeline ends at the swap: tapesctl runs no daemon +/// and installs no symlink, so there is nothing to re-point or bounce +/// afterwards. A capture already in flight keeps running on its old inode and +/// the next invocation is the new binary. +pub async fn run(version: Option, nightly: bool) -> Result<(), UpgradeCliError> { + use upgrade_cli_error::*; + + let target = target_from_args(version.as_deref(), nightly).context(InvalidTargetSnafu)?; + let layout = InstallLayout::from_current_exe().context(ResolveLayoutSnafu)?; + let base_url = + std::env::var(DOWNLOAD_BASE_URL_ENV).unwrap_or_else(|_| DOWNLOAD_BASE_URL.to_owned()); + + run_reporting( + &mut std::io::stdout(), + &layout, + &base_url, + crate::build_info::version(), + &target, + ) + .await +} + +/// Command core with every seam injected: the output writer, the install +/// layout, the bucket base URL, the version the running binary reports, and the +/// parsed target. +pub async fn run_reporting( + out: &mut W, + layout: &InstallLayout, + base_url: &str, + current_version: &str, + target: &UpgradeTarget, +) -> Result<(), UpgradeCliError> +where + W: Write + Send, +{ + use upgrade_cli_error::*; + + match run_at(layout, base_url, current_version, target) + .await + .context(PipelineSnafu)? + { + UpgradeOutcome::AlreadyCurrent { version } => { + writeln!(out, "tapesctl {version} is already up to date.").context(WriteSnafu) + } + UpgradeOutcome::Upgraded { from, to } => { + writeln!(out, "tapesctl upgraded: {from} → {to}").context(WriteSnafu) + } + } +} + +/// Pipeline core with the production seams injected: the install `layout` (a +/// tempdir layout in tests), the bucket `base_url` (wiremock in tests), and +/// `current_version`, the version the running binary reports. +/// +/// Step order — every step aborts the run with the installed binary +/// byte-identical and the staging file removed: +/// +/// 1. refuse an unwritable install directory (the message names the installer +/// as the fix; no escalation is attempted), +/// 2. take the exclusive pipeline lock — concurrent upgrades sharing one +/// staging path would unlink each other's verified bytes — then sweep any +/// stale staging file a crashed previous run left behind, +/// 3. resolve `target` against the bucket, which may short-circuit to +/// [`UpgradeOutcome::AlreadyCurrent`] without downloading, +/// 4. download the binary to the staging dotfile and fetch its published +/// `.sha256` digest, +/// 5. verify the digest — only then mark the staged file executable, +/// 6. probe the staged file (` version`) to catch artifacts the digest +/// can't (a correctly-published wrong-OS/arch build), and — for targets with +/// an orderable version — check the probed version against the resolved one +/// (a prefix serving the wrong build), +/// 7. fsync the staged file and atomically rename it over the installed binary. +pub async fn run_at( + layout: &InstallLayout, + base_url: &str, + current_version: &str, + target: &UpgradeTarget, +) -> Result { + use upgrade_error::*; + + // The writability refusal happens before any network I/O: an unmigrated + // root-owned install gets its instructions instantly, without a byte of + // download it could never apply. + match layout.ensure_writable() { + Ok(()) => {} + Err(EnsureWritableError::NotWritable { dir }) => return NotWritableSnafu { dir }.fail(), + Err(source) => return Err(source).context(WritableCheckSnafu), + } + // One pipeline at a time. Taken before the sweep: the sweep unlinks the + // shared staging path, which is only safe while this run is its sole + // owner. The kernel drops the lock however this process ends. + let _lock = acquire_upgrade_lock(layout)?; + clean_stale_staging(layout)?; + // Hoisted above the client: a host the release pipeline does not publish + // for can never be served, so asking the bucket first would only add a + // round-trip before the same refusal. + let platform = bucket_platform()?; + + let client = build_client()?; + let (prefix, to_version) = + match resolve_target(&client, base_url, current_version, target).await? { + Resolution::AlreadyCurrent { version } => { + return Ok(UpgradeOutcome::AlreadyCurrent { version }); + } + Resolution::Download { prefix, version } => (prefix, version), + }; + + let (binary_url, digest_url) = artifact_urls(base_url, &prefix, platform); + + let mut guard = StagingGuard::new(layout.staging_path()); + download_to_staging(&client, &binary_url, layout.staging_path()).await?; + let expected = fetch_expected_digest(&client, &digest_url).await?; + verify_staged_digest(layout.staging_path(), &expected)?; + make_executable(layout.staging_path(), layout.tapesctl_path())?; + let probed = probe_staged_binary(layout.staging_path()).await?; + // Nightly carries no orderable version to compare the probe against. + if !matches!(target, UpgradeTarget::Nightly) { + verify_probed_version(layout.staging_path(), &probed, &to_version)?; + } + commit_staged_binary(layout)?; + guard.disarm(); + + Ok(UpgradeOutcome::Upgraded { + from: current_version.to_owned(), + to: to_version, + }) +} + +/// Failure modes for the `tapesctl upgrade` command entry ([`run`]). +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum UpgradeCliError { + /// The `--version` argument was not a recognizable target. + #[snafu(display("invalid upgrade target"))] + InvalidTarget { + /// Underlying parse failure. + source: ParseTargetError, + }, + + /// The install layout could not be resolved from the running executable. + #[snafu(display("could not resolve the install layout"))] + ResolveLayout { + /// Underlying layout-construction failure. + source: InstallLayoutError, + }, + + /// The swap pipeline failed; the installed binary is untouched. + #[snafu(display("the upgrade pipeline aborted; the installed binary is untouched"))] + Pipeline { + /// Underlying pipeline failure. + source: UpgradeError, + }, + + /// Could not write progress output. + #[snafu(display("write failed"))] + Write { + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +/// Failure modes for the upgrade pipeline ([`run_at`]). +/// +/// Every variant means the installed binary is still the pre-upgrade file: +/// nothing destructive happens before the final rename, and the rename is +/// atomic. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum UpgradeError { + /// The install directory refuses mutation by the invoking user — the + /// unmigrated root-owned-install state. The remediation lives in the + /// message because this refusal *is* the migration path for old installs; + /// no escalation is ever attempted. + #[snafu(display( + "install directory '{}' is not writable; re-run the installer to migrate: {}", + dir.display(), + INSTALL_COMMAND + ))] + NotWritable { + /// The unwritable install directory. + dir: PathBuf, + }, + + /// Probing install-directory writability failed outright (an I/O error + /// distinct from a clean permission refusal). + #[snafu(display("could not check whether the install directory is writable"))] + WritableCheck { + /// Underlying probe failure. + source: EnsureWritableError, + }, + + /// The running OS/arch has no published artifacts in the bucket. + #[snafu(display("no published builds for {os}/{arch}"))] + UnsupportedPlatform { + /// The running operating system. + os: String, + /// The running architecture. + arch: String, + }, + + /// The HTTP client could not be constructed (TLS backend init — effectively + /// unreachable, but never worth a panic). + #[snafu(display("could not build the HTTP client"))] + BuildHttpClient { + /// Underlying reqwest failure. + source: reqwest::Error, + }, + + /// Removing a stale staging file from a previous crashed run failed. + #[snafu(display("could not remove stale staging file '{}'", path.display()))] + CleanStaging { + /// The staging file being removed. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The latest-version lookup request failed at the transport level. + #[snafu(display("could not fetch the latest published version from '{url}'"))] + FetchLatestVersion { + /// The latest-version endpoint URL. + url: String, + /// Underlying HTTP failure. + source: reqwest::Error, + }, + + /// The latest-version lookup answered a non-success status. + #[snafu(display("latest-version endpoint '{url}' answered {status}"))] + LatestVersionStatus { + /// The latest-version endpoint URL. + url: String, + /// The HTTP status received. + status: reqwest::StatusCode, + }, + + /// The latest-version body did not parse as a version. + #[snafu(display("latest-version endpoint returned unparseable version {value:?}"))] + MalformedLatestVersion { + /// The unparseable response body. + value: String, + }, + + /// Downloading an artifact failed at the transport level. + #[snafu(display("could not download '{url}'"))] + Download { + /// The artifact URL. + url: String, + /// Underlying HTTP failure. + source: reqwest::Error, + }, + + /// An artifact download answered a non-success status. + #[snafu(display("download of '{url}' answered {status}"))] + DownloadStatus { + /// The artifact URL. + url: String, + /// The HTTP status received. + status: reqwest::StatusCode, + }, + + /// The published `.sha256` object is missing. Its absence aborts the + /// upgrade — an unverifiable binary is never installed or executed. + #[snafu(display( + "no published checksum at '{url}' (answered {status}) — refusing to install \ + an unverifiable binary" + ))] + MissingChecksum { + /// The checksum object URL that did not serve a digest. + url: String, + /// What it answered instead. + status: reqwest::StatusCode, + }, + + /// The artifact exceeded the sanity ceiling mid-transfer. + #[snafu(display("'{url}' exceeded the {cap}-byte artifact ceiling"))] + ArtifactTooLarge { + /// The artifact URL. + url: String, + /// The ceiling it exceeded. + cap: u64, + }, + + /// The `.sha256` object's contents were not a sha256sum-format digest line. + #[snafu(display("published checksum is malformed: {value:?}"))] + MalformedChecksum { + /// The unparseable checksum body. + value: String, + }, + + /// Writing downloaded bytes to the staging file failed. + #[snafu(display("could not write staging file '{}'", path.display()))] + WriteStaging { + /// The staging file being written. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// Reading the staged file back for digest verification failed. + #[snafu(display("could not read staging file '{}'", path.display()))] + ReadStaging { + /// The staging file being read. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The staged file's digest does not match the published digest. The staged + /// file was never executed and is removed; the installed binary is + /// untouched. + #[snafu(display("sha256 mismatch: expected {expected}, downloaded file hashes to {actual}"))] + ChecksumMismatch { + /// The digest the bucket published. + expected: String, + /// The digest the downloaded bytes actually hash to. + actual: String, + }, + + /// Marking the verified staged file executable failed. + #[snafu(display("could not mark staged file '{}' executable", path.display()))] + MakeExecutable { + /// The staged file. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The upgrade lock file could not be opened or locked. + #[snafu(display("could not take the upgrade lock at '{}'", path.display()))] + Lock { + /// The lock file. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// Another upgrade holds the pipeline lock right now. + #[snafu(display( + "another tapesctl upgrade is already running (lock '{}' is held); let it finish and retry", + path.display() + ))] + AnotherUpgradeRunning { + /// The held lock file. + path: PathBuf, + }, + + /// The staged binary could not be spawned for the sanity probe (the + /// signature of a wrong-architecture artifact). + #[snafu(display("staged binary '{}' would not execute", path.display()))] + ProbeSpawn { + /// The staged file that failed to exec. + path: PathBuf, + /// Underlying spawn failure. + source: std::io::Error, + }, + + /// The staged binary was spawned but did not finish within the probe + /// bound. The installed binary is untouched and the staged file removed — + /// an artifact that will not answer `version` promptly is not one to + /// install. + #[snafu(display( + "staged binary '{}' did not answer `version` within {timeout:?}", + path.display() + ))] + ProbeTimedOut { + /// The staged file that hung. + path: PathBuf, + /// The bound it exceeded. + timeout: std::time::Duration, + }, + + /// The staged binary ran but did not behave like `tapesctl version`. + #[snafu(display( + "staged binary '{}' failed its sanity probe (exit {status:?}): {stderr}", + path.display() + ))] + ProbeFailed { + /// The staged file that failed the probe. + path: PathBuf, + /// The probe process's exit code, when it exited at all. + status: Option, + /// The probe process's captured stderr. + stderr: String, + }, + + /// The staged binary ran but reported a version other than the one the + /// pipeline resolved — the artifact behind the prefix is not the build it + /// claims to be. The installed binary is untouched. + #[snafu(display( + "staged binary '{}' reports version {probed:?}, expected {expected}", + path.display() + ))] + ProbeVersionMismatch { + /// The staged file that mis-reported its version. + path: PathBuf, + /// The version the pipeline resolved for the target. + expected: String, + /// The probe's actual (trimmed) stdout. + probed: String, + }, + + /// Flushing the staged file to disk before the swap failed. + #[snafu(display("could not fsync staging file '{}'", path.display()))] + Fsync { + /// The staging file being flushed. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The atomic rename over the installed binary failed. The installed binary + /// is untouched — the rename either happened completely or not at all. + #[snafu(display("could not rename '{}' over '{}'", from.display(), to.display()))] + Rename { + /// The staged file being renamed. + from: PathBuf, + /// The installed binary being replaced. + to: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, +} diff --git a/crates/tapesctl/tests/self_upgrade.rs b/crates/tapesctl/tests/self_upgrade.rs new file mode 100644 index 0000000..98c9fc3 --- /dev/null +++ b/crates/tapesctl/tests/self_upgrade.rs @@ -0,0 +1,547 @@ +//! Self-upgrade end to end against the real compiled binary. +//! +//! The unit tests in `upgrade::artifact` and `upgrade::http` drive the pipeline +//! piece by piece; this suite is where the REAL `tapesctl` binary upgrades +//! itself. The compiled binary (`CARGO_BIN_EXE_tapesctl`) is copied into a +//! tempdir install layout, a localhost bucket serves the published objects, and +//! `tapesctl upgrade` runs as a child process with the hidden +//! `TAPESCTL_DOWNLOAD_BASE_URL` seam pointed at the bucket. Assertions observe +//! what a user would: the bytes on disk change and the swapped-in binary still +//! answers `tapesctl version`. +//! +//! Two properties keep the suite hermetic and host-safe: +//! +//! * The served artifact is the compiled binary itself with distinguishing +//! bytes appended (Unix executables ignore trailing data), so the post-verify +//! sanity probe passes while the swap stays observable on disk. +//! * The `nightly` target is the natural fit: it downloads unconditionally, so +//! nothing depends on how the dev build's stamped version compares to a fake +//! "latest". +//! +//! HOME points into a tempdir for every child, so a run can never read or write +//! the developer's real `~/.tapes`, and no request ever leaves localhost. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Output; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Env var the production entry reads as the bucket base URL override. +/// +/// Duplicated from `upgrade` on purpose: this suite observes the binary's +/// contract from the outside, so a rename in the library must break this string +/// — and this suite — loudly. +const DOWNLOAD_BASE_URL_ENV: &str = "TAPESCTL_DOWNLOAD_BASE_URL"; + +/// Basename of the staging dotfile the pipeline writes beside the install. +/// Every abort must leave none behind. +const STAGING_FILE_NAME: &str = ".tapesctl.new"; + +/// Trailing bytes appended to the compiled binary to build the served artifact +/// — present on disk after the swap, absent before it. +const ARTIFACT_MARKER: &[u8] = b"\n#tapesctl-self-upgrade-e2e-marker\n"; + +/// Absolute path of the compiled `tapesctl` binary under test, as cargo stamps +/// it for integration tests. +fn compiled_tapesctl() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_tapesctl")) +} + +/// A tempdir "install": a copy of the real compiled binary named `tapesctl`, +/// executable, alone in its own directory — the shape `InstallLayout` derives +/// every other path from. +struct TestInstall { + /// Owns the directory for the test's duration. + dir: tempfile::TempDir, + /// The installed copy that child processes execute. + tapesctl: PathBuf, +} + +impl TestInstall { + /// Copy [`compiled_tapesctl`] into a fresh tempdir, mode 0o755. + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let tapesctl = dir.path().join("tapesctl"); + fs::copy(compiled_tapesctl(), &tapesctl).unwrap(); + fs::set_permissions(&tapesctl, fs::Permissions::from_mode(0o755)).unwrap(); + Self { dir, tapesctl } + } + + /// The staging dotfile's path beside the install. + fn staging(&self) -> PathBuf { + self.dir.path().join(STAGING_FILE_NAME) + } +} + +/// The compiled binary's bytes with a distinguishing suffix appended: still +/// executable (trailing data is ignored on Unix), byte-distinct from the +/// installed copy, and honestly probe-able — no stand-ins. +fn distinguishable_artifact() -> Vec { + let mut bytes = fs::read(compiled_tapesctl()).unwrap(); + bytes.extend_from_slice(ARTIFACT_MARKER); + bytes +} + +/// The bucket's normalized `/` directory names for the host running +/// the tests. +fn host_platform() -> (&'static str, &'static str) { + let os = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + other => panic!("tests only run on published platforms, not {other}"), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => panic!("tests only run on published architectures, not {other}"), + }; + (os, arch) +} + +/// Lowercase-hex SHA-256 of `bytes`, matching the digest field of the published +/// sha256sum-format objects. +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest as _, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// What the bucket serves for the `.sha256` sidecar. +enum Sidecar { + /// The digest of the served bytes — the healthy case. + Correct, + /// A syntactically valid digest of something else — a corrupted or + /// tampered download. + Wrong, + /// No object at all: 404, the case that must abort rather than degrade to + /// an unverified install. + Missing, +} + +/// Serve a release bucket over localhost: `///tapesctl` plus +/// its sha256sum-format `.sha256` beside it, for the host's normalized +/// platform. +async fn serve_bucket(prefix: &str, binary: &[u8], sidecar: Sidecar) -> MockServer { + let (os, arch) = host_platform(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/{prefix}/{os}/{arch}/tapesctl"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(binary.to_vec())) + .mount(&server) + .await; + let digest_response = match sidecar { + Sidecar::Correct => ResponseTemplate::new(200) + .set_body_string(format!("{} tapesctl\n", sha256_hex(binary))), + Sidecar::Wrong => ResponseTemplate::new(200) + .set_body_string(format!("{} tapesctl\n", sha256_hex(b"other bytes"))), + Sidecar::Missing => ResponseTemplate::new(404), + }; + Mock::given(method("GET")) + .and(path(format!("/{prefix}/{os}/{arch}/tapesctl.sha256"))) + .respond_with(digest_response) + .mount(&server) + .await; + server +} + +/// Run ` ` to completion as a child process with the bucket +/// override set to `base_url` and HOME isolated into `home`. +fn run_tapesctl(tapesctl: &Path, base_url: &str, home: &Path, args: &[&str]) -> Output { + std::process::Command::new(tapesctl) + .args(args) + .env("HOME", home) + // Linux resolves config under XDG_CONFIG_HOME when set; pin it inside + // the isolated HOME so no host value leaks through. + .env("XDG_CONFIG_HOME", home.join(".config")) + // The cassette cache is the other thing a run can write; keep it in the + // tempdir too rather than in the developer's real cache directory. + .env("TAPESCTL_CACHE_DIR", home.join("cache")) + .env(DOWNLOAD_BASE_URL_ENV, base_url) + .output() + .unwrap() +} + +/// The blocking child-process waits in the test bodies must not park the same +/// thread the wiremock bucket serves from, hence the multi-thread runtime. +#[tokio::test(flavor = "multi_thread")] +async fn the_binary_replaces_itself_end_to_end() { + // Given a tempdir install of the real compiled binary + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + + // Given a localhost bucket publishing a byte-distinct nightly artifact with + // its digest beside it + let artifact = distinguishable_artifact(); + assert_ne!( + artifact, original, + "the served artifact must be distinguishable from the install" + ); + let server = serve_bucket("nightly", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + // When `tapesctl upgrade --nightly` runs as a child pointed at the bucket + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it exits 0 and says what it did + assert!( + output.status.success(), + "upgrade --nightly failed ({:?})\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("upgraded"), "stdout: {stdout}"); + + // Then the on-disk binary is exactly the served artifact + let swapped = fs::read(&install.tapesctl).unwrap(); + assert_ne!(swapped, original, "binary on disk was not replaced"); + assert_eq!( + swapped, artifact, + "swapped binary must be exactly the served artifact" + ); + + // Then the swap kept it executable, and left no staging file behind + let mode = fs::metadata(&install.tapesctl) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "swapped binary must stay executable"); + assert!(!install.staging().exists(), "staging file should be gone"); + + // Then the machine is left with a working tool — the whole point of the + // crash-safe pipeline + let version = run_tapesctl(&install.tapesctl, &server.uri(), home.path(), &["version"]); + assert!( + version.status.success(), + "swapped binary failed `tapesctl version`\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&version.stdout), + String::from_utf8_lossy(&version.stderr), + ); + assert!( + String::from_utf8_lossy(&version.stdout).contains("tapesctl"), + "swapped binary printed no version" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_checksum_mismatch_leaves_the_install_untouched() { + // Given an install, and a bucket whose sidecar disagrees with the bytes it + // serves — a corrupted or tampered download + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let server = serve_bucket("nightly", &distinguishable_artifact(), Sidecar::Wrong).await; + let home = tempfile::tempdir().unwrap(); + + // When the upgrade runs + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it fails, names the mismatch, and the installed bytes are identical + assert!(!output.status.success(), "a bad digest must not succeed"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("sha256 mismatch"), "stderr: {stderr}"); + assert_eq!( + fs::read(&install.tapesctl).unwrap(), + original, + "the installed binary must be byte-identical after an abort" + ); + // And the unverified bytes are not left lying around next to it. + assert!( + !install.staging().exists(), + "the staging file must be cleaned up on abort" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_missing_checksum_aborts_rather_than_installing_unverified() { + // Given a bucket serving a binary with no `.sha256` beside it + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let server = serve_bucket("nightly", &distinguishable_artifact(), Sidecar::Missing).await; + let home = tempfile::tempdir().unwrap(); + + // When the upgrade runs + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it refuses — there is no unverified-download fallback — and the + // install is untouched + assert!( + !output.status.success(), + "a missing checksum must not succeed" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unverifiable"), "stderr: {stderr}"); + assert_eq!(fs::read(&install.tapesctl).unwrap(), original); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_install_already_at_the_published_version_downloads_nothing() { + // Given a bucket whose `latest/version` names exactly what this build + // reports. The binary under test is a dev build, so the object is written + // to match whatever it says it is — the property under test is the + // comparison, not any particular version string. + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let home = tempfile::tempdir().unwrap(); + + let server = MockServer::start().await; + let reported = { + let out = run_tapesctl(&install.tapesctl, &server.uri(), home.path(), &["version"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + // First line is `tapesctl `; the version is the second token. + stdout + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap() + .to_owned() + }; + Mock::given(method("GET")) + .and(path("/latest/version")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!("{reported}\n"))) + .mount(&server) + .await; + // Deliberately no artifact mounts: reaching for one would 404, so this test + // fails loudly if the short-circuit ever stops short-circuiting. + + // When a bare `tapesctl upgrade` runs + let output = run_tapesctl(&install.tapesctl, &server.uri(), home.path(), &["upgrade"]); + + // Then it exits 0, says so, and touches nothing + assert!( + output.status.success(), + "already-current must exit 0\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("already up to date"), "stdout: {stdout}"); + assert_eq!(fs::read(&install.tapesctl).unwrap(), original); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stale_staging_file_from_a_crashed_run_is_swept() { + // Given debris beside the install: a partial download a previous crashed + // run left behind. Repeated failures have to converge, not accumulate. + let install = TestInstall::new(); + fs::write(install.staging(), b"half a download from last time").unwrap(); + let artifact = distinguishable_artifact(); + let server = serve_bucket("nightly", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + // When an upgrade runs + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it succeeds — the stale file did not get in the way of the fresh + // download — and nothing is left over + assert!( + output.status.success(), + "stale staging file blocked the upgrade\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fs::read(&install.tapesctl).unwrap(), artifact); + assert!(!install.staging().exists()); +} + +/// A stand-in artifact that reports `version` as `label`. +/// +/// The compiled binary under test reports whatever *it* was stamped with, so a +/// pinned-version upgrade — where the point is that the artifact's version and +/// the requested one must agree — cannot be exercised with a copy of it. The +/// probe runs ` version` and reads stdout, which a script satisfies +/// honestly. +fn artifact_reporting(label: &str) -> Vec { + format!("#!/bin/sh\n[ \"$1\" = version ] && {{ echo 'tapesctl {label}'; exit 0; }}\nexit 2\n") + .into_bytes() +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pinned_version_installs_that_exact_release() { + // The half of the target surface no other end-to-end test covers: an + // explicit `--version`, resolved without a `latest/version` lookup, whose + // artifact must satisfy the probed-version check to land. + let install = TestInstall::new(); + let artifact = artifact_reporting("v9.9.9+abc1234"); + let server = serve_bucket("v9.9.9", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--version", "v9.9.9"], + ); + + assert!( + output.status.success(), + "pinned upgrade failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("9.9.9"), "stdout: {stdout}"); + assert_eq!(fs::read(&install.tapesctl).unwrap(), artifact); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_prefix_serving_the_wrong_build_aborts_before_the_swap() { + // The failure the digest structurally cannot see: the bytes are exactly + // what the bucket published and hash correctly, but the bucket published + // the wrong build behind this prefix — a stale `latest/`, a mispublished + // tag. Only the probed-version check catches it, and until now nothing + // exercised that check end to end. + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let artifact = artifact_reporting("v0.1.0+0000000"); + let server = serve_bucket("v9.9.9", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--version", "v9.9.9"], + ); + + assert!( + !output.status.success(), + "a mispublished prefix must not land" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("reports version"), "stderr: {stderr}"); + assert_eq!( + fs::read(&install.tapesctl).unwrap(), + original, + "the installed binary must survive a probe-version mismatch" + ); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_second_upgrade_refuses_while_the_first_holds_the_lock() { + // Two overlapping upgrades sharing one staging path must never interleave + // — the loser refuses up front, before any network I/O, with the binary + // untouched. The lock here is held exactly the way a concurrent run would + // hold it: an exclusive flock on the lock file in the install directory. + let outer = tempfile::tempdir().unwrap(); + let bin_dir = outer.path().join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let installed = bin_dir.join("tapesctl"); + fs::copy(compiled_tapesctl(), &installed).unwrap(); + fs::set_permissions(&installed, fs::Permissions::from_mode(0o755)).unwrap(); + let before = fs::read(&installed).unwrap(); + + let lock_path = bin_dir.join(".tapesctl.upgrade.lock"); + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .unwrap(); + let rc = unsafe { + libc::flock( + std::os::fd::AsRawFd::as_raw_fd(&lock), + libc::LOCK_EX | libc::LOCK_NB, + ) + }; + assert_eq!(rc, 0, "test could not take the lock it means to hold"); + + let server = MockServer::start().await; + let home = tempfile::tempdir().unwrap(); + let output = run_tapesctl( + &installed, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("already running"), "stderr: {stderr}"); + assert_eq!( + server.received_requests().await.unwrap().len(), + 0, + "the refusal must happen before any network I/O" + ); + assert_eq!(fs::read(&installed).unwrap(), before, "binary untouched"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unwritable_install_directory_refuses_before_any_download() { + // Given the unmigrated root-owned shape: a directory the invoking user + // cannot write. The bucket is mounted with NO routes at all, so any network + // request the pipeline made would fail differently than the refusal we + // expect — the assertion below is what proves the refusal came first. + let outer = tempfile::tempdir().unwrap(); + let bin_dir = outer.path().join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let installed = bin_dir.join("tapesctl"); + fs::copy(compiled_tapesctl(), &installed).unwrap(); + fs::set_permissions(&installed, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&bin_dir, fs::Permissions::from_mode(0o555)).unwrap(); + + // Root writes through 0o555 (CAP_DAC_OVERRIDE), so the unwritable + // precondition cannot be constructed from mode bits alone. Skip + // rather than assert a refusal the kernel will never produce — + // containerized CI runs as root. + if fs::write(bin_dir.join(".root-probe"), b"").is_ok() { + let _ = fs::remove_file(bin_dir.join(".root-probe")); + return; + } + + let server = MockServer::start().await; + let home = tempfile::tempdir().unwrap(); + + // When an upgrade runs + let output = run_tapesctl( + &installed, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it refuses with the installer named as the way out, and never + // escalates + fs::set_permissions(&bin_dir, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not writable"), "stderr: {stderr}"); + assert!( + stderr.contains("download.tapes.dev/tapesctl/install"), + "the refusal must name the installer: {stderr}" + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + 0, + "the refusal must happen before any network I/O" + ); +} From 9172a0f634cb6fbbe121876dfb2a19a4df32976f Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Mon, 24 Aug 2026 11:14:46 -0700 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=9A=20docs:=20the=20install=20layo?= =?UTF-8?q?ut,=20upgrade,=20and=20what=20a=20version=20means?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install section described a `/usr/local/bin` install that no longer happens, and the reference had no entry for either new command. Both are now documented where a user looks: the README for the tour, docs/commands.md for the reference, docs/introduction.md for the first five minutes. The `version` entry was also stale in a way that now actively contradicts `upgrade`. It said the number is not a release identifier because the crate version has never been bumped — true when it was written, false since the build began stamping the release tag, and directly at odds with a command whose "already up to date" answer is that number compared against the bucket's. It now describes what the three build kinds actually report and why the commit rides along as build metadata that comparison ignores. The exit-code and error-family entries said every runtime error is one line. They are a line plus a `caused by:` line per underlying cause now, and the note says to read the chain from the bottom, because the outermost message is deliberately the least specific. The uninstall entry names what is deliberately not deleted: an overridden TAPESCTL_CACHE_DIR is reported and left in place rather than recursively deleted, because the variable names a directory the user chose. The checksum error carries the status it saw, since any non-success answer — not only a 404 — counts as an absent sidecar. --- AGENTS.md | 7 +++ README.md | 36 +++++++++++++++ docs/commands.md | 105 ++++++++++++++++++++++++++++++++++++++----- docs/introduction.md | 29 ++++++++++++ 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b16b0c..068e23b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,13 @@ Inside `crates/tapesctl/src`: - `api/` — the ` ` read client. - `cassette/` — the runtime-discovered `cassettes ` surface: discovery, the spec reducer, the cache, and clap synthesis. +- `upgrade/` — verified, crash-safe self-replacement: bucket resolution, + staging, digest verification, and the atomic swap. +- `install_layout.rs` — where this binary actually lives, derived from the + running executable. `upgrade` and `uninstall` both build on it. +- `rc_block.rs` — removal of the installer's sentinel block from shell rc + files. Its markers are pinned against `install.sh` by a test. +- `uninstall.rs` — the binary, the local state, and that block. - `plugin.rs`, `capture.rs`, `logging.rs`, `error.rs` — the remaining command entry points and cross-cutting support. - `ports/` — search, skills, and seed. diff --git a/README.md b/README.md index af9f073..d8bca22 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,42 @@ tapesctl version Supported platforms are Linux and macOS, on x86-64 and arm64. +### Upgrading + +```bash +tapesctl upgrade +``` + +No curl, no re-running the installer. It checks the published version, says +`already up to date` and exits successfully when there is nothing to do, and +otherwise replaces this binary in place and prints `old → new`. + +Nothing touches the installed binary until the download's SHA-256 matches the +published sidecar and the staged file has answered `version` sensibly — so a +corrupted download, a wrong-architecture artifact, or a lost connection all +leave the binary you had still working. A missing sidecar aborts rather than +installing something unverifiable. + +`--version v0.6.0` pins an exact release, older ones included, because a bad +release needs an escape hatch. `--nightly` installs the rolling nightly build. + +An install still living in `/usr/local/bin` cannot upgrade itself — the +directory is not yours to write — and says so, naming the installer as the way +to migrate. + +### Uninstalling + +```bash +tapesctl uninstall +``` + +Removes the binary, `~/.tapes`, the cassette cache, and the `PATH` block the +installer wrote — leaving every other line of your rc file byte-for-byte +intact, and listing each path before you confirm. A cache location you pinned +with `TAPESCTL_CACHE_DIR` is named rather than deleted. Capture plugins installed into a harness are left alone, because those +live in the harness's own config; `tapesctl plugin uninstall ` removes +one. Pass `-y` to skip the confirmation. + ## Your first capture `start` launches a harness the way you normally would, with a capture proxy in diff --git a/docs/commands.md b/docs/commands.md index 0663571..6ff1596 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -34,7 +34,8 @@ cannot steer `tapesctl`. **`--api-url` appears in the help of commands that never make an HTTP call** — `config set`, `config get`, `config path`, -`version`, and `plugin uninstall` — because the global flag propagates into +`version`, `upgrade`, `uninstall`, and `plugin uninstall` — because the global +flag propagates into every leaf's help. It is inert there. Its presence in `config`'s help is actively misleading, since the point of `config set api-url` is that you do not have a server configured yet. @@ -46,7 +47,7 @@ Three values, and only three. | code | meaning | |---|---| | `0` | success | -| `1` | a runtime error — one line on stderr, prefixed `tapesctl: ` | +| `1` | a runtime error — a `tapesctl: ` line on stderr, plus a `caused by:` line per underlying cause | | `2` | an argument-parsing error, or help printed because a subcommand was missing | **A bare `tapesctl` prints help and exits `2`.** So does `tapesctl sessions`, @@ -527,19 +528,96 @@ tapesctl version ``` ``` -tapesctl 0.1.0 +tapesctl v0.7.0+3f2a1b9 +Sha: 3f2a1b9c0d4e5f60718293a4b5c6d7e8f9012345 +Built at: 2026-08-13T18:22:04Z All in all, just another tape in the stereo ``` -Both lines are expected; the second is the release smoke test's canary and is -pinned as an exact string. `--version` prints only the first line. +Four lines, and all four are expected; the last is the release smoke test's +canary and is pinned as an exact string. `--version` prints the same block +without it. -**The number is not a release identifier.** It comes from the crate version, -which has never been bumped, while releases are tagged independently — so a -binary from any release reports `0.1.0`. Do not tell anyone to "check your -version with `tapesctl --version`", do not pin documentation to a version the -binary can confirm, and treat `0.1.0` in a bug report as version-less. To -identify a build, record where you got it. +The identity comes from the build that produced the artifact, not from +`Cargo.toml` — the manifest holds a placeholder no release bumps, because a +release is cut by tagging a commit that has already merged, so the source +cannot know its own tag. A release reports `v0.7.0+`, a nightly +`nightly+`, and a local `cargo build` `0.0.0-dev+`. The commit +rides along as semver build metadata, which comparison ignores — that is what +lets `upgrade` recognize a stamped `v0.7.0+3f2a1b9` as the published `v0.7.0`. + +A `0.0.0-dev` in a bug report means a build from source, not a release. + +## upgrade + +```bash +tapesctl upgrade # newest published release +tapesctl upgrade --version v0.6.0 # an exact release, older included +tapesctl upgrade --nightly # the rolling nightly build +``` + +Replaces this binary in place. With no flags it compares the installed version +against the published `latest`, prints `tapesctl is already up to +date.` and exits `0` when they match, and otherwise prints +`tapesctl upgraded: `. + +| flag | default | notes | +|---|---|---| +| `--version ` | the newest release | `v0.6.0` and `0.6.0` are the same target. Downgrades are allowed — a bad release needs an escape hatch | +| `--nightly` | off | always downloads; a nightly carries no orderable version to compare. Conflicts with `--version` | + +**Every failure leaves the binary you had still working.** The order is fixed +and nothing is skipped: the published `.sha256` is compared *before* the +downloaded file is made executable or run; the staged file is then probed with +`version`, which catches a faithfully published wrong-architecture artifact +that a correct digest cannot; its answer is checked against the resolved +version, which catches a prefix serving the wrong build; only then is the file +renamed over the installed binary, atomically. A missing `.sha256` aborts — +there is no unverified-download fallback. Debris from a crashed run is swept at +the start of the next attempt, so repeated failures converge. + +An install in a directory you cannot write — the pre-`$HOME/.local/bin` layout +— refuses before any network request and names the installer as the way to +migrate. `upgrade` never escalates. + +| error | means | +|---|---| +| `install directory '' is not writable; re-run the installer to migrate: …` | an unmigrated root-owned install; refused before downloading | +| `no published checksum at '' (answered ) — refusing to install an unverifiable binary` | no `.sha256` sidecar was served. Any non-success status counts: a bucket without `ListBucket` answers 403 for a missing object | +| `sha256 mismatch: expected , downloaded file hashes to ` | a corrupted or tampered download; nothing was executed | +| `staged binary '' would not execute` | a wrong-architecture artifact | +| `staged binary '' reports version , expected ` | the bucket prefix is serving the wrong build | + +## uninstall + +```bash +tapesctl uninstall # confirms first +tapesctl uninstall -y # no prompt +``` + +Removes, in this order: `~/.tapes`, the cassette cache, the installer's `PATH` +block from `.bashrc` / `.zshrc` / `config.fish`, and finally the binary itself. +The prompt lists every one of those paths before you answer, because two of +them are recursive deletes. + +The cassette cache it removes is the one tapesctl derived for itself, under the +platform cache directory. If `TAPESCTL_CACHE_DIR` is set, that location is +**named in the output and left alone** — the variable points at a directory you +chose, which may hold more than our cache, and an uninstall does not get to +recursively delete it on your behalf. + +Each step warns and continues rather than aborting, so an interruption always +leaves a `tapesctl` that can be run again to finish. The rc-file edit removes +exactly the lines between the installer's sentinel markers and preserves every +byte outside them; a block whose end marker is missing is reported and left +alone, because rewriting would drop whatever follows it. A `paperctl` block in +the same file is never touched. + +Harness-side capture plugins are **not** removed — those are registrations in a +config file the harness owns. Use `tapesctl plugin uninstall `. + +A prompt that reaches end-of-input counts as a decline, so a piped or +``. + ## Two minutes: capture, then read Capture a Claude session. The harness behaves as it would unproxied — its