diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b36599d..e2846ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,20 +40,60 @@ jobs: run: | sudo apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus - - name: global install, from this checkout (the #71 layout) - # Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove - # the code under review. Same tree, same layout, same a+rX. + - name: global install, via install.sh itself (the #71 layout, versioned) + # install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this + # checkout, so CI proves the INSTALLER under review — the versioned + # layout, the current symlink, the PATH chain — not a hand-built + # imitation of it. Setup is run explicitly in the next step, so its + # output is its own CI section. run: | - sudo cp -r . /opt/box - sudo rm -rf /opt/box/.git - sudo chmod -R a+rX /opt/box - sudo ln -sf /opt/box/bin/box /usr/local/bin/box + sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh + # assert what landed: the layout, the chain, and that it answers + readlink -f /usr/local/bin/box | grep '^/opt/box/versions/' + /usr/local/bin/box --version + /usr/local/bin/box versions - name: setup-host - run: sudo bash /opt/box/host/setup-host.sh + run: sudo bash /opt/box/current/host/setup-host.sh - name: doctor — the baseline is provable before anything is judged - run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh + run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh - name: multi-user rehearsal (criteria a-l, container mode) - run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container + run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container + - name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue + # The full-removal order, end to end on the real daemon: revoke a + # granted user (--purge asserts its own absence, incl. the incus-user + # state dir), tear the stack down, uninstall the tree — then assert + # NOTHING survived: no networks, profiles, ACLs, nft tables, systemd + # units, files or symlinks. The uninstall was flaky exactly because + # nobody measured this. + run: | + set -x + sudo useradd -m -s /bin/bash uninstdrill + sudo BOX_YES=1 /usr/local/bin/box grant uninstdrill + uid="$(id -u uninstdrill)" + sudo BOX_YES=1 /usr/local/bin/box revoke uninstdrill --purge + sudo test ! -e "/var/lib/incus/users/$uid" + ! sudo incus project show "user-$uid" + ! sudo incus config trust list --format csv | grep -q "incus-user-$uid" + # The COMBINED verb, --force only, deliberately no BOX_YES and no + # TTY: this is the exact invocation that used to die at teardown's + # own prompt when consent was not forwarded (--purge-host now + # passes --yes through under --force/BOX_YES). + sudo /usr/local/bin/box uninstall --all --purge-host --force + # zero residue: the daemon's state... + ! sudo incus network show boxnet + ! sudo incus profile show box-net + ! sudo incus network acl show box-isolate + # ...the firewall and its boot persistence... + ! sudo nft list table inet box + ! sudo nft list table bridge box + sudo test ! -e /etc/systemd/system/box-firewall.service + sudo test ! -e /usr/local/sbin/box-firewall + # ...and the install itself: files AND symlinks, both name generations + sudo test ! -e /opt/box + sudo test ! -e /usr/local/bin/box + sudo test ! -L /usr/local/bin/box + sudo test ! -e /usr/local/bin/claudebox + sudo test ! -L /usr/local/bin/claudebox # NOT run here: the full drill (drill/drill.sh). It rehearses the whole # surface — cold template mints, expose, migration — and wants a real host diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e4d22..bac2f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ which records not just what changed but what each drill run proved. ## Unreleased +### Added + +- **Versioned installs** (#66's stance, made livable) — install.sh now lands + each version side by side at `/versions/` (its own `VERSION` + + `INSTALLED_FROM`), with a `current` symlink tracking the default and + `$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New + verbs: `box versions` (lists installs, marks the current default and the + running tree), `box use ` (flips the default, converges the PATH + symlinks, and *asserts the effective result* — `current` must resolve to + the asked-for version and the chain's `box --version` must answer it). + Re-running the installer with an installed version is a converging no-op + (`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs + side-by-side and flips `current` only when no boxes exist — under existing + boxes the flip is refused loudly, naming the boxes (#66: never change + versions under a user's boxes; `box use` keeps the same refusal). A + pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's + tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale + or dangling `$BINDIR/box` is healed instead of wedging the install; and the + installer warns when the *other* tier's install (/opt/box vs ~/.local) + coexists, since PATH order decides which wins. +- **A real uninstall** — `box uninstall [] [--all] [--purge-host]` + replaces the "rm -rf two paths" prose. One version: refuses the current one + (`box use` off it first). Everything: runs in the safe order — refuses + while boxes exist (naming them) unless `--purge-host` runs teardown-host + first — then removes every version, the `current` and PATH symlinks, and + the legacy claudebox crumbs (both name generations), and **ends with an + absence assert**: every removed path is re-checked, and any survivor makes + it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the + `revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1` + for automation and now points at `box uninstall` when done. +- **`BOX_INSTALL_SOURCE=`** — installs from a local tree, + bypassing the download. CI's rehearsal job now installs via install.sh + itself (proving the installer under review, not a `cp -r` mimic of it), and + ends with an **uninstall drill**: grant + `revoke --purge` a throwaway + user, `teardown-host`, `box uninstall --all`, then assert **zero residue** + — no networks, profiles, ACLs, nft tables, systemd units, files or + symlinks. +- **test/cli.sh drives real installs** — still dependency-free, non-root, no + daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a + fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge, + reinstall, side-by-side upgrade, the three #66 refusals (install flip, + `use`, `uninstall` — boxes named), flat-tree migration, symlink healing, + single-version and zero-residue uninstalls, and the `INCOMPLETE` scream + into *driven* tests instead of greps (154 checks). + +### Fixed + +- **`revoke --purge` re-checks the incus-user state** — the purge removed + `/var/lib/incus/users/` without ever asserting its absence, the one + path its own absence block did not cover; and the stat now rides + `$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin, + so a bare `[ -d ]` answered "absent" for a directory that was there). +- **A wedged `$BINDIR/box` no longer blocks installing** — the old + no-op-if-installed check keyed off the symlink's existence OR the tree's, + so a stale symlink (or a half-removed tree) could fake "already installed" + forever. Installed-ness is now judged from `versions/` itself; symlinks + are converged with `ln -sfn`, never trusted as the signal. + ## 0.6.0 — 2026-07-18 ### Added diff --git a/README.md b/README.md index 6bbb2ba..d73db51 100644 --- a/README.md +++ b/README.md @@ -42,29 +42,48 @@ design rationale. curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash ``` -It asks first — **"Install box?"** — then, if box is not already installed, -downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and -asks a second question: **"Set up this machine as a box host now?"** Say yes and -it builds the whole isolation stack for you (it may ask for `sudo`); say no and -you can run `box setup-host` later. (No `git clone` needed.) +It asks first — **"Install box?"** — then downloads the tree into a +**versioned** install (the way plenty of CLIs manage theirs), links `box` onto +your `PATH`, and on a fresh host asks a second question: **"Set up this +machine as a box host now?"** Say yes and it builds the whole isolation stack +for you (it may ask for `sudo`); say no and you can run `box setup-host` +later. (No `git clone` needed.) -**Re-running is a safe no-op.** If box is already installed, the installer tells -you so and changes nothing — a stray re-run can never clobber your install or -rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall -what you have and install fresh. Preserve any boxes first — `box down `, -copy out anything you need (a portable `box export` is +The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a +root install): + +``` +versions// one full tree per installed version +current -> versions/ the tracked default +$BINDIR/box -> current/bin/box the PATH entry, riding the chain +``` + +**Re-running is a safe converge.** Installing a version you already have +changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree); +a stray re-run can never clobber your install or rebuild the stack under your +boxes. Installing a **new** version lands it side by side and flips `current` +only when you have **no boxes** — under existing boxes the flip is refused +(never change versions under a user's boxes, +[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a +deliberate act: preserve what you care about — `box down `, copy out +anything you need (a portable `box export` is [#70](https://github.com/heavy-duty/box/issues/70)), then `box rm ` (which deletes the box _and_ its snapshots) — then: ```sh -rm -rf ~/.local/share/box ~/.local/bin/box # uninstall -curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash +box versions # what is installed, which is current, which is running +box use # flip the default (same refusal while boxes exist) ``` -A version-aware upgrade that migrates boxes instead of asking you to is -[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs -(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1` -declines the host-setup step. +A pre-0.7.0 flat install is migrated into `versions/` automatically on the +next installer run — the tree is moved, not re-downloaded, and your boxes are +untouched. A version-aware upgrade that migrates boxes instead of asking you +to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended +installs (CI, images), `BOX_YES=1` answers every prompt yes, +`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and +`BOX_INSTALL_SOURCE=` installs from a local tree instead of +downloading (how CI proves the installer under review, and how the drill can +install an unpushed branch). ### Global vs per-user install @@ -83,7 +102,9 @@ box's tree is _executed by other users_ — so it cannot hide in one user's home `BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user install under `/root` would be `0700` and unreadable to everyone else — which -is exactly the bug the root branch fixes. +is exactly the bug the root branch fixes. When both tiers are installed, PATH +order decides which `box` wins — the installer warns when it sees the other +tier's tree. ## One-time host setup (Ubuntu 24.04 / Debian 13) @@ -206,8 +227,7 @@ form; flags win). The template's identity (name, user) is stamped onto the insta so `shell`, `exec` and `tmux` land in the right user — and a clone still knows, because `incus copy` carries the metadata. -## Log in once, reuse via snapshotsrm -rf ~/.local/share/box ~/.local/bin/box # per-user install -sudo rm -rf /opt/box /usr/local/bin/box # global (root) install +## Log in once, reuse via snapshots Because every fresh box is creds-free, re-authenticating each time would be toil. Snapshot an authenticated box and clone from it instead: @@ -388,13 +408,29 @@ documentation, not a host-executed script. See ## Uninstall +`box uninstall` is the real uninstall, and it runs in the safe order — boxes +first, then the stack, then the tree — and **ends with an absence assert**: +every path it removed is re-checked, and any survivor makes it exit 1 naming +the leftovers instead of reporting a clean uninstall that wasn't (the same +discipline as `box revoke --purge`). + ```sh -box teardown-host # boxes, network, ACL, profile, firewall -box teardown-host --purge-incus # ...and Incus itself -rm -rf ~/.local/share/box ~/.local/bin/box # per-user install -sudo rm -rf /opt/box /usr/local/bin/box # global (root) install +box uninstall # one non-current version (side-by-side cleanup) +box uninstall --all --purge-host # everything: teardown-host (all boxes, the + # boxnet stack, the firewall), then every + # version, the symlinks, legacy claudebox crumbs +box uninstall # just the install — refuses while boxes exist + # (and names them); run teardown-host first, + # or use --purge-host ``` +The full-removal order on a multi-user host: `box revoke --purge` each +granted user (it asserts its own zero-residue, including the incus-user state +under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus` +to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then +`box uninstall`. CI drills exactly this sequence and asserts zero residue — +no networks, profiles, nft tables, systemd units, files or symlinks. + ## Non-goals - **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask diff --git a/bin/box b/bin/box index a20f7e7..f764317 100755 --- a/bin/box +++ b/bin/box @@ -77,6 +77,9 @@ CMDS=( "revoke^ [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^" "teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^" "migrate-host^--box | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^" + "versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^" + "use^^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^" + "uninstall^[] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^" "status^^^Deprecated alias for 'list'^fn:cmd_status^" "help^[]^^This help, or 'box help ' for one command^fn:cmd_help^" ) @@ -480,6 +483,56 @@ the profile, then verifies the box works on its new network leg. box migrate-host --all-boxes # re-home every legacy box box migrate-host --retire-legacy # remove the old stack (once no legacy box remains) EOF +;; + versions) cat <<'EOF' +List the versions installed under this install root — install.sh lands each +one side by side at /versions/, and a 'current' symlink tracks the +default (what the box on your PATH runs). The default is marked (current); +the tree answering THIS command is marked (running) — they differ when your +PATH resolves a different install (say, a global /opt/box shadowing yours). + + box versions + box use # switch the default + # install another version side by side: re-run install.sh +EOF +;; + use) cat <<'EOF' +Switch the default box version — repoint the 'current' symlink (and the PATH +symlink riding it) at an installed version. Refuses while ANY box exists: +never change versions under a user's boxes (#66) — 'box down' what you keep, +copy out what you need via 'box shell'/'box exec' (a portable 'box export' +is #70), 'box rm' each box, then switch. The flip is asserted afterwards: +current must resolve to the version you asked for, and the chain's +'box --version' must answer it. + + box versions # what is installed + box use 0.6.0 +EOF +;; + uninstall) cat <<'EOF' +Remove one installed version, or the whole install — the real uninstall, +replacing the old "rm -rf two paths by hand" instructions. + + box uninstall one NON-current version ('box use' another + first if you are on it) + box uninstall everything: every version, the current and + box uninstall --all PATH symlinks, and any legacy claudebox crumbs + box uninstall --purge-host run teardown-host first (all boxes, the boxnet + stack, the firewall — its own confirmation), + then remove the install + +The full uninstall runs in the safe order: boxes first — it refuses while +any exist (and names them) unless --purge-host tears them down; then the +trees and symlinks; and it ENDS with an absence assert — every removed path +is re-checked, and anything still present makes it exit 1 naming the +leftovers instead of reporting a clean uninstall that wasn't (the same +discipline as 'box revoke --purge'). Asks before removing; --force or +BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users +first: 'box revoke --purge'. + + box uninstall 0.5.0 + box uninstall --all --purge-host +EOF ;; status) cat <<'EOF' Deprecated alias for 'box list'. It ignored the argument it @@ -536,8 +589,9 @@ while [ $# -gt 0 ]; do if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi # expose's own flags (--list, --remove) are positional to it, not box's if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi - # the host verbs delegate their flags to the scripts they wrap - case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke) args+=("$1"); shift; continue ;; esac + # the host verbs delegate their flags to the scripts they wrap; + # uninstall parses its own (--all, --purge-host) the same way + case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" fi @@ -1045,6 +1099,258 @@ cmd_migrate_host() { host_script migrate-host.sh; } cmd_grant() { host_script grant-user.sh; } cmd_revoke() { host_script revoke-user.sh; } +# --- the versioned install (#66's stance, made livable in 0.7.0) ------------ +# install.sh lands each version at /versions/, with a +# 'current' symlink naming the default and $BINDIR/box pointing through it. +# $root (readlink -f, line 8) already resolved the whole chain, so a versioned +# install always runs from .../versions/ — and a git checkout does not, +# which is how these verbs know to refuse instead of uninstalling somebody's +# working copy. +install_root() { + local vdir; vdir="$(dirname "$root")" + [ "$(basename "$vdir")" = versions ] || return 1 + dirname "$vdir" +} + +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. install.sh carries a byte-identical copy; +# test/cli.sh diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# Which boxes exist on this host, at THIS caller's tier? Prints their names +# (both tag generations) and succeeds when at least one exists; fails when +# none are visible — including when incus is absent or not answering, because +# #66's stance protects BOXES from a version change, and a daemon that cannot +# answer has none to protect. install.sh carries a byte-identical copy (it +# runs before any install tree exists); test/cli.sh diffs the two so they +# cannot drift. +existing_boxes() { + command -v incus >/dev/null 2>&1 || return 1 + { timeout 10 incus list user.box=1 --format csv --columns n /dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep . +} + +# #66, kept at flip time: never change (or remove) the version under existing +# boxes. Names every box and the remedy, then dies — a refusal that does not +# say which boxes block it sends the operator off to rediscover 'box list'. +die_under_boxes() { # $1 = the act being refused, $2 = the retry command + local names n + names="$(existing_boxes)" || return 0 + { + echo "box: this host has existing boxes:" + while IFS= read -r n; do echo "box: · $n"; done <<<"$names" + echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)." + echo "box: preserve what you care about — 'box down ', copy out via 'box shell'/'box exec'" + echo "box: (a portable 'box export' is #70) — then 'box rm ' each, and re-run: $2" + } >&2 + exit 1 +} + +# The PATH symlinks that could ride this install: the one this invocation came +# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer +# checks where a link actually points before touching it, so a symlink that is +# somebody else's (another install root, a hand-rolled wrapper) is never moved. +bin_links() { + local c=() + [ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}") + [ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box") + if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi + printf '%s\n' "${c[@]}" | awk '!seen[$0]++' +} + +converge_bin_links() { # $1 = install root: point our PATH symlinks through current + local ir="$1" p t + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in + "$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;; + esac + done < <(bin_links) +} + +cmd_versions() { + local ir cur d v mark + ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list" + cur="$(readlink -f "$ir/current" 2>/dev/null || true)" + echo "VERSIONS ($ir)" + for d in "$ir/versions"/*/; do + [ -d "$d" ] || continue + v="$(basename "$d")" + mark="" + [ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)" + [ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)" + printf ' %s%s\n' "$v" "$mark" + done + echo + echo "switch the default: box use " + echo "install another: re-run install.sh (versions land side by side)" +} + +cmd_use() { + local v="${args[0]:-}" ir eff expect out + [ -n "$v" ] || usage_error "usage: $(synopsis_of use)" + ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch" + valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'box versions')" + [ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')" + die_under_boxes "switch the default box version" "box use $v" + # An atomic flip, not unlink+create: ln -sfn leaves a window where current + # is missing; a rename over it does not. + ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current" + converge_bin_links "$ir" + # Assert the EFFECTIVE result, not the intent: current must resolve to the + # version asked for, and the chain's own binary must answer that version — + # a flip that "worked" while the operator's box still runs the old tree is + # exactly the flakiness this verb exists to end. + eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + [ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v" + expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)" + if [ -n "$expect" ]; then + out="$("$ir/current/bin/box" --version 2>&1 || true)" + case "$out" in + *"$expect"*) : ;; + *) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;; + esac + fi + echo "box: switched to $v (current -> versions/$v)" +} + +# The uninstall's own confirmation. NOT confirm() above: that one is for box +# lifecycle verbs and must never auto-accept from the environment (the drill +# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse +# without --force). Uninstalling is installer-family, and BOX_YES is the +# installer-family consent contract — same as install.sh and revoke --purge. +uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY. + [ "$force" -eq 1 ] && return 0 + [ -n "${BOX_YES:-}" ] && return 0 + [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)" + local reply + printf 'box: %s? [y/N] ' "$1" + read -r reply + case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac +} + +# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose. +# The full removal runs in the documented order: boxes first (refuse while +# they exist, or --purge-host tears the stack down with them), then trees and +# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the +# last word is a re-check, not a hope. +cmd_uninstall() { + local ir a ver="" all=0 purge_host=0 cur p t granted leftover="" + local targets=() + for a in ${args[@]+"${args[@]}"}; do + case "$a" in + --all) all=1 ;; + --purge-host) purge_host=1 ;; + -*) usage_error "unknown option: $a (see 'box help uninstall')" ;; + *) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;; + esac + done + ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)" + [ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall" + + # -- one version ----------------------------------------------------------- + if [ -n "$ver" ] && [ "$all" -eq 0 ]; then + [ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version" + valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'box versions')" + [ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')" + cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + # A broken current makes the CURRENT guard below unfireable (cur empty + # when the link is missing; cur naming a non-directory when it dangles — + # readlink -f resolves a link whose last component does not exist). Heal + # first, then decide; never delete around a broken default. + { [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \ + || die "current is dangling — 'box use ' to repoint the default first (refusing to remove versions while it is broken)" + [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use ' first, or 'box uninstall --all' for everything" + uninstall_confirm "remove box version $ver from $ir" + # rm's exit code is not the verdict — the absence re-check below is (a + # half-removed tree must be reported as INCOMPLETE, not as a crash). + rm -rf "$ir/versions/$ver" || true + if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then + echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2 + exit 1 + fi + echo "box: removed version $ver (the default stays $cur)" + return 0 + fi + [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous" + + # -- everything ------------------------------------------------------------ + if [ "$purge_host" -eq 1 ]; then + # Granted users' worlds are not ours to erase silently — name them first; + # 'box revoke --purge' is the clean path (and asserts its absence). + granted="$(timeout 10 incus project list --format csv 2>/dev/null --purge' removes each world cleanly first" >&2 + # Consent forwards: --force and BOX_YES are this verb's installer-family + # yes, and teardown-host must hear it too — otherwise a non-interactive + # 'uninstall --all --purge-host --force' dies at teardown's own prompt + # (EOF on read) with the tree untouched but the promise broken. + if [ "$force" -eq 1 ] || [ -n "${BOX_YES:-}" ]; then + bash "$root/host/teardown-host.sh" --yes \ + || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)" + else + bash "$root/host/teardown-host.sh" \ + || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)" + fi + else + die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)" + fi + uninstall_confirm "remove the ENTIRE box install at $ir (every version)" + + # The removal set, gathered BEFORE anything is deleted, so the absence + # assert below re-checks exactly what was promised gone. PATH symlinks are + # removed only when they resolve into (or dangle at) THIS install root. + targets+=("$ir") + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in "$ir"/*) targets+=("$p") ;; esac + done < <(bin_links) + # Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real + # uninstall leaves neither generation behind. + while IFS= read -r p; do + p="$(dirname "$p")/claudebox" + [ -L "$p" ] && targets+=("$p") + done < <(bin_links) + [ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox") + mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++') + + # rm's exit code is not the verdict — the absence assert below is (a + # half-removed tree must be reported as INCOMPLETE by name, not as a crash). + for p in "${targets[@]}"; do rm -rf "$p" || true; done + + # END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink. + # A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims + # get verified (the revoke --purge discipline). + for p in "${targets[@]}"; do + if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi + done + if [ -n "$leftover" ]; then + echo "box: uninstall INCOMPLETE — still present:$leftover" >&2 + echo "box: remove them by hand, and re-check each path is really gone." >&2 + exit 1 + fi + echo "box: uninstalled — removed:" + for p in "${targets[@]}"; do echo "box: · $p"; done + if [ "$purge_host" -eq 0 ]; then + echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:" + echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time." + fi +} + cmd_help() { show_help "${args[0]:-}"; } # The escape hatch. The box is resolved and tag-checked; everything else is diff --git a/docs/plans/2026-07-18-versioned-install.md b/docs/plans/2026-07-18-versioned-install.md new file mode 100644 index 0000000..07d1bc1 --- /dev/null +++ b/docs/plans/2026-07-18-versioned-install.md @@ -0,0 +1,154 @@ +# Versioned installs, and a real uninstall (0.7.0 core) + +**Status: implemented and tested.** 154/154 in `test/cli.sh` (which now +*drives* real installer runs, not greps of them), shellcheck clean, and CI's +rehearsal job installs via `install.sh` itself and ends with a zero-residue +uninstall drill on a live Incus. This doc records the design and why each +decision fell where it did. + +## What was asked + +Two maintainer requests, one PR: + +1. **Install should be versioned** — each box version goes to its own folder + and a tracked default names the one you run, like plenty of CLIs manage + theirs. Before this, `install.sh` refused to touch an existing install at + all: changing versions meant uninstalling by hand (`rm -rf` two paths from + the README) and re-running the installer. +2. **Uninstall is flaky** — there was no uninstall verb at all, only prose; + `teardown-host.sh` deliberately leaves the install tree; and nothing + encoded the safe full-removal order (revoke users → teardown-host → remove + trees/symlinks). Add thorough tests for uninstall, and for grant/revoke + that they are clean. + +## The layout + +``` +/ /opt/box (root) or ~/.local/share/box (user); + BOX_HOME overrides — both unchanged from #71 + versions// one full tree per version, each with its own + VERSION + INSTALLED_FROM + current -> versions/ the tracked default (a relative symlink, so the + root can move as a unit) +$BINDIR/box -> /current/bin/box +``` + +The version key is the tree's own `VERSION` file — the identity of what was +installed, and the name `box versions` lists. `bin/box` needed **no change** +to run from here: line 8 already derives `$root` via `readlink -f`, which +resolves the whole `$BINDIR/box → current → versions/` chain, so +`VERSION`, `templates/`, `host/` and `drill/` all resolve inside the version +tree that is actually running. That same fact is how the new verbs detect +their world: a versioned install always runs from `.../versions/`; a git +checkout does not, and the verbs refuse instead of uninstalling somebody's +working copy. + +## Install semantics (#66's stance, kept — at the flip) + +#66 established: a stray installer re-run must never clobber a working +install or rebuild the stack under existing boxes. The old enforcement was a +blanket "refuse if anything is installed", which also blocked upgrades. The +versioned layout splits the two concerns: + +- **Same version present** → converging no-op ("already installed", exit 0); + `BOX_REINSTALL=1` replaces that version's tree via two renames (never a + partial overlay). A converge/reinstall of a non-current version never + moves the default — switching is `box use`, a deliberate act. +- **Different version** → installs side-by-side, then flips `current` **only + when no boxes exist**. With boxes present (both tag generations, checked at + the caller's tier via a shared `existing_boxes()` — byte-identical in + `install.sh` and `bin/box`, diffed by the tests so the two #66 stances + cannot drift), the flip is refused loudly, the boxes are *named*, and the + operator is pointed at the remedy: down/copy-out/rm, then `box use `. + A daemon that is absent or not answering has no boxes to protect — the + stance guards boxes, not daemons. +- **Pre-0.7.0 flat tree** → migrated before anything else: `mv` the root + aside, `mkdir versions/`, `mv` it to `versions/`, link + `current` and `$BINDIR/box`. Two renames inside one parent directory — no + copy, no window with no install, the operator's tree preserved bit for bit + (the tests assert the migrated tree's own `INSTALLED_FROM` survives). +- **Wedged symlinks** → healed, never trusted. The old no-op check keyed off + `$BINDIR/box` *or* `$DEST/bin/box` existing, so a stale symlink (or a + half-removed tree) faked "already installed" forever. Installed-ness is now + judged from `versions/` itself; `ln -sfn` converges the links. +- **Tier coexistence** → a root and a per-user install shadow each other by + PATH order alone; the installer warns when it sees the other tier's tree. +- Host setup is offered on **fresh** hosts only — an upgraded host has made + that decision (and may have live boxes the stack must not be rebuilt + under); `box setup-host` re-applies stack changes deliberately. + +`BOX_INSTALL_SOURCE=` bypasses the download (a directory is +tar-copied with `--exclude=.git`). This exists for CI and the drill — the +code under review is what lands — and it is what turned the test suite's +install coverage from greps into real runs. + +## The new verbs + +Table rows like every other verb (the CMDS table stays the single source of +truth); `uninstall` joins the host-verb flag passthrough so `--all` / +`--purge-host` reach it. + +- `box versions` — lists `versions/*`, marking the current default and the + tree answering the command (they differ when another install shadows yours + on PATH). +- `box use ` — same existing-boxes refusal as the installer's flip + (shared helper, boxes named), then repoints `current`, converges every PATH + symlink that resolves into this install root (never one that is somebody + else's), and **asserts the effective result**: `current` must resolve to + the asked-for version and `current/bin/box --version` must answer it. A + flip that "worked" while the operator still runs the old tree is exactly + the flakiness this verb exists to end. +- `box uninstall [] [--all] [--purge-host]` — + - one version: refuses the current one; removes the dir; re-checks it. + - full: the safe order — refuse while boxes exist (naming them) unless + `--purge-host` runs `teardown-host.sh` first (its own confirmation; a + note names granted users' surviving projects and `revoke --purge` as the + clean path); confirm (`--force` / `BOX_YES=1` — installer-family consent, + deliberately *not* the lifecycle `confirm()`, which must never + auto-accept from the environment); gather the removal set (root, every + PATH symlink pointing into it, claudebox crumbs of both name + generations); remove; then **the absence assert**: every path re-checked + for file/dir/symlink existence, any survivor → exit 1 + `uninstall INCOMPLETE` naming the leftovers. `rm`'s exit code is not the + verdict — the re-check is (a half-removed tree is INCOMPLETE, not a + crash). + +## Grant/revoke cleanliness + +Reading `revoke-user.sh` against its own absence assert found the gap: the +purge removes `/var/lib/incus/users/` but never re-checks it — and the +stat was a bare `[ -d ]`, which lies for a non-root admin (`/var/lib/incus` +is not traversable, so the directory reads as absent while it is there). +Both fixed: the check rides `$SUDO test -d`, and the absence block now covers +the state dir. Grepped-and-guarded in `test/cli.sh`; drilled live in CI. + +## Tests (the heart of this PR) + +`test/cli.sh` stays dependency-free, non-root, daemon-free. New machinery: a +fake `incus` on PATH whose `list` prints `$FAKE_BOXES`, throwaway +`BOX_HOME`/`BOX_BIN` roots, and fabricated second/third sources with +different `VERSION`s. Driven end to end: fresh layout + chain +(`box --version` through both symlinks), no-op/canary, `BOX_REINSTALL`, +side-by-side + no-boxes flip, all three #66 refusals (install flip, `use`, +`uninstall` — boxes named, remedies named), `versions` markers, `use` +flip-and-assert, flat-tree migration (alone, and combined with an upgrade), +dangling- and stale-symlink healing, single-version uninstall (current +refused), full uninstall with planted legacy crumbs and a zero-residue +assert (files *and* symlinks *and* legacy names), the INCOMPLETE scream +(a chmod-pinned survivor), and refusals from a working tree. The existing +DEST/BINDIR-branch tests are kept unchanged (the branch itself is unchanged). + +CI's rehearsal job now installs via `install.sh` +(`BOX_INSTALL_SOURCE=$GITHUB_WORKSPACE`), asserts the layout it left, runs +the stack from `/opt/box/current/...`, and appends the uninstall drill: +grant + `revoke --purge` a throwaway user (asserting the incus-user state +dir is gone), `teardown-host` (new `--yes`/`BOX_YES` support), `box +uninstall --all`, then zero residue — networks, profiles, ACLs, nft tables, +systemd units, files, symlinks, both name generations. + +## What this is not + +- Not #67: boxes still do not migrate across versions — this PR delivers the + version-agnostic upgrade *path* (side-by-side installs, an explicit flip); + data migration remains #67. +- Not a release: `VERSION` is untouched (a release PR bumps it). diff --git a/drill/drill.sh b/drill/drill.sh index 9bb7dbe..257e715 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -202,10 +202,11 @@ EOF export BOX_SKIP_SETUP_HOST=1 fi - # The installer is a no-op when box is already installed — upgrading is - # uninstall-then-install, by design. The drill re-proves a tree from scratch - # every run, so it does the uninstall itself: clear any prior tree and symlink - # before installing, or install.sh would correctly refuse to touch them. + # The installer converges when a version is already installed (0.7.0's + # versioned layout: re-running the same version is a no-op, and an upgrade + # lands side-by-side without flipping under boxes). The drill re-proves a + # tree from SCRATCH every run — a fresh host, not a converged one — so it + # removes the whole install root and symlink first. rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box" # The installer prompts (install? set up host?) and reads /dev/tty. The drill @@ -224,7 +225,7 @@ EOF # reads BOX_* — the vars were ignored, main was installed, and the run drilled # the wrong tree while reporting success. A drill that silently drills the # wrong code is worse than one that fails. - got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '')" + got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '')" if [ "$got" != "$REPO@$REF" ]; then echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2 echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2 @@ -302,7 +303,7 @@ if [ "${DRILL_OWNS_SETUP:-0}" != 1 ]; then echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2 echo " is supposed to converge in one run (#63). One of those did not happen." >&2 echo " reproduce with the output visible:" >&2 - echo " ~/.local/share/box/host/setup-host.sh" >&2 + echo " ~/.local/share/box/current/host/setup-host.sh" >&2 echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2 exit 1 fi @@ -361,7 +362,7 @@ left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')" # proves is idempotency: a second run over a cleaned host is a no-op that # restores the stack rather than a fresh build. inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…" -if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then +if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then echo "drill: setup-host.sh failed or timed out (>5 min)." >&2 echo " it should take seconds on a host that already has incus. usual causes:" >&2 echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2 @@ -469,7 +470,7 @@ phase "B. The box surface" # =========================================================================== # Compare against the installed tree's VERSION file, not a hardcoded number — # a pinned literal here would fail the drill on every release. -expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')" +expected="$(cat "$HOME/.local/share/box/current/VERSION" 2>/dev/null || echo '?')" v="$(box --version 2>&1)" case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac @@ -498,7 +499,7 @@ box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \ # The one rule that keeps templates honest: no key can name a network. Plant a # bad template in the installed tree (the drill owns this host), expect the # parser to reject it BY NAME, remove it. -badt="$HOME/.local/share/box/templates/cbdrill-bad" +badt="$HOME/.local/share/box/current/templates/cbdrill-bad" mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml" box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \ && ok "a template cannot name a network — BOX_NETWORK rejected by name" \ @@ -889,7 +890,7 @@ phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)" # tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove # migrate-host.sh moves it onto the new stack with its identity intact, and # retires the legacy stack only once it is empty. -MIG="$HOME/.local/share/box/host/migrate-host.sh" +MIG="$HOME/.local/share/box/current/host/migrate-host.sh" if [ ! -f "$MIG" ]; then no "migrate-host.sh not installed — cannot drill the transition" else @@ -985,5 +986,5 @@ fi echo inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules" inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)." -inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]" +inf "to undo: box uninstall --purge-host (or ~/.local/share/box/current/host/teardown-host.sh)" [ "$fail" -eq 0 ] diff --git a/host/revoke-user.sh b/host/revoke-user.sh index 50f2574..5a86203 100644 --- a/host/revoke-user.sh +++ b/host/revoke-user.sh @@ -135,19 +135,26 @@ done < <(incus config trust list --format csv --columns nf 2>/dev/null) # incus-user's per-user client state (their key pair). Removed so a future # re-grant starts clean instead of trusting a key the purge revoked. -if [ -d "/var/lib/incus/users/$uid" ]; then +# $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a +# non-root admin, so an unprivileged stat answers "absent" for a directory +# that is very much there — the same lie the absence assert below must dodge. +if $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null; then $SUDO rm -rf "/var/lib/incus/users/$uid" echo "purge: incus-user state for uid $uid removed" fi # Assert absence rather than trusting exit codes — the wipe.sh discipline. # The certificate included: its removal above is set -e-exempt (left of &&), -# and a promise the header makes is a promise this block checks. +# and a promise the header makes is a promise this block checks. The +# incus-user state directory too — it was purged for releases without being +# re-checked, which is exactly the gap this block exists to close. leftover="" incus project show "$project" >/dev/null 2>&1 /dev/null 2>&1 /dev/null | grep -q "^incus-user-$uid," \ && leftover="$leftover cert:incus-user-$uid" +$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \ + && leftover="$leftover /var/lib/incus/users/$uid" if [ -n "$leftover" ]; then echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2 exit 1 diff --git a/host/teardown-host.sh b/host/teardown-host.sh index af6266f..4a3d5d3 100755 --- a/host/teardown-host.sh +++ b/host/teardown-host.sh @@ -3,20 +3,34 @@ # ancestor created, so one teardown cleans a host of any generation: all boxes # (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev # profiles, and both generations of firewall units and nft tables. -# Usage: ./host/teardown-host.sh [--purge-incus] +# Usage: ./host/teardown-host.sh [--purge-incus] [--yes] # --purge-incus also apt-purge Incus itself (skipped if non-box # instances still exist on this host) +# --yes skip the confirmation (BOX_YES=1 does the same) — for +# automation: CI's uninstall drill and 'box uninstall +# --purge-host' run this unattended set -euo pipefail -purge=false -[ "${1:-}" = "--purge-incus" ] && purge=true +purge=false; yes=0 +for arg in "$@"; do + case "$arg" in + --purge-incus) purge=true ;; + --yes|-y) yes=1 ;; + *) echo "teardown-host: unknown option: $arg" >&2; exit 2 ;; + esac +done +[ -n "${BOX_YES:-}" ] && yes=1 echo "This removes ALL boxes (uncommitted work in them is lost), the" echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules" echo "(both current and pre-0.4.0 names)." $purge && echo "Incus itself will also be uninstalled (--purge-incus)." -read -rp "Continue? [y/N] " a -case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac +if [ "$yes" -eq 1 ]; then + echo "(confirmed non-interactively: --yes/BOX_YES)" +else + read -rp "Continue? [y/N] " a + case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac +fi # Instances — both tag generations, one delete at a time (a multi-name # 'incus delete' aborts at the first missing name). @@ -72,4 +86,4 @@ if $purge; then fi fi -echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)" +echo "Teardown complete. (The box install tree itself remains — 'box uninstall' removes it, with a zero-residue check.)" diff --git a/install.sh b/install.sh index a894f7d..37fb3a8 100755 --- a/install.sh +++ b/install.sh @@ -3,10 +3,25 @@ set -euo pipefail # box installer — intended for: curl -fsSL .../install.sh | bash # -# Downloads the box source tarball from its GitHub repo (heavy-duty/box), -# installs the whole tree under $DEST, and puts a `box` symlink on PATH via -# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install -# script keeps working; BOX_REPO overrides.) +# Downloads the box source tarball from its GitHub repo (heavy-duty/box) and +# installs it into the VERSIONED layout under $DEST: +# +# $DEST/versions// one full tree per installed version +# $DEST/current -> versions/ the default version +# $BINDIR/box -> $DEST/current/bin/box the PATH entry +# +# Versions install side by side, the way plenty of CLIs manage theirs: `box +# versions` lists them, `box use ` switches the default, `box uninstall` +# removes them. Re-running with an already-installed version is a converging +# no-op (BOX_REINSTALL=1 replaces that version's tree); a NEW version installs +# beside the old one and becomes the default only when NO boxes exist — #66's +# stance (never change versions under a user's boxes) now guards the FLIP, not +# the whole install. A pre-0.7.0 flat tree is migrated in place, so upgrading +# from 0.6.0 is seamless. (GitHub redirects the repo's pre-rename URLs, so an +# old install script keeps working; BOX_REPO overrides.) +# +# BOX_INSTALL_SOURCE= installs from a local tree instead of +# downloading — for CI and the drill, so what lands is the code under review. REPO="${BOX_REPO:-heavy-duty/box}" REF="${BOX_REF:-main}" @@ -47,30 +62,96 @@ confirm() { # $1 = question case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. bin/box carries a byte-identical copy; +# test/cli.sh diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# Which boxes exist on this host, at THIS caller's tier? Prints their names +# (both tag generations) and succeeds when at least one exists; fails when +# none are visible — including when incus is absent or not answering, because +# #66's stance protects BOXES from a version change, and a daemon that cannot +# answer has none to protect. bin/box carries a byte-identical copy (the CLI +# needs the same gate for 'box use' / 'box uninstall'); test/cli.sh diffs the +# two so they cannot drift. +existing_boxes() { + command -v incus >/dev/null 2>&1 || return 1 + { timeout 10 incus list user.box=1 --format csv --columns n /dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep . +} + # --- prerequisites --------------------------------------------------------- -command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." +# curl only when something must be downloaded — a local BOX_INSTALL_SOURCE +# needs none, which is what lets test/cli.sh drive REAL installs offline. +if [ -z "${BOX_INSTALL_SOURCE:-}" ]; then + command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." +fi command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run." -# --- confirm, then no-op if already installed ------------------------------ -# Prompt BEFORE downloading anything: the first thing a curl|bash should do is -# ask whether you meant to. Then, if box is already installed, this run changes -# nothing and says so — a re-run is a safe no-op, which dissolves the whole -# "curl clobbered my working install / rebuilt the stack under my boxes" class -# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall -# what you have (dealing with your boxes as you do) and install fresh. -confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed." +if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then + SRCDESC="local source $BOX_INSTALL_SOURCE" +else + SRCDESC="$REPO@$REF" +fi -if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then - cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '')" - cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')" - log "box is already installed ($cur, version $cur_ver) — nothing to do." - log "To install a different version, remove the current one first:" - log " · preserve any boxes you care about — 'box down ', then keep them" - log " (a portable 'box export' is #70; for now copy what you need OUT via" - log " 'box shell'/'box exec'), and 'box rm ' when you are done" - log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\"" - log " · then re-run this installer" - exit 0 +# --- confirm first --------------------------------------------------------- +# Prompt BEFORE downloading anything: the first thing a curl|bash should do is +# ask whether you meant to. Everything after this converges: re-running with a +# version that is already installed changes nothing and says so, which +# dissolves the whole "curl clobbered my working install / rebuilt the stack +# under my boxes" class of failures (#66). +confirm "Install box from $SRCDESC?" || die "cancelled — nothing was changed." + +# Flip $DEST/current to versions/ atomically: build the new link beside it, +# rename over. Plain ln -sfn is unlink+create — a window where current names +# nothing and a concurrent 'box' invocation dies mid-chain. bin/box's cmd_use +# flips with the same pattern. +flip_current() { + ln -sfn "versions/$1" "$DEST/current.new.$$" + mv -Tf "$DEST/current.new.$$" "$DEST/current" +} + +# --- migrate a pre-0.7.0 flat install -------------------------------------- +# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under +# it). Move such a tree to versions/ BEFORE anything else, so an +# upgrade from 0.6.0 is seamless and the version comparison below sees the +# truth. The move is two renames inside one parent directory — no copying, no +# window with no install — and the operator's tree is preserved bit for bit. +if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then + flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)" + # The flat tree's VERSION is data from disk, not from this installer — the + # same trust boundary as the new_ver check, so the same gate: a corrupted + # (or hostile) VERSION must not steer the mv/ln below out of versions/. + valid_version "$flat_ver" || die "the flat install's VERSION is not a sane directory name: '$flat_ver' — fix $DEST/VERSION (one line, e.g. 0.6.0), then re-run" + log "found a pre-0.7.0 flat install at $DEST (version $flat_ver) — migrating it into the versioned layout" + staging="$DEST.migrating.$$" + mv "$DEST" "$staging" + mkdir -p "$DEST/versions" + mv "$staging" "$DEST/versions/$flat_ver" + flip_current "$flat_ver" + mkdir -p "$BINDIR" + ln -sfn "$DEST/current/bin/box" "$BINDIR/box" + log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)" +fi + +# Whether ANY version was installed before this run — read before we add one. +# It gates the host-setup offer below: a host that already ran box has made +# that decision (and may have live boxes the stack must not be rebuilt under, +# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose. +had_install=0 +if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then + had_install=1 fi # --- temp workspace -------------------------------------------------------- @@ -78,50 +159,131 @@ TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } trap cleanup EXIT -URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" +# --- acquire the tree ------------------------------------------------------ +if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then + SRC="$BOX_INSTALL_SOURCE" + INSTALLED_FROM="local:$SRC" + if [ -d "$SRC" ]; then + log "copying local tree $SRC" + mkdir -p "$TMPDIR/tree" + # tar, not cp -a: --exclude=.git, so a working checkout never carries its + # VCS state (or its size) into the install tree. + tar -C "$SRC" --exclude=.git -cf - . | tar -xf - -C "$TMPDIR/tree" + EXTRACTED="$TMPDIR/tree" + elif [ -f "$SRC" ]; then + log "extracting local tarball $SRC" + tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC" + EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" + else + die "BOX_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC" + fi +else + INSTALLED_FROM="$REPO@$REF" + URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" + log "installing box from $REPO@$REF" + log "downloading $URL" + curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \ + || die "failed to download $URL" -log "installing box from $REPO@$REF" -log "downloading $URL" -curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \ - || die "failed to download $URL" + log "extracting archive" + tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \ + || die "failed to extract archive" -log "extracting archive" -tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \ - || die "failed to extract archive" + # GitHub names the archive's top dir - (slashes in a ref become + # dashes) — deriving that name is guesswork, and it broke for real at the + # claudebox → box rename, when this glob kept looking for claudebox-* and the + # installer died on every host. The tarball has exactly ONE top-level + # directory: take the directory, whatever it is called, and let the bin/box + # check below judge whether it is the right tree. + EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" +fi +[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC" +[ -f "$EXTRACTED/bin/box" ] || die "source does not contain bin/box — is $SRCDESC correct?" -# GitHub names the archive's top dir - (slashes in a ref become -# dashes) — deriving that name is guesswork, and it broke for real at the -# claudebox → box rename, when this glob kept looking for claudebox-* and the -# installer died on every host. The tarball has exactly ONE top-level -# directory: take the directory, whatever it is called, and let the bin/box -# check below judge whether it is the right tree. -EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" -[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive" -[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?" +# The tree's own VERSION file names the directory it lands in — the version IS +# the identity of what is being installed, and 'box versions' lists these names. +new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)" +[ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version" +valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'" -# --- install into $DEST ---------------------------------------------------- -# Reached only on a host with no existing install (the no-op check above -# exits otherwise), so this is always a fresh tree, never an overwrite. -log "installing into $DEST" -mkdir -p "$(dirname "$DEST")" -mv "$EXTRACTED" "$DEST" +# --- install into $DEST/versions/ --------------------------------- +VDIR="$DEST/versions/$new_ver" +newly_installed=0 +if [ -d "$VDIR" ]; then + if [ -n "${BOX_REINSTALL:-}" ]; then + # Replace THIS version's tree, as atomically as two renames allow — never + # a partial overlay of new files onto an old tree. + log "BOX_REINSTALL=1 — replacing the installed $new_ver tree" + stage="$VDIR.new.$$"; old="$VDIR.old.$$" + rm -rf "$stage" "$old" + chmod +x "$EXTRACTED/bin/box" + mv "$EXTRACTED" "$stage" + # Swap by renames, delete LAST: rm-then-move leaves a hole the whole + # length of the delete where current -> this version resolves to nothing. + mv "$VDIR" "$old" + mv "$stage" "$VDIR" + rm -rf "$old" + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" + log "reinstalled $new_ver" + else + cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '')" + log "box $new_ver is already installed ($cur_from) — nothing to do." + log "(BOX_REINSTALL=1 replaces this version's tree; 'box versions' lists what is installed.)" + fi +else + log "installing $new_ver into $VDIR" + mkdir -p "$DEST/versions" + chmod +x "$EXTRACTED/bin/box" + mv "$EXTRACTED" "$VDIR" + newly_installed=1 + # Record WHAT was installed, so a caller can assert it got what it asked for. + # Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names + # retired in 0.5.0) silently falls back to the defaults and installs main — + # and the caller drills the wrong tree, believing it drilled its branch. + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" +fi -chmod +x "$DEST/bin/box" - -# A global (root) install is run by OTHER users, but mv preserves the tarball's -# root:root ownership and GitHub's archives carry no world bits on some paths — so -# without this, a non-root caller cannot even traverse into $DEST to reach bin/box. -# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on -# files, +search (x) on directories only. Guarded on root so the per-user install -# stays byte-identical to before. -if [ "$(id -u)" -eq 0 ]; then - chmod -R a+rX "$DEST" +# --- which version is the default? ----------------------------------------- +# 'current' is the tracked default; flipping it is the ONLY step that changes +# what an operator's `box` runs. #66's stance, kept exactly here: never change +# versions under existing boxes. A fresh host (or a dangling current) is +# claimed outright; an upgrade flips only when no box exists — otherwise the +# new version sits installed side-by-side and 'box use' is the deliberate act. +cur="$(readlink -f "$DEST/current" 2>/dev/null || true)" +want="$(readlink -f "$VDIR")" +if [ -z "$cur" ] || [ ! -d "$cur" ]; then + flip_current "$new_ver" + log "default version: $new_ver" +elif [ "$cur" = "$want" ]; then + : # already the default — nothing to flip +elif [ "$newly_installed" -eq 0 ]; then + # A converge/no-op (or BOX_REINSTALL) of a version that is NOT the default + # never moves the default — a re-run must change nothing (#66); switching is + # 'box use', a deliberate act. + log "the default stays $(basename "$cur") — 'box use $new_ver' switches." +else + old_ver="$(basename "$cur")" + if names="$(existing_boxes)"; then + warn "this host has existing boxes:" + while IFS= read -r n; do warn " · $n"; done <<<"$names" + warn "refusing to change the default box version under them (#66) — the default stays at $old_ver." + log "box $new_ver is installed side-by-side. To switch:" + log " · preserve what you care about — 'box down ', copy out via 'box shell'/'box exec'" + log " (a portable 'box export' is #70), then 'box rm ' when you are done" + log " · then flip the default: box use $new_ver" + else + flip_current "$new_ver" + log "default version switched: $old_ver -> $new_ver ('box use $old_ver' switches back)" + fi fi # --- put box on PATH ------------------------------------------------------- +# ln -sfn converges, and that includes HEALING: a stale or dangling +# $BINDIR/box (say, its tree half-removed by hand) must never block or wedge +# an install — it gets repointed at the current chain, whatever it said before. mkdir -p "$BINDIR" -ln -sf "$DEST/bin/box" "$BINDIR/box" -log "linked $BINDIR/box -> $DEST/bin/box" +ln -sfn "$DEST/current/bin/box" "$BINDIR/box" +log "linked $BINDIR/box -> $DEST/current/bin/box" # 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it # cannot dangle at the old bin path forever. Old BOXES keep working — the CLI # honors their legacy tag — it is only the old command name that retires. @@ -137,6 +299,34 @@ if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then log "removed the old install tree at $OLD_DEST (it now lives at $DEST)" fi +# A global (root) install is run by OTHER users, but mv preserves the tarball's +# root:root ownership and GitHub's archives carry no world bits on some paths — so +# without this, a non-root caller cannot even traverse into $DEST to reach bin/box. +# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on +# files, +search (x) on directories only. Guarded on root so the per-user install +# stays byte-identical to before. +if [ "$(id -u)" -eq 0 ]; then + chmod -R a+rX "$DEST" +fi + +# --- the OTHER tier's install, if any -------------------------------------- +# A root (/opt/box) and a per-user (~/.local/share/box) install coexist by +# PATH order alone, which is easy to be surprised by — say so out loud rather +# than let two versions silently shadow each other (#71's layout, both sides). +if [ "$(id -u)" -ne 0 ]; then + if [ -e /opt/box/current/bin/box ] || [ -e /opt/box/bin/box ]; then + warn "a GLOBAL install also exists at /opt/box — PATH order decides which 'box' you run (check: command -v box)" + fi +else + sudo_home="" + if [ -n "${SUDO_USER:-}" ]; then + sudo_home="$(getent passwd "$SUDO_USER" | cut -d: -f6)" || sudo_home="" + fi + if [ -n "$sudo_home" ] && { [ -e "$sudo_home/.local/share/box/current/bin/box" ] || [ -e "$sudo_home/.local/share/box/bin/box" ]; }; then + warn "a PER-USER install also exists at $sudo_home/.local/share/box — PATH order decides which 'box' $SUDO_USER runs" + fi +fi + # --- PATH check ------------------------------------------------------------ case ":$PATH:" in *":$BINDIR:"*) : ;; @@ -147,29 +337,27 @@ case ":$PATH:" in ;; esac -# Record WHAT was installed, so a caller can assert it got what it asked for. -# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names -# retired in 0.5.0) silently falls back to the defaults and installs main — -# and the caller drills the wrong tree, believing it drilled its branch. -# Written BEFORE host setup: this records the install, which has now happened, -# and it must not hinge on whether the host stack came up. -printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM" - # --- host setup (second prompt) -------------------------------------------- # The tool is installed; the machine is not yet a box host. Offer to finish the # job — build Incus and the isolation stack — rather than leave 'box new' to die # later on a host with no boxnet and no profile (#64). This is its own decision: # you might be installing the CLI on a workstation and hosting boxes elsewhere. +# Offered on a FRESH host only: a host that already had a box install has made +# this decision (and may have live boxes the stack must not be rebuilt under); +# 'box setup-host' re-applies stack changes deliberately, after an upgrade. # BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set # up by hand); BOX_YES answers "yes". setup_ok="" setup_declined="" -if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then +if [ "$had_install" -eq 1 ]; then + log "this host already had a box install — skipping host setup (re-apply stack changes any time: box setup-host)" + setup_declined=1 +elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then log "skipping host setup (BOX_SKIP_SETUP_HOST is set)." setup_declined=1 elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then warn "cannot set up the host: it needs root and sudo was not found." - warn " run this as root to finish: $DEST/host/setup-host.sh" + warn " run this as root to finish: $DEST/current/host/setup-host.sh" setup_declined=1 elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then # but never RE-CHECKED it — +# the one path its own absence assert did not cover. And the stat must ride +# $SUDO: /var/lib/incus is not traversable by a non-root admin, so a bare +# [ -d ] answers "absent" for a directory that is very much there. +# --------------------------------------------------------------------------- +check "revoke: purge removes the incus-user state directory" 0 "" \ + grep -qF '/var/lib/incus/users/' "$ROOT/host/revoke-user.sh" +check "revoke: the absence assert covers the incus-user state too" 0 "" \ + bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "/var/lib/incus/users/"' +# shellcheck disable=SC2016 # the $-strings are literals in the target file +check "revoke: the state checks go through \$SUDO test (an unprivileged stat lies)" 0 "" \ + grep -qF '$SUDO test -d "/var/lib/incus/users/$uid"' "$ROOT/host/revoke-user.sh" + +# --------------------------------------------------------------------------- +# The versioned install (#66 → 0.7.0). BOX_INSTALL_SOURCE bypasses the network, +# so these are REAL runs of install.sh against throwaway BOX_HOME/BOX_BIN +# roots — layout, symlink chain, flat-tree migration, symlink healing, use and +# uninstall are all DRIVEN, not grepped. A fake `incus` on PATH answers the +# existing-boxes gate ($FAKE_BOXES names them), so the #66 refusals — refuse +# to flip, refuse to switch, refuse to uninstall under boxes — run for real +# too, with no daemon anywhere near this suite. +# --------------------------------------------------------------------------- +VER="$(cat "$ROOT/VERSION")" +WORK="$(mktemp -d)" +FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" + +ISHIM="$WORK/ishim"; mkdir -p "$ISHIM" +cat > "$ISHIM/incus" <<'SHIM' +#!/usr/bin/env bash +# Fake incus: 'list' prints $FAKE_BOXES (whitespace-separated names, one per +# line); everything else succeeds silently. Just enough for the existing-boxes +# gate that guards version flips. +case " $* " in + *" list "*) for b in ${FAKE_BOXES:-}; do printf '%s\n' "$b"; done ;; +esac +exit 0 +SHIM +chmod +x "$ISHIM/incus" + +# A fabricated "newer release": the same CLI, a different VERSION — what an +# upgrade actually is, from the installer's point of view. +SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin" +cp "$ROOT/bin/box" "$SRC9/bin/box"; chmod +x "$SRC9/bin/box" +echo "9.9.9-drill" > "$SRC9/VERSION" +SRC8="$WORK/src-8.8.8"; mkdir -p "$SRC8/bin" +cp "$ROOT/bin/box" "$SRC8/bin/box"; chmod +x "$SRC8/bin/box" +echo "8.8.8-drill" > "$SRC8/VERSION" + +inst() { # inst [VAR=val ...] — run install.sh for real + local h="$1" b="$2"; shift 2 + env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= \ + BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \ + BOX_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh" +} +ibox() { # ibox [VAR=val ...] — run an installed box under the shim + env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= "$@" +} + +# --- fresh install: the layout and the chain -------------------------------- +H1="$WORK/h1"; B1="$WORK/b1" +check "install: a fresh install runs clean" 0 "done" inst "$H1" "$B1" +check "install: the tree lands in versions/" 0 "" test -x "$H1/versions/$VER/bin/box" +check "install: 'current' points at versions/" 0 "versions/$VER" readlink "$H1/current" +check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/box" readlink "$B1/box" +check "install: box --version answers through the whole chain" 0 "box $VER" ibox "$B1/box" --version +check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM" + +# --- converge, don't clobber ------------------------------------------------ +touch "$H1/versions/$VER/CANARY" +check "install: a same-version re-run is a no-op that says so (#66)" 0 "already installed" inst "$H1" "$B1" +check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY" +check "install: BOX_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" BOX_REINSTALL=1 +check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY" + +# --- a second version: side-by-side, and the no-boxes flip ------------------ +check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9" +check "install: ...into its own versions dir" 0 "" test -x "$H1/versions/9.9.9-drill/bin/box" +check "install: ...and the old version stays" 0 "" test -d "$H1/versions/$VER" +check "install: with no boxes, the default flips to the new version" 0 "box 9.9.9-drill" ibox "$B1/box" --version + +# --- box versions ----------------------------------------------------------- +check "versions: lists the installed versions" 0 "$VER" ibox "$B1/box" versions +check "versions: marks the current default" 0 "(current)" ibox "$B1/box" versions +check "versions: marks the running one" 0 "(running)" ibox "$B1/box" versions + +# --- box use ---------------------------------------------------------------- +check "use: no argument is a usage error" 2 "usage: box use" ibox "$B1/box" use +check "use: an unknown version is refused by name" 1 "no such version" ibox "$B1/box" use 1.2.3 +# A version is a directory NAME — a crafted one must die at the gate, never +# reach the ln (current pointing outside the root) or an rm -rf. +check "use: a path-traversal version dies at the gate" 1 "not a sane version name" \ + ibox "$B1/box" use '../../tmp/evil' +check "use: refuses under existing boxes, naming them (#66)" 1 "wedged" \ + ibox FAKE_BOXES="wedged stuck" "$B1/box" use "$VER" +check "use: the refusal points at the remedy (box rm, then re-run)" 1 "box rm" \ + ibox FAKE_BOXES=wedged "$B1/box" use "$VER" +check "use: with no boxes, flips the default" 0 "switched to $VER" ibox "$B1/box" use "$VER" +check "use: the flip is effective through the PATH chain" 0 "box $VER" ibox "$B1/box" --version +check "install: an installed-but-not-current version is a no-op too" 0 "already installed" \ + inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9" +check "install: ...and does not move the default" 0 "box $VER" ibox "$B1/box" --version + +# --- the upgrade-under-boxes refusal, driven end to end --------------------- +H2="$WORK/h2"; B2="$WORK/b2" +check "refusal drill: baseline install" 0 "done" inst "$H2" "$B2" +check "upgrade under boxes: REFUSES the default flip (#66)" 0 "refusing to change the default box version" \ + inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC9" FAKE_BOXES=work +check "upgrade under boxes: the new version IS installed side-by-side" 0 "" \ + test -d "$H2/versions/9.9.9-drill" +check "upgrade under boxes: the default stayed put" 0 "box $VER" ibox "$B2/box" --version +check "upgrade under boxes: the blocking boxes are NAMED" 0 "· work" \ + inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC8" FAKE_BOXES=work +check "upgrade under boxes: the refusal names the deliberate flip" 0 "" \ + bash -c 'grep -q "then flip the default: box use" "'"$ROOT"'/install.sh"' + +# --- migration: a 0.6.0 flat tree becomes a versioned one ------------------- +H3="$WORK/h3"; B3="$WORK/b3"; mkdir -p "$H3/bin" "$B3" +cp "$ROOT/bin/box" "$H3/bin/box"; chmod +x "$H3/bin/box" +cp "$ROOT/VERSION" "$H3/VERSION" +echo "test@flat" > "$H3/INSTALLED_FROM" +ln -s "$H3/bin/box" "$B3/box" +check "migrate: a pre-0.7.0 flat tree is moved into versions/" 0 "migrating" inst "$H3" "$B3" +check "migrate: the OPERATOR'S tree moved (not a fresh copy)" 0 "test@flat" \ + cat "$H3/versions/$VER/INSTALLED_FROM" +check "migrate: nothing flat remains at the root" 1 "" test -e "$H3/bin" +check "migrate: current points at the migrated version" 0 "versions/$VER" readlink "$H3/current" +check "migrate: the PATH symlink was re-pointed through current" 0 "$H3/current/bin/box" readlink "$B3/box" +check "migrate: the migrated install answers --version" 0 "box $VER" ibox "$B3/box" --version + +# ...and the seamless 0.6.0 → 0.7.0 upgrade: flat tree in, new version beside it. +H4="$WORK/h4"; B4="$WORK/b4"; mkdir -p "$H4/bin" "$B4" +cp "$ROOT/bin/box" "$H4/bin/box"; chmod +x "$H4/bin/box" +cp "$ROOT/VERSION" "$H4/VERSION" +ln -s "$H4/bin/box" "$B4/box" +check "migrate+upgrade: flat 0.6.0 in, new version installed beside it" 0 "" \ + inst "$H4" "$B4" BOX_INSTALL_SOURCE="$SRC9" +check "migrate+upgrade: both versions present" 0 "" \ + bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]" +check "migrate+upgrade: no boxes → the new version is the default" 0 "box 9.9.9-drill" \ + ibox "$B4/box" --version + +# A broken current must halt the single-version path BEFORE any decision: the +# CURRENT guard keys off what current resolves to, and a dangling link makes +# that answer a lie. Drive the version tree's own binary — the current chain +# is exactly what is broken. H4 has two versions; heal current afterwards. +ln -sfn "versions/gone" "$H4/current" +check "uninstall: refuses while current is dangling (heal before delete)" 1 "dangling" \ + ibox "$H4/versions/$VER/bin/box" uninstall 9.9.9-drill --force +check "uninstall: ...and both version trees survived the refusal" 0 "" \ + bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]" +ln -sfn "versions/9.9.9-drill" "$H4/current" + +# The migration reads VERSION off the old tree — disk data, not installer +# data. A hostile value must refuse BEFORE the tree moves anywhere. +H9="$WORK/h9"; B9="$WORK/b9"; mkdir -p "$H9/bin" "$B9" +cp "$ROOT/bin/box" "$H9/bin/box"; chmod +x "$H9/bin/box" +printf '%s\n' '../pwn' > "$H9/VERSION" +check "migrate: a hostile flat VERSION refuses to migrate" 1 "not a sane directory name" \ + inst "$H9" "$B9" +check "migrate: ...with the flat tree untouched where it was" 0 "" test -x "$H9/bin/box" + +# --- healing: a wedged \$BINDIR/box must never block an install ------------- +H5="$WORK/h5"; B5="$WORK/b5"; mkdir -p "$B5" +ln -s "$WORK/nowhere/box" "$B5/box" # dangling +check "heal: a DANGLING \$BINDIR/box does not wedge the install" 0 "done" inst "$H5" "$B5" +check "heal: ...and got repointed" 0 "box $VER" ibox "$B5/box" --version +H6="$WORK/h6"; B6="$WORK/b6"; mkdir -p "$B6" +ln -s /bin/true "$B6/box" # stale, but resolvable +check "heal: a STALE \$BINDIR/box with no tree does not fake 'installed'" 0 "installing $VER" \ + inst "$H6" "$B6" +check "heal: ...the install is real and answers" 0 "box $VER" ibox "$B6/box" --version + +# --- box uninstall: one version --------------------------------------------- +check "uninstall: refuses to remove the CURRENT version" 1 "CURRENT" \ + ibox "$B1/box" uninstall "$VER" --force +check "uninstall: an unknown version is refused by name" 1 "no such version" \ + ibox "$B1/box" uninstall 5.5.5 --force +check "uninstall: a path-traversal version dies at the gate (never an rm -rf)" 1 "not a sane version name" \ + ibox "$B1/box" uninstall '../../../../etc' --force +check "uninstall: a version plus --all is ambiguous (usage error)" 2 "" \ + ibox "$B1/box" uninstall 9.9.9-drill --all --force +check "uninstall: removes a non-current version" 0 "removed version" \ + ibox "$B1/box" uninstall 9.9.9-drill --force +check "uninstall: that version dir is gone" 1 "" test -e "$H1/versions/9.9.9-drill" +check "uninstall: the current version still answers" 0 "box $VER" ibox "$B1/box" --version + +# --- box uninstall: everything, in the safe order --------------------------- +check "uninstall: refuses while boxes exist, naming them" 1 "wedged" \ + ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force +check "uninstall: the refusal offers --purge-host" 1 "purge-host" \ + ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force +check "uninstall: refuses without --force when no terminal" 2 "refusing" \ + ibox bash -c "'$B1/box' uninstall --all /dev/null 2>&1 + mkdir -p "$H7/versions/$VER/stuck"; touch "$H7/versions/$VER/stuck/pin" + chmod 555 "$H7/versions/$VER/stuck" + check "uninstall: a survivor makes it scream INCOMPLETE (exit 1)" 1 "INCOMPLETE" \ + ibox "$B7/box" uninstall --all --force + chmod -R u+w "$H7" 2>/dev/null +fi + +# --- the versioned verbs from a working tree: refuse, don't guess ----------- +check "uninstall: refuses from a working tree" 1 "not a versioned install" "$BOX" uninstall --all --force +check "versions: refuses from a working tree" 1 "not a versioned install" "$BOX" versions +check "use: refuses from a working tree" 1 "not a versioned install" "$BOX" use 1.0.0 + +# The existing-boxes gate must be ONE decision: install.sh and bin/box carry +# byte-identical copies (the installer runs before any tree exists), and a +# drifted copy is two #66 stances pretending to be one. +EBBIN="$(mktemp)"; EBINST="$(mktemp)" +awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/bin/box" > "$EBBIN" +awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/install.sh" > "$EBINST" +check "existing_boxes: extracted from bin/box (guards the awk)" 0 "user.box=1" cat "$EBBIN" +check "existing_boxes: bin/box and install.sh copies are byte-identical" 0 "" diff "$EBBIN" "$EBINST" +rm -f "$EBBIN" "$EBINST" + +# Same discipline for the version-name gate: one policy, two copies, no drift +# — a version that install.sh would refuse must not be one 'box use' accepts. +VVBIN="$(mktemp)"; VVINST="$(mktemp)" +awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/bin/box" > "$VVBIN" +awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/install.sh" > "$VVINST" +check "valid_version: extracted from bin/box (guards the awk)" 0 "A-Za-z0-9" cat "$VVBIN" +check "valid_version: bin/box and install.sh copies are byte-identical" 0 "" diff "$VVBIN" "$VVINST" +rm -f "$VVBIN" "$VVINST" + +# --purge-host must FORWARD installer-family consent: under --force/BOX_YES +# the teardown call carries --yes, or a non-interactive combined uninstall +# dies at teardown's own prompt with the flag's promise broken. +# shellcheck disable=SC2016 # the $-string is a literal in the target file +check "uninstall: --purge-host forwards consent to teardown-host (--yes)" 0 "" \ + grep -qF -- 'bash "$root/host/teardown-host.sh" --yes' "$ROOT/bin/box" + +# --- the help keeps its promises -------------------------------------------- +check "help: the table lists 'versions'" 0 "versions" "$BOX" help +check "help use: names the #66 stance" 0 "boxes" "$BOX" help use +check "help uninstall: names --purge-host" 0 "purge-host" "$BOX" help uninstall +check "help uninstall: promises the absence re-check" 0 "absence" "$BOX" help uninstall + +# --- automation hooks the CI uninstall drill rides --------------------------- +check "teardown-host: honors --yes/BOX_YES (CI runs it unattended)" 0 "" \ + grep -qF 'BOX_YES' "$ROOT/host/teardown-host.sh" +check "teardown-host: points at box uninstall when done" 0 "" \ + grep -qF "box uninstall" "$ROOT/host/teardown-host.sh" +check "drill: reads the installed tree through current/" 0 "" \ + grep -qF '.local/share/box/current/VERSION' "$ROOT/drill/drill.sh" echo "---" echo "$PASS passed, $FAIL failed" -rm -rf "$SHIMDIR" +rm -rf "$SHIMDIR" "$WORK" [ "$FAIL" -eq 0 ]