2026-07-18 00:01:15 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Dependency-free CLI assertions for box. Run: bash test/cli.sh
|
|
|
|
|
#
|
|
|
|
|
# Runnable by a NON-root user with NO Incus installed — that is the whole point.
|
|
|
|
|
# Anything that needs a real incus daemon (every lifecycle command) is proven the
|
|
|
|
|
# way rig proves its root-only paths: source the pure function and drive it against
|
|
|
|
|
# a fixture, or grep the load-bearing line so a deleted guard cannot ship green.
|
|
|
|
|
# Deliberately no `set -e` — the harness asserts on failing commands.
|
|
|
|
|
set -u
|
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
|
PASS=0 FAIL=0
|
|
|
|
|
|
|
|
|
|
# check <desc> <want_exit> <want_substr> <cmd...>
|
|
|
|
|
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
|
|
|
|
# contains want_substr.
|
|
|
|
|
check() {
|
|
|
|
|
local desc="$1" want="$2" substr="$3"; shift 3
|
|
|
|
|
local out rc
|
|
|
|
|
out="$("$@" 2>&1)"; rc=$?
|
|
|
|
|
if [ "$rc" -ne "$want" ]; then
|
|
|
|
|
echo "FAIL: $desc — exit $rc, wanted $want"
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
fi
|
|
|
|
|
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
|
|
|
|
echo "FAIL: $desc — output missing '$substr'"
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
fi
|
|
|
|
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BOX="$ROOT/bin/box"
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# The CLI contract: dispatch, help, usage errors. No incus needed — these all
|
|
|
|
|
# resolve before any daemon call. Exit codes are box's own (0 ok / 1 wrong /
|
|
|
|
|
# 2 you-asked-wrong), read straight from bin/box and confirmed by running it.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# box with no args is 'help' (cmd="${1:-help}"), which prints the general usage
|
|
|
|
|
# and exits 0 — NOT rig's exit-2 bare-usage. Assert box's actual contract.
|
|
|
|
|
check "no args → general help, exit 0" 0 "USAGE" "$BOX"
|
|
|
|
|
check "no args help names the command form" 0 "box <command>" "$BOX"
|
|
|
|
|
check "--help exits 0" 0 "USAGE" "$BOX" --help
|
|
|
|
|
check "-h exits 0" 0 "USAGE" "$BOX" -h
|
|
|
|
|
check "help exits 0" 0 "USAGE" "$BOX" help
|
|
|
|
|
check "help <command> → that command's usage" 0 "usage: box new" "$BOX" help new
|
|
|
|
|
check "--version exits 0" 0 "box" "$BOX" --version
|
|
|
|
|
# Unknown command is a usage error (2), and it says so — the suggester may add a
|
|
|
|
|
# 'did you mean', but the stem is stable.
|
|
|
|
|
check "unknown command exits 2" 2 "unknown command" "$BOX" frobnicate
|
|
|
|
|
check "unknown command points at help" 2 "box help" "$BOX" zzzzzz
|
|
|
|
|
# Options before the command are the classic mistake; box names the fix.
|
|
|
|
|
check "option before command exits 2" 2 "options come after the command" "$BOX" --json list
|
|
|
|
|
# A missing required positional is a usage error carrying that command's synopsis.
|
|
|
|
|
check "new without --name exits 2" 2 "usage: box new" "$BOX" new
|
|
|
|
|
check "shell without a box exits 2" 2 "usage: box shell" "$BOX" shell
|
|
|
|
|
check "restore without arg2 needs a box first" 2 "usage: box restore" "$BOX" restore
|
|
|
|
|
# An unknown flag is refused, not swallowed as a positional (the --labl bug).
|
|
|
|
|
check "unknown flag on list exits 2" 2 "unknown option" "$BOX" list --nope
|
|
|
|
|
# A flag that needs a value and gets none.
|
|
|
|
|
check "--name with no value exits 2" 2 "--name needs a value" "$BOX" new --name
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# A shim `id` on PATH: lets us drive install.sh's DEST branch with a canned uid +
|
|
|
|
|
# group output, exactly the way rig drives assert_runner_repo against fixtures.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
SHIMDIR="$(mktemp -d)"
|
|
|
|
|
cat > "$SHIMDIR/id" <<'SHIM'
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Fake `id`: -u prints $FAKE_UID, -nG prints $FAKE_GROUPS. Just enough for
|
|
|
|
|
# install.sh's DEST branch, which only ever asks these two.
|
|
|
|
|
case "${1:-}" in
|
|
|
|
|
-u) printf '%s\n' "${FAKE_UID:-1000}" ;;
|
|
|
|
|
-nG) printf '%s\n' "${FAKE_GROUPS:-}" ;;
|
|
|
|
|
*) exit 0 ;;
|
|
|
|
|
esac
|
|
|
|
|
SHIM
|
|
|
|
|
chmod +x "$SHIMDIR/id"
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# install.sh — #71 global/root install. bash -n first, then drive the actual
|
|
|
|
|
# DEST/BINDIR branch with the shim id (the functional proof the contract asks
|
|
|
|
|
# for), then grep the root-only pieces that a daemon-free run cannot exercise.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
check "install.sh is valid bash" 0 "" bash -n "$ROOT/install.sh"
|
|
|
|
|
# Extract EXACTLY the DEST/BINDIR if/else/fi (the first `id -u -eq 0` block) and
|
|
|
|
|
# print what it resolved — the same "run the pure block in isolation" trick rig
|
|
|
|
|
# uses for its embedded dump script. Fail closed: a mangled extraction is caught
|
|
|
|
|
# by the /opt/box grep below before any resolution is trusted.
|
|
|
|
|
DBLOCK="$(mktemp)"
|
|
|
|
|
awk '/id -u.*-eq 0/{f=1} f{print} f&&/^fi$/{exit}' "$ROOT/install.sh" > "$DBLOCK"
|
|
|
|
|
# The $DEST/$BINDIR here are LITERAL text appended into the extracted block — they
|
|
|
|
|
# must expand when that block RUNS, not when this printf writes it. Hence single
|
|
|
|
|
# quotes; SC2016 is the intent.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
printf '\nprintf "DEST=%%s BINDIR=%%s\\n" "$DEST" "$BINDIR"\n' >> "$DBLOCK"
|
|
|
|
|
check "install.sh: DEST block extracted (guards the awk)" 0 "/opt/box" cat "$DBLOCK"
|
|
|
|
|
check "install.sh: the extracted DEST block is valid bash" 0 "" bash -n "$DBLOCK"
|
|
|
|
|
|
|
|
|
|
dest() { # dest <uid> [extra env assignments...] — resolve DEST/BINDIR
|
|
|
|
|
local uid="$1"; shift
|
|
|
|
|
FAKE_UID="$uid" HOME=/home/tester PATH="$SHIMDIR:$PATH" env "$@" bash "$DBLOCK"
|
|
|
|
|
}
|
|
|
|
|
# Root: the global path — a system tree other users can read (#71).
|
|
|
|
|
check "install.sh: root → DEST=/opt/box" 0 "DEST=/opt/box" dest 0
|
|
|
|
|
check "install.sh: root → BINDIR=/usr/local/bin" 0 "BINDIR=/usr/local/bin" dest 0
|
|
|
|
|
# Non-root: unchanged, the solo path.
|
|
|
|
|
check "install.sh: non-root → DEST=\$HOME/.local" 0 "DEST=/home/tester/.local/share/box" dest 1000
|
|
|
|
|
check "install.sh: non-root → BINDIR=\$HOME/.local" 0 "BINDIR=/home/tester/.local/bin" dest 1000
|
|
|
|
|
# BOX_HOME / BOX_BIN still win on BOTH branches — the scripting override.
|
|
|
|
|
check "install.sh: BOX_HOME overrides the root default" 0 "DEST=/srv/box" dest 0 BOX_HOME=/srv/box
|
|
|
|
|
check "install.sh: BOX_BIN overrides the root default" 0 "BINDIR=/srv/bin" dest 0 BOX_BIN=/srv/bin
|
|
|
|
|
check "install.sh: BOX_HOME overrides the non-root default" 0 "DEST=/srv/box" dest 1000 BOX_HOME=/srv/box
|
|
|
|
|
rm -f "$DBLOCK"
|
|
|
|
|
# The root-only world-readable chmod (#71): the tree is EXECUTED by other users,
|
|
|
|
|
# so root must open read+traverse. Grep it, and that it is root-guarded so the
|
|
|
|
|
# per-user install stays byte-identical to before.
|
|
|
|
|
# $DEST is a LITERAL in the grep pattern (install.sh's own variable) — single
|
|
|
|
|
# quotes intended.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "install.sh: root makes the tree world-readable (a+rX)" 0 "" \
|
|
|
|
|
grep -qF 'chmod -R a+rX "$DEST"' "$ROOT/install.sh"
|
|
|
|
|
check "install.sh: the a+rX is root-guarded" 0 "" \
|
|
|
|
|
bash -c 'grep -B2 "chmod -R a+rX" "'"$ROOT"'/install.sh" | grep -q "id -u.*-eq 0"'
|
|
|
|
|
# #66's flow, preserved: confirm-before-download, and no-op if already installed.
|
|
|
|
|
check "install.sh: still confirms before downloading (#66)" 0 "" \
|
|
|
|
|
grep -qF 'confirm "Install box from' "$ROOT/install.sh"
|
|
|
|
|
check "install.sh: still no-ops on an existing install (#66)" 0 "" \
|
|
|
|
|
grep -qF 'already installed' "$ROOT/install.sh"
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Templates — #65 tmux. `box tmux` runs `tmux new-session` INSIDE the box, so a
|
|
|
|
|
# template that never installs tmux fails with "tmux: command not found". Every
|
|
|
|
|
# template must carry it in its cloud-init package list.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
for t in blank claude codex grok; do
|
|
|
|
|
check "template '$t': installs tmux (#65)" 0 "" \
|
|
|
|
|
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$ROOT/templates/$t/user-data.yaml"
|
|
|
|
|
done
|
|
|
|
|
|
test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# The restricted tier (#74). box_tier() is the decision the whole tier hangs
|
|
|
|
|
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run
|
|
|
|
|
# against a shim id for every case — including the one that bites (a user in
|
|
|
|
|
# BOTH groups is admin: membership wins at the socket, and the function must
|
|
|
|
|
# not substring-match 'incus' inside 'incus-admin').
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
TIERFN="$(mktemp)"
|
|
|
|
|
awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/bin/box" > "$TIERFN"
|
|
|
|
|
check "box_tier: extracted from bin/box (guards the awk)" 0 "incus-admin" cat "$TIERFN"
|
|
|
|
|
check "box_tier: the extracted function is valid bash" 0 "" bash -n "$TIERFN"
|
|
|
|
|
|
|
|
|
|
tier() { # tier <uid> <groups...>
|
|
|
|
|
local uid="$1"; shift
|
|
|
|
|
FAKE_UID="$uid" FAKE_GROUPS="$*" PATH="$SHIMDIR:$PATH" \
|
|
|
|
|
bash -c ". '$TIERFN'; box_tier"
|
|
|
|
|
}
|
|
|
|
|
check "box_tier: uid 0 → admin" 0 "admin" tier 0
|
|
|
|
|
check "box_tier: incus-admin → admin" 0 "admin" tier 1000 "users incus-admin"
|
|
|
|
|
check "box_tier: incus only → restricted" 0 "restricted" tier 1000 "users incus"
|
|
|
|
|
check "box_tier: both groups → admin (membership wins at the socket)" \
|
|
|
|
|
0 "admin" tier 1000 "users incus incus-admin"
|
|
|
|
|
check "box_tier: neither → none" 0 "none" tier 1000 "users dialout"
|
|
|
|
|
rm -f "$TIERFN"
|
|
|
|
|
|
|
|
|
|
# setup-host.sh must decide the tier BEFORE any install tree exists, so it
|
|
|
|
|
# carries its own copy — and a drifted copy is two tiers pretending to be one.
|
|
|
|
|
# Byte-identical, asserted.
|
|
|
|
|
BINFN="$(mktemp)"; HOSTFN="$(mktemp)"
|
|
|
|
|
awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/bin/box" > "$BINFN"
|
|
|
|
|
awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$HOSTFN"
|
|
|
|
|
check "box_tier: bin/box and setup-host.sh copies are byte-identical" 0 "" \
|
|
|
|
|
diff "$BINFN" "$HOSTFN"
|
|
|
|
|
rm -f "$BINFN" "$HOSTFN"
|
|
|
|
|
|
|
|
|
|
# The tier scripts parse and refuse bad usage without a daemon — drive them.
|
|
|
|
|
check "grant: no argument is a usage error" 2 "usage: box grant" bash "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: a flag is not a user" 2 "usage: box grant" bash "$ROOT/host/grant-user.sh" --frob
|
|
|
|
|
check "revoke: no argument is a usage error" 2 "usage: box revoke" bash "$ROOT/host/revoke-user.sh"
|
|
|
|
|
check "revoke: two users is a usage error" 2 "usage: box revoke" bash "$ROOT/host/revoke-user.sh" a b
|
|
|
|
|
check "box grant with no user exits 2 (via the CLI table)" 2 "usage: box grant" "$BOX" grant
|
|
|
|
|
check "box revoke with no user exits 2 (via the CLI table)" 2 "usage: box revoke" "$BOX" revoke
|
|
|
|
|
check "help grant names the hardened network" 0 "boxnet" "$BOX" help grant
|
|
|
|
|
check "help revoke names --purge" 0 "purge" "$BOX" help revoke
|
|
|
|
|
|
|
|
|
|
# Load-bearing lines a daemon-free run cannot exercise — grepped so a deleted
|
|
|
|
|
# guard cannot ship green (the house test discipline).
|
|
|
|
|
# The expose guard must fire before ANY incus call in cmd_expose: line order.
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
check "expose: the restricted guard precedes the first incus call" 0 "" bash -c '
|
|
|
|
|
fn="$(awk "/^cmd_expose\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
guard="$(printf "%s\n" "$fn" | grep -n "box_tier" | head -1 | cut -d: -f1)"
|
|
|
|
|
first="$(printf "%s\n" "$fn" | grep -n "incus config" | head -1 | cut -d: -f1)"
|
|
|
|
|
[ -n "$guard" ] && [ -n "$first" ] && [ "$guard" -lt "$first" ]'
|
|
|
|
|
# cmd_new refuses before minting when the placement contract is absent, and
|
|
|
|
|
# the message is tier-aware (a restricted user is sent to 'box grant', not
|
|
|
|
|
# to setup-host they cannot run).
|
|
|
|
|
check "new: pre-flights the box-net profile" 0 "" bash -c '
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
|
|
|
|
|
check "new: the restricted fix names box grant" 0 "" bash -c '
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
|
|
|
|
|
# grant converges to boxnet and ONLY boxnet — "boxnet,incusbr" would keep the
|
|
|
|
|
# unhardened private bridge one --network flag away (the #74 measured hole).
|
|
|
|
|
check "grant: narrows access to boxnet alone" 0 "" \
|
|
|
|
|
grep -qE 'restricted\.networks\.access boxnet($| )' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: never grants the private bridge" 1 "" \
|
|
|
|
|
grep -qE 'networks\.access[^#]*incusbr' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: allows snapshots (the clone workflow)" 0 "" \
|
|
|
|
|
grep -qF 'restricted.snapshots allow' "$ROOT/host/grant-user.sh"
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
check "grant: installs the SHIPPED profile into the project" 0 "" \
|
|
|
|
|
grep -qF 'profile edit box-net < "$here/profiles/box-net.yaml"' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: unpins the private-bridge eth0 from the default profile" 0 "" \
|
|
|
|
|
grep -qF 'profile device remove default eth0' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: refuses an incus-admin member (nothing tighter to grant)" 0 "" \
|
|
|
|
|
grep -qF 'incus-admin' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "revoke: group removal is the lockout" 0 "" \
|
|
|
|
|
grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh"
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
# Group membership is read at login: purge must terminate live sessions (a
|
|
|
|
|
# stale-group process could recreate the project unhardened AFTER the purge),
|
|
|
|
|
# and a bare revoke must say the socket survives in held sessions.
|
|
|
|
|
check "revoke: purge terminates live sessions first" 0 "" \
|
|
|
|
|
grep -qF 'loginctl terminate-user' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
check "revoke: purge refuses under unkillable sessions" 0 "" \
|
|
|
|
|
grep -qF 'refusing to purge under them' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
check "revoke: bare revoke warns about held sessions" 0 "" \
|
|
|
|
|
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
check "revoke: the purge asserts the certificate's absence too" 0 "" \
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
# A failed grant must not leave a half-granted user: if THIS run added the
|
|
|
|
|
# group, the exit path takes it back (and the trap disarms only on success).
|
|
|
|
|
check "grant: backs out its own group-add on failure" 0 "" \
|
|
|
|
|
grep -qF 'trap backout EXIT' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: the back-out disarms on success" 0 "" \
|
|
|
|
|
grep -qF 'trap - EXIT' "$ROOT/host/grant-user.sh"
|
grant/rehearsal: the codex round — verified rollback, loud partial states, and the raw-attach guarantee measured (#75)
Review 4727756972 (A2): the backout no longer trusts gpasswd — it re-reads
the live group database after removal; verified-absent gets the safe
message, anything else screams ROLLBACK INCOMPLETE, exits nonzero, and
names the exact remediation. The concurrent-login window (a session begun
between usermod and backout keeps the group) is CLOSED to the extent the
database can't reach: the backout detects live processes and names
loginctl terminate-user, and the success wording claims only what was
verified.
Review 4727641752 (A1): a failed grant for a user whose membership predates
the run (the hand-added-user scenario) now fails LOUDLY — they retain
socket access on part-converged policy, and the message says so with both
remediations (box revoke now, or fix and re-run). Their membership is not
stripped: breaking a working user over a failed re-grant is its own hazard.
The default-profile eth0 removal is deliberately not restored on failure —
that mutation only reduces capability, and restoring it would move the
failure state AWAY from fail-closed. Injected-failure coverage is criterion
(n), both flavors: fresh-user backout (fault at the LAST mutation, so the
rollback runs after every earlier one) with the group's absence verified
and a converging re-run; blocked narrowing staged for real with an
instance-local NIC parked on the private bridge.
Review A3, resolution 3 with the measurement demanded: criterion (m)
launches exactly 'incus launch --network boxnet' as the restricted user and
probes the raw NIC from inside — egress works, RFC1918 dropped (the ACL is
the network's), sibling probes dropped BOTH directions (the nft drop is the
host's), name enumeration blocked. The scoped guarantee is now stated in
box-design.md and measured on every run: box-minted instances carry per-NIC
port_isolation; raw attachments keep every network- and host-owned control,
losing only that redundant L2 layer. Instrument lesson kept as MU-5: the
probe's first cut minted the non-cloud image — no DHCP client, no lease,
and a dead NIC passes every negative probe vacuously; it now requires the
lease before believing its own answers.
Rehearsal: 54/54 (containers). test/cli.sh: 82 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:41:05 +00:00
|
|
|
# The backout must VERIFY the removal and scream when it cannot — an
|
|
|
|
|
# unverified rollback printing a security guarantee is the review's A2.
|
|
|
|
|
check "grant: the backout verifies against the group database" 0 "" \
|
|
|
|
|
bash -c 'awk "/^backout\(\) \{/,/^\}/" "'"$ROOT"'/host/grant-user.sh" | grep -q "id -nG"'
|
|
|
|
|
check "grant: an unverifiable rollback screams" 0 "" \
|
|
|
|
|
grep -qF 'ROLLBACK INCOMPLETE' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: a failed re-grant warns the pre-existing member is untouched" 0 "" \
|
|
|
|
|
grep -qF 'still holding socket access' "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "grant: the mid-grant login window is named" 0 "" \
|
|
|
|
|
bash -c 'awk "/^backout\(\) \{/,/^\}/" "'"$ROOT"'/host/grant-user.sh" | grep -q "loginctl terminate-user"'
|
|
|
|
|
# The scoped guarantee (raw --network boxnet) is measured, not prose:
|
|
|
|
|
check "rehearsal: measures the raw boxnet attach (criterion m)" 0 "" \
|
|
|
|
|
grep -qF -- '--network boxnet' "$ROOT/drill/multiuser.sh"
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
check "rehearsal: injects grant failures (criterion n)" 0 "" \
|
|
|
|
|
grep -qF 'grant-user.sh" "$U3"' "$ROOT/drill/multiuser.sh"
|
test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
check "revoke: purge deletes instances one at a time" 0 "" \
|
|
|
|
|
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
check "revoke: purge removes the trust-store certificate" 0 "" \
|
|
|
|
|
grep -qF 'config trust remove' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
check "setup-host: the restricted gate precedes the sudo resolution" 0 "" bash -c '
|
|
|
|
|
gate="$(grep -n "restricted tier" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
sudo="$(grep -n "^elif command -v sudo" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
[ -n "$gate" ] && [ -n "$sudo" ] && [ "$gate" -lt "$sudo" ]'
|
|
|
|
|
check "setup-host: enables incus-user.socket for the tier" 0 "" \
|
|
|
|
|
grep -qF 'incus-user.socket' "$ROOT/host/setup-host.sh"
|
|
|
|
|
check "doctor: honors BOX_TIER" 0 "" \
|
|
|
|
|
grep -qF 'BOX_TIER' "$ROOT/drill/doctor.sh"
|
|
|
|
|
check "box exports BOX_TIER to the doctor" 0 "" \
|
|
|
|
|
grep -qF 'export BOX_TIER' "$ROOT/bin/box"
|
|
|
|
|
# 'box restore' must speak incus 6 ('snapshot restore'); bare 'incus restore'
|
|
|
|
|
# does not exist and the verb was broken for everyone until #74's rehearsal hit it.
|
|
|
|
|
check "restore: dispatches 'incus snapshot restore'" 0 "" \
|
|
|
|
|
grep -qF '^incus:snapshot restore^' "$ROOT/bin/box"
|
|
|
|
|
# The rehearsal itself stays runnable: syntax-checked here, run on real hosts.
|
|
|
|
|
check "multiuser.sh is valid bash" 0 "" bash -n "$ROOT/drill/multiuser.sh"
|
|
|
|
|
check "multiuser.sh refuses without the env gate" 2 "opt in" \
|
|
|
|
|
bash "$ROOT/drill/multiuser.sh" --yes
|
|
|
|
|
check "grant-user.sh is valid bash" 0 "" bash -n "$ROOT/host/grant-user.sh"
|
|
|
|
|
check "revoke-user.sh is valid bash" 0 "" bash -n "$ROOT/host/revoke-user.sh"
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
check "teardown-host.sh is valid bash" 0 "" bash -n "$ROOT/host/teardown-host.sh"
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Revoke leaves NOTHING (the grant/revoke cleanliness pass). The gap this
|
|
|
|
|
# closes: --purge removed /var/lib/incus/users/<uid> 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 <box_home> <box_bin> [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 ...] <cmd...> — 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/<v>" 0 "" test -x "$H1/versions/$VER/bin/box"
|
|
|
|
|
check "install: 'current' points at versions/<v>" 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
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
# 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'
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
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
|
|
|
|
|
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
# 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"
|
|
|
|
|
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
# --- 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
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
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
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
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"
|
|
|
|
|
# Plant legacy crumbs: a real uninstall leaves neither name generation behind.
|
|
|
|
|
mkdir -p "$FAKEHOME/.local/share/claudebox"
|
|
|
|
|
ln -s "$WORK/gone" "$B1/claudebox"
|
|
|
|
|
check "uninstall --all: removes the whole install" 0 "uninstalled" \
|
|
|
|
|
ibox "$B1/box" uninstall --all --force
|
|
|
|
|
check "uninstall --all: ZERO residue — root, symlinks, legacy names" 0 "" bash -c "
|
|
|
|
|
[ ! -e '$H1' ] && [ ! -L '$H1' ] &&
|
|
|
|
|
[ ! -e '$B1/box' ] && [ ! -L '$B1/box' ] &&
|
|
|
|
|
[ ! -e '$B1/claudebox' ] && [ ! -L '$B1/claudebox' ] &&
|
|
|
|
|
[ ! -e '$FAKEHOME/.local/share/claudebox' ]"
|
|
|
|
|
# The last word is a re-check: a survivor must turn into a loud INCOMPLETE,
|
|
|
|
|
# never a cheerful "uninstalled". (Root ignores file modes, so this drill is
|
|
|
|
|
# meaningful — and runnable — for a non-root runner only.)
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
H7="$WORK/h7"; B7="$WORK/b7"
|
|
|
|
|
inst "$H7" "$B7" >/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"
|
|
|
|
|
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
# 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"
|
|
|
|
|
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
# --- 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"
|
2026-07-18 00:01:15 +00:00
|
|
|
|
|
|
|
|
echo "---"
|
|
|
|
|
echo "$PASS passed, $FAIL failed"
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
rm -rf "$SHIMDIR" "$WORK"
|
2026-07-18 00:01:15 +00:00
|
|
|
[ "$FAIL" -eq 0 ]
|