forked from heavy-duty/box
3187 lines
184 KiB
Bash
3187 lines
184 KiB
Bash
#!/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
|
||
# BOX_YES is this family's documented automation switch, so an operator's CI
|
||
# wrapper may well export it. Checks that drive a destructive script for real
|
||
# would then take the CONSENT arm instead of the refusal they are asserting —
|
||
# turning this suite into `box uninstall --purge-host` on the host it runs on.
|
||
# Individual call sites use `env -u BOX_YES`; this is the belt to that braces,
|
||
# so the header's "runnable anywhere" promise cannot be broken by one export.
|
||
unset BOX_YES
|
||
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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# box exec — preserve command argv across the login-environment boundary
|
||
# (#169). `sudo -i <command...>` joins argv into one shell string; its escaped
|
||
# newline becomes a continuation, so a multi-line body can fuse into a
|
||
# different valid command and still return 0. Drive cmd_exec through a fake
|
||
# incus boundary that validates the wrapper shape and then executes it. This
|
||
# test therefore fails against the old -i implementation before trusting the
|
||
# marker files.
|
||
# ---------------------------------------------------------------------------
|
||
EXECFN="$(mktemp)"
|
||
grep '^cmd_exec()' "$BOX" > "$EXECFN"
|
||
check "box exec: cmd_exec extracted (guards the grep)" 0 "exec \"\$@\"" cat "$EXECFN"
|
||
check "box exec: extracted function is valid bash" 0 "" bash -n "$EXECFN"
|
||
check "box exec: command argv never rides sudo -i" 1 "" grep -q -- ' -i ' "$EXECFN"
|
||
|
||
exec_fixture() { # exec_fixture <command> [arg...]
|
||
bash -c '
|
||
set -e
|
||
. "$0"
|
||
box_user() { printf "%s\n" fixture-user; }
|
||
incus() {
|
||
[ "$1" = exec ] && [ "$2" = fixture ] && [ "$3" = -- ]
|
||
shift 3
|
||
[ "$1" = sudo ] && [ "$2" = -u ] && [ "$3" = fixture-user ] &&
|
||
[ "$4" = -H ]
|
||
shift 4
|
||
"$@"
|
||
}
|
||
inst=fixture
|
||
args=(fixture "$@")
|
||
cmd_exec
|
||
' "$EXECFN" "$@"
|
||
}
|
||
|
||
EXEC_STATE="$(mktemp -d)"
|
||
exec_body="set -euo pipefail
|
||
touch '$EXEC_STATE/step-one'
|
||
touch '$EXEC_STATE/step-two'"
|
||
check "box exec: silent-success multiline body executes each statement" 0 "" \
|
||
exec_fixture bash -lc "$exec_body"
|
||
check "box exec: multiline step one was not fused into set argv" 0 "" \
|
||
test -f "$EXEC_STATE/step-one"
|
||
check "box exec: multiline step two was not fused into set argv" 0 "" \
|
||
test -f "$EXEC_STATE/step-two"
|
||
check "box exec: plain argv remains separate" 0 "one argument" \
|
||
exec_fixture printf '%s\n' "one argument"
|
||
rm -rf "$EXEC_STATE"
|
||
rm -f "$EXECFN"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 — DYNAMIC over templates/*/ (#68): the loop discovers every
|
||
# template directory, so a new template cannot ship without passing these (the
|
||
# old hardcoded blank/claude/codex/grok list let exactly that happen). The
|
||
# box.env parse is proven against the REAL allowlist: load_template is
|
||
# extracted from bin/box and DRIVEN against each template — the same
|
||
# source-the-pure-function trick install.sh's DEST block and box_tier get
|
||
# below — so an unknown key, a missing BOX_IMAGE/BOX_USER, or a line that is
|
||
# not KEY="value" fails HERE, not at mint time on a host.
|
||
# ---------------------------------------------------------------------------
|
||
TPLFN="$(mktemp)"
|
||
awk '/^load_template\(\) \{/,/^\}/' "$ROOT/bin/box" > "$TPLFN"
|
||
check "load_template: extracted from bin/box (guards the awk)" 0 "unknown key" cat "$TPLFN"
|
||
check "load_template: the extracted function is valid bash" 0 "" bash -n "$TPLFN"
|
||
|
||
# tpl <root> <template> — run the real parser against <root>/templates/, print
|
||
# what it resolved. $0 carries the extracted-function file into the subshell.
|
||
tpl() {
|
||
root="$1" bash -c '
|
||
die() { echo "box: $*" >&2; exit 1; }
|
||
. "$0"; load_template "$1"
|
||
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s ROLE=%s\n" \
|
||
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART" "$T_BOOTSTRAP_ROLE"
|
||
' "$TPLFN" "$2"
|
||
}
|
||
|
||
# The allowlist itself is load-bearing: a template must not be able to grow a
|
||
# network key, and the required keys must still be required. Fixture-driven,
|
||
# against a throwaway root — exactly the dies a green parse cannot prove.
|
||
EVILROOT="$(mktemp -d)"; mkdir -p "$EVILROOT/templates/evil"
|
||
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="dev"\nBOX_NETWORK="lan"\n' \
|
||
> "$EVILROOT/templates/evil/box.env"
|
||
check "load_template: an unknown key dies (no template grows a network)" 1 "unknown key" \
|
||
tpl "$EVILROOT" evil
|
||
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
|
||
check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil
|
||
# The boot demands' green path, kept as a fixture even now that staging sets
|
||
# them in-tree: fixtures survive a template rename, and a deleted case arm
|
||
# must fail HERE, through the real parser, not at first use on a host.
|
||
mkdir -p "$EVILROOT/templates/server"
|
||
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
|
||
> "$EVILROOT/templates/server/box.env"
|
||
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
|
||
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server
|
||
# BOX_BOOTSTRAP_ROLE (#81): accepted and surfaced through the real parser —
|
||
# and the value is a rig role NAME, nothing more. It is handed to
|
||
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
|
||
# it must die at parse time, on the host, before a guest exists.
|
||
mkdir -p "$EVILROOT/templates/tenant"
|
||
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude"\n' \
|
||
> "$EVILROOT/templates/tenant/box.env"
|
||
check "load_template: BOX_BOOTSTRAP_ROLE round-trips (accepted + surfaced)" \
|
||
0 "ROLE=claude" tpl "$EVILROOT" tenant
|
||
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude; rm -rf /"\n' \
|
||
> "$EVILROOT/templates/tenant/box.env"
|
||
check "load_template: a shell-shaped BOX_BOOTSTRAP_ROLE dies at the gate" \
|
||
1 "not a sane role name" tpl "$EVILROOT" tenant
|
||
rm -rf "$EVILROOT"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# render_userdata (#81) — the seed's ONE substitution, driven for real: the
|
||
# rig pin point. Defaults resolve to heavy-duty/rig@main; RIG_REPO/RIG_REF
|
||
# override at mint (how a rig branch under review reaches a guest); and a
|
||
# hostile value — the tokens land inside a runcmd shell line — dies on the
|
||
# host before touching the YAML. bash's =~ anchors the WHOLE string, so a
|
||
# multi-line value cannot sneak one clean line past it (the line-oriented
|
||
# grep -q failure mode).
|
||
# ---------------------------------------------------------------------------
|
||
RUFN="$(mktemp)"
|
||
# The pin's DEFAULTS live in rig_repo/rig_ref, one definition shared with the
|
||
# mint stamp (#103) — extract them alongside the function that reads them, or
|
||
# the extracted copy silently renders an empty repo and every assertion below
|
||
# goes green against nothing.
|
||
{ grep -cE '^rig_(repo|ref)\(\)' "$ROOT/bin/box" | grep -qx 2 || echo 'die "the rig pin helpers moved — this extraction is stale"'
|
||
grep -E '^rig_(repo|ref)\(\)' "$ROOT/bin/box"
|
||
awk '/^render_userdata\(\) \{/,/^\}/' "$ROOT/bin/box"; } > "$RUFN"
|
||
check "render_userdata: extracted from bin/box (guards the awk)" 0 "RIG_REPO" cat "$RUFN"
|
||
check "render_userdata: the pin's defaults came with it (guards the grep)" 0 "heavy-duty/rig" cat "$RUFN"
|
||
check "render_userdata: the extracted function is valid bash" 0 "" bash -n "$RUFN"
|
||
|
||
SEED="$(mktemp)"
|
||
printf '#cloud-config\nruncmd:\n - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash\n' > "$SEED"
|
||
# shellcheck disable=SC2016 # $0/$1 expand in the child shell, by design
|
||
rud() { # rud [VAR=val ...] — render the fixture seed through the real function
|
||
env "$@" bash -c 'die() { echo "box: $*" >&2; exit 1; }; . "$0"; render_userdata "$1"' "$RUFN" "$SEED"
|
||
}
|
||
check "render_userdata: defaults pin heavy-duty/rig" 0 "githubusercontent.com/heavy-duty/rig/main/install.sh" rud
|
||
check "render_userdata: defaults feed the installer's own env too" 0 'RIG_REPO="heavy-duty/rig" RIG_REF="main"' rud
|
||
check "render_userdata: RIG_REPO/RIG_REF override at mint" 0 "dan-claude-bot/rig/feat/bootstrap-roles/install.sh" \
|
||
rud RIG_REPO=dan-claude-bot/rig RIG_REF=feat/bootstrap-roles
|
||
# shellcheck disable=SC2016 # $0/$1 expand in the child shells, by design
|
||
check "render_userdata: no token survives the render" 1 "" \
|
||
bash -c 'env bash -c "die() { echo box: \$*; exit 1; }; . \"\$0\"; render_userdata \"\$1\"" "$1" "$2" | grep -q @RIG_' _ "$RUFN" "$SEED"
|
||
check "render_userdata: a shell-shaped RIG_REPO dies on the host" 1 "RIG_REPO" \
|
||
rud 'RIG_REPO=evil"; rm -rf /; "/rig'
|
||
check "render_userdata: a spaced RIG_REF dies on the host" 1 "RIG_REF" \
|
||
rud 'RIG_REF=main plus junk'
|
||
check "render_userdata: a newline-smuggled RIG_REPO dies (whole-string anchor)" 1 "RIG_REPO" \
|
||
rud "RIG_REPO=$(printf 'a/b\nevil')"
|
||
rm -f "$RUFN" "$SEED"
|
||
|
||
# YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip
|
||
# gracefully (never silently) where they are missing.
|
||
HAVE_YAML=0
|
||
command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null && HAVE_YAML=1
|
||
|
||
for d in "$ROOT"/templates/*/; do
|
||
t="$(basename "$d")"
|
||
# The parse itself asserts the allowlist AND the required keys (the driven
|
||
# function dies without BOX_IMAGE/BOX_USER); the greps pin both keys to the
|
||
# FILE, so neither can quietly become an inherited default.
|
||
check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t"
|
||
check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env"
|
||
check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env"
|
||
# cloud-init is passed to Incus verbatim (modulo the two rig pin tokens),
|
||
# so it must exist, declare itself, and be well-formed — a mint is far too
|
||
# late to learn about a typo.
|
||
check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml"
|
||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||
check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \
|
||
bash -c 'head -1 "$1" | grep -qx "#cloud-config"' _ "$d/user-data.yaml"
|
||
if [ "$HAVE_YAML" = 1 ]; then
|
||
check "template '$t': user-data.yaml is well-formed YAML" 0 "" \
|
||
python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1]))' "$d/user-data.yaml"
|
||
else
|
||
echo "skip: template '$t' YAML well-formedness (no python3+pyyaml here; CI has both)"
|
||
fi
|
||
# #65: 'box tmux' runs 'tmux new-session' INSIDE the box, so every
|
||
# template's package list must carry tmux or the verb dies inside.
|
||
check "template '$t': installs tmux (#65)" 0 "" \
|
||
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml"
|
||
# BOX_USER is duplicated into the cloud-init by hand (the file reaches
|
||
# Incus verbatim) — assert the two halves actually agree, per template.
|
||
tuser="$(tpl "$ROOT" "$t" | sed -n 's/.*USER=\([^ ]*\).*/\1/p')"
|
||
check "template '$t': user-data.yaml creates BOX_USER ('$tuser')" 0 "" \
|
||
grep -qE "^[[:space:]]*-[[:space:]]+name:[[:space:]]+$tuser\$" "$d/user-data.yaml"
|
||
|
||
# ------------------------------------------------------------------------
|
||
# The thin-template contract (#81), both halves per template:
|
||
#
|
||
# THE SEED — a template that names a tenant role (BOX_BOOTSTRAP_ROLE) must
|
||
# preinstall rig carrying BOTH pin tokens, on the installer URL and on the
|
||
# installer's own env, or the pin is a half-truth: a mint would fetch one
|
||
# ref's installer and install another ref's tree.
|
||
# ------------------------------------------------------------------------
|
||
trole="$(tpl "$ROOT" "$t" | sed -n 's/.*ROLE=\([^ ]*\).*/\1/p')"
|
||
if [ -n "$trole" ]; then
|
||
check "template '$t': the seed installs rig (role '$trole')" 0 "" \
|
||
grep -q 'install.sh' "$d/user-data.yaml"
|
||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||
check "template '$t': the rig install carries the @RIG_REPO@ pin token" 0 "" \
|
||
bash -c 'grep "install.sh" "$1" | grep -q "@RIG_REPO@/@RIG_REF@"' _ "$d/user-data.yaml"
|
||
# shellcheck disable=SC2016
|
||
check "template '$t': the pin reaches the installer's env too" 0 "" \
|
||
bash -c 'grep "install.sh" "$1" | grep -q "RIG_REPO=\"@RIG_REPO@\" RIG_REF=\"@RIG_REF@\""' _ "$d/user-data.yaml"
|
||
# HOME=/root: a scar found live — cloud-init's runcmd has no $HOME and
|
||
# rig's installer (set -u) dies on it (rig#39). The pin must survive
|
||
# every seed rewrite.
|
||
# shellcheck disable=SC2016
|
||
check "template '$t': the rig install pins HOME=/root (runcmd has no \$HOME)" 0 "" \
|
||
bash -c 'grep "install.sh" "$1" | grep -q "HOME=/root "' _ "$d/user-data.yaml"
|
||
fi
|
||
# ------------------------------------------------------------------------
|
||
# THE ABSENCE — no tenant content in ANY template, ever again. Everything a
|
||
# box becomes lives in rig's roles (rig#31); a template that grows an agent
|
||
# CLI, docker, node, a tailnet join or a context-file heredoc is the
|
||
# regression this suite exists to refuse. Greps run over EFFECTIVE
|
||
# cloud-init lines (comments may name what they refuse — #69's idiom), and
|
||
# they fail CLOSED: the want-exit is 1, so re-adding any of it goes red.
|
||
# ------------------------------------------------------------------------
|
||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||
check "template '$t': no agent CLI install (rig's job, rig#31)" 1 "" \
|
||
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "claude\.ai|x\.ai|@openai|npm|nodesource|nodejs"' _ "$d/user-data.yaml"
|
||
# shellcheck disable=SC2016
|
||
check "template '$t': no docker (rig's job, rig#31)" 1 "" \
|
||
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qi docker' _ "$d/user-data.yaml"
|
||
# shellcheck disable=SC2016
|
||
check "template '$t': nothing that joins or admits (no tailscale/authkey/ssh)" 1 "" \
|
||
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "tailscale|authkey|ssh"' _ "$d/user-data.yaml"
|
||
# shellcheck disable=SC2016
|
||
check "template '$t': no context-file heredoc (the #80 guard lives in rig's roles)" 1 "" \
|
||
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "write_files|CLAUDE\.md|AGENTS\.md"' _ "$d/user-data.yaml"
|
||
done
|
||
|
||
# The staging seed's boot demands are part of its contract (#68/#69): the VM
|
||
# is its trust boundary (its guest runs docker, via rig) and a server returns
|
||
# from a host reboot without an operator. Pinned to the FILE so neither can
|
||
# quietly vanish in a rewrite.
|
||
check "staging-box: demands VM mode (BOX_REQUIRE_VM=1)" 0 "" \
|
||
grep -qx 'BOX_REQUIRE_VM="1"' "$ROOT/templates/staging-box/box.env"
|
||
check "staging-box: demands autostart (BOX_AUTOSTART=1)" 0 "" \
|
||
grep -qx 'BOX_AUTOSTART="1"' "$ROOT/templates/staging-box/box.env"
|
||
check "staging-box: the tenant role is 'staging-box'" 0 "ROLE=staging-box" tpl "$ROOT" staging-box
|
||
check "staging-box: the seed user is rig's default for the role ('ops')" 0 "USER=ops" tpl "$ROOT" staging-box
|
||
# The agent tenants. Two names, not one: the TEMPLATE is named for the rig role
|
||
# it converges — suffix and all, since rig's roles carry a family suffix
|
||
# ('-box' for box tenants, '-server' for fleet machines, rig#76) — while the
|
||
# seed USER stays the bare agent name, because that is the user rig's role
|
||
# converges and the one 'box shell' lands in. The pairing is the whole point of
|
||
# pinning it here: a rename that moves one and forgets the other mints a box
|
||
# whose role dies looking for a user that was never created.
|
||
for u in claude codex grok; do
|
||
check "$u-box: role is '$u-box', seed user is '$u' (rig's tenant mapping)" \
|
||
0 "USER=$u REQUIRE_VM= AUTOSTART= ROLE=$u-box" tpl "$ROOT" "$u-box"
|
||
done
|
||
# blank stays a box with NOBODY home: no rig, no role — same isolation, no
|
||
# tooling, and nothing auto-runs in it.
|
||
check "blank: names no bootstrap role" 1 "" \
|
||
grep -q '^BOX_BOOTSTRAP_ROLE=' "$ROOT/templates/blank/box.env"
|
||
check "blank: does not preinstall rig" 1 "" grep -q 'install.sh' "$ROOT/templates/blank/user-data.yaml"
|
||
|
||
rm -f "$TPLFN"
|
||
|
||
# The keys' cmd_new half, grepped the way the expose guard is (line order —
|
||
# a daemon-free run cannot mint). The REQUIRE_VM refusal must read the
|
||
# EFFECTIVE mode, i.e. come after pick_mode: refusing on the template key
|
||
# alone would refuse valid VM mints, and a guard deleted in a refactor must
|
||
# not ship green.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the REQUIRE_VM refusal orders after pick_mode" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
pick="$(printf "%s\n" "$fn" | grep -n "pick_mode" | head -1 | cut -d: -f1)"
|
||
guard="$(printf "%s\n" "$fn" | grep -n "T_REQUIRE_VM" | head -1 | cut -d: -f1)"
|
||
[ -n "$pick" ] && [ -n "$guard" ] && [ "$pick" -lt "$guard" ]'
|
||
# Order is necessary, not sufficient: a regression to the RAW flag
|
||
# ([ "$mode" != vm ]) would still sit after pick_mode — and would refuse every
|
||
# auto mint on a valid VM host. Pin the guard to the EFFECTIVE operand: the
|
||
# T_REQUIRE_VM line itself must compare $m, the pick_mode result.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the REQUIRE_VM guard compares the effective mode (\$m)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep "T_REQUIRE_VM" | grep -qF "\"\$m\" != vm"'
|
||
check "new: boot.autostart is stamped under the T_AUTOSTART guard" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"'
|
||
|
||
# The auto-run half of #81, grepped the same way (a daemon-free run cannot
|
||
# mint). The seed reaches Incus through render_userdata — the pin point — not
|
||
# through a raw cat; and the tenant convergence must order AFTER the
|
||
# cloud-init wait (rig is installed by the seed's runcmd, so exec'ing the
|
||
# role before cloud-init settles would race its own installer) and sit under
|
||
# the T_BOOTSTRAP_ROLE guard (blank must never auto-run anything).
|
||
check "new: cloud-init user-data goes through render_userdata (the rig pin)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -F "cloud-init.user-data" | grep -q "render_userdata"'
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the tenant auto-run orders after the cloud-init wait" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
wait="$(printf "%s\n" "$fn" | grep -n "cloud-init status --wait" | head -1 | cut -d: -f1)"
|
||
run="$(printf "%s\n" "$fn" | grep -n "rig bootstrap" | head -1 | cut -d: -f1)"
|
||
[ -n "$wait" ] && [ -n "$run" ] && [ "$wait" -lt "$run" ]'
|
||
check "new: the auto-run sits under the T_BOOTSTRAP_ROLE guard" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -B2 "incus exec .* rig bootstrap" | grep -q "T_BOOTSTRAP_ROLE"'
|
||
check "new: a failed tenant role names the re-run (the role converges)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -q "sudo rig bootstrap"'
|
||
|
||
# The launch phase, narrated and time-boxed (#93) — grepped the way the other
|
||
# mint-path guards are (a daemon-free run cannot mint). Twice in the
|
||
# 2026-07-19 release drill the child 'incus launch' wedged silently before
|
||
# the create was even accepted, once for 56 minutes. The narration must order
|
||
# BEFORE the launch call (a wedge after the line is visible at a glance; a
|
||
# wedge before it is the old silent hang), the call itself must sit under
|
||
# 'timeout -k' with the BOX_LAUNCH_TIMEOUT override and pinned stdin (RUNS.md
|
||
# trap 13: bare 'timeout N' cannot kill an incus call that owns a TTY), and
|
||
# the budget's failure must be LOUD — no server-side operation, the measured
|
||
# retry-succeeds hint, and the doctor as the next move.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the launch narration orders before incus launch (#93)" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
say="$(printf "%s\n" "$fn" | grep -n "launching instance" | head -1 | cut -d: -f1)"
|
||
run="$(printf "%s\n" "$fn" | grep -n "timeout -k.*incus launch" | head -1 | cut -d: -f1)"
|
||
[ -n "$say" ] && [ -n "$run" ] && [ "$say" -lt "$run" ]'
|
||
check "new: incus launch is time-boxed (timeout -k on the budget)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep "timeout -k" | grep "budget" | grep -q "incus launch"'
|
||
check "new: the budget is BOX_LAUNCH_TIMEOUT, default 600s (the BOX_CPU knob shape)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep "budget=" | grep -q "BOX_LAUNCH_TIMEOUT:-600"'
|
||
check "new: the launch pins stdin (RUNS.md trap 13)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -F "extra[@]" | grep -qF "</dev/null"'
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the wedge failure is loud — retry hint, the doctor, and #93" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
printf "%s\n" "$fn" | grep -A6 "WEDGED" | grep -q "observed to succeed" &&
|
||
printf "%s\n" "$fn" | grep -q "box doctor" &&
|
||
printf "%s\n" "$fn" | grep "did not finish inside" | grep -q "#93"'
|
||
# timeout proves only that the CLIENT overran the budget: launch is
|
||
# create-then-start, so a slow launch may have REGISTERED the instance and a
|
||
# blind "never created, retry" would send the operator into 'Instance already
|
||
# exists' (#94 round-1, all three reviewers). The timeout path must probe the
|
||
# instance, tell the two stories apart, and best-effort delete either way so
|
||
# the retry advice is safe in both worlds.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the timeout path probes before claiming never-created (#94 r1)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep "incus info" | grep -q "\$instance"'
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the timeout path best-effort deletes, so retry is always clean" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep "incus delete --force" | grep -q "|| true"'
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: the overran-but-registered branch says so (not the wedge story)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep "OVERRAN" | grep -q "budget"'
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "new: BOX_LAUNCH_TIMEOUT is documented in box help new" 0 "" bash -c '
|
||
"'"$ROOT"'/bin/box" help new | grep "BOX_LAUNCH_TIMEOUT" | grep -q 600'
|
||
# staging-box's creds-holding join stays OPERATOR-run: cmd_new may print it as
|
||
# a next step, but no template and no code path auto-runs "rig bootstrap
|
||
# workload-server" — the one absence that keeps box creds-free end to end.
|
||
check "new: the workload join is printed, never exec'd" 1 "" bash -c '
|
||
grep "rig bootstrap workload-server" "'"$ROOT"'/bin/box" | grep -q "incus exec"'
|
||
check "templates: no template names a creds-holding role" 1 "" bash -c '
|
||
grep -h "^BOX_BOOTSTRAP_ROLE=" "'"$ROOT"'"/templates/*/box.env | grep -qE "workload|host|custom"'
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The 'pristine' mark (#104, child of heavy-duty/rig#62). The whole feature is
|
||
# a MOMENT: the guest after cloud-init and before rig converges anything. Get
|
||
# the position wrong by one step and the mark is a lie — a 'pristine' taken
|
||
# after 'rig bootstrap' is a converged box wearing the wrong label, and
|
||
# nothing at runtime would ever say so. So the position is pinned by line
|
||
# order, the way the other mint-path guards are (a daemon-free run cannot
|
||
# mint), and the policy half is DRIVEN against a stubbed incus.
|
||
# ---------------------------------------------------------------------------
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "pristine: the mark is taken in the fresh-mint branch" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "snapshot_pristine \"\$instance\""'
|
||
# AFTER cloud-init: before it, the guest is mid-install and the mark is not
|
||
# pristine Debian, it is a half-provisioned one.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "pristine: the mark orders AFTER the cloud-init wait" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
wait="$(printf "%s\n" "$fn" | grep -n "cloud-init status --wait" | head -1 | cut -d: -f1)"
|
||
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_pristine " | head -1 | cut -d: -f1)"
|
||
[ -n "$wait" ] && [ -n "$snap" ] && [ "$wait" -lt "$snap" ]'
|
||
# BEFORE the rig hook: this is the assertion the whole issue rests on.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "pristine: the mark orders BEFORE the rig bootstrap hook (the moment)" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_pristine " | head -1 | cut -d: -f1)"
|
||
run="$(printf "%s\n" "$fn" | grep -n "rig bootstrap \$T_BOOTSTRAP_ROLE" | head -1 | cut -d: -f1)"
|
||
[ -n "$snap" ] && [ -n "$run" ] && [ "$snap" -lt "$run" ]'
|
||
# NOT under the T_BOOTSTRAP_ROLE guard: a blank box has no rig hook but has
|
||
# the same pristine moment, and "box restore <box> pristine" must mean one
|
||
# thing on every box box mints.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "pristine: the mark is unconditional, not gated on a tenant role" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_pristine " | head -1 | cut -d: -f1)"
|
||
guard="$(printf "%s\n" "$fn" | grep -n "if \[ -n \"\$T_BOOTSTRAP_ROLE\" \]" | head -1 | cut -d: -f1)"
|
||
[ -n "$snap" ] && [ -n "$guard" ] && [ "$snap" -lt "$guard" ]'
|
||
|
||
# THE CLONE TRAP. --from skips cloud-init and the rig hook entirely, so the
|
||
# pristine moment never happens on that path. A mark taken there would be
|
||
# "whatever the source was" — converged, worked-in — wearing a label that
|
||
# promises pristine Debian, which is strictly worse than no mark. Pin the
|
||
# ABSENCE: extract the clone branch alone (up to its 'else') and assert
|
||
# nothing in it takes the mark.
|
||
CLONEBR="$(mktemp)"
|
||
awk '/if \[ -n "\$from" \]; then/,/^ else$/' "$ROOT/bin/box" > "$CLONEBR"
|
||
check "pristine: the clone branch extracted from bin/box (guards the awk)" 0 "incus copy" cat "$CLONEBR"
|
||
check "pristine: a --from clone takes NO mark of its own (the correctness trap)" 1 "" \
|
||
grep -q "snapshot_pristine" "$CLONEBR"
|
||
check "pristine: nothing on the clone path creates a snapshot at all" 1 "" \
|
||
grep -q "incus snapshot create" "$CLONEBR"
|
||
# Inheritance is the other half of the decision, and it must be SAID: a clone
|
||
# of a box carries the source's snapshots (a real pristine among them), a
|
||
# clone of a snapshot carries none. Silence there sends the operator to
|
||
# 'box info' to find out which world they are in.
|
||
check "pristine: the clone narrates whether a pristine rode along" 0 "" \
|
||
grep -q "no 'pristine' mark here" "$CLONEBR"
|
||
# ...and it reads the snapshot list CAPTURE-FIRST (#124's class). Piping a
|
||
# multi-line incus writer into an early-exit reader lets the reader close the
|
||
# pipe, SIGPIPE incus, and hand pipefail a 141 — which on THIS line reads as
|
||
# "no pristine" and narrates the wrong inheritance shape on a clone that has
|
||
# one. Pin the shape, not the instance spelling: no 'incus snapshot list'
|
||
# feeding grep/head/sed/awk/read directly.
|
||
check "pristine: the clone's inheritance read is capture-first, not a piped early-exit reader" 1 "" \
|
||
grep -Eq 'incus snapshot list[^|]*\| *(grep|head|sed|awk|read)' "$CLONEBR"
|
||
rm -f "$CLONEBR"
|
||
|
||
# The policy half, DRIVEN not grepped: extract storage_driver +
|
||
# snapshot_pristine and run them against a stubbed incus, so every branch is
|
||
# actually executed on a host with no daemon.
|
||
PRISFN="$(mktemp)"
|
||
awk '/^storage_driver\(\) \{/,/^\}/;/^snapshot_mark\(\) \{/,/^\}/;/^snapshot_pristine\(\) \{/,/^\}/' "$ROOT/bin/box" > "$PRISFN"
|
||
check "pristine: the functions extracted from bin/box (guards the awk)" 0 "BOX_SNAPSHOT_PRISTINE" cat "$PRISFN"
|
||
check "pristine: the extracted functions are valid bash" 0 "" bash -n "$PRISFN"
|
||
|
||
# storage_driver's probes must survive a REFUSAL, and not by accident. Today
|
||
# command substitution strips errexit, so a failing probe falls through to the
|
||
# fallback; add 'shopt -s inherit_errexit' to bin/box — the robustness tweak
|
||
# #107 describes sailing through review — and under pipefail that same refusal
|
||
# becomes a fatal abort mid-mint, inside the function whose contract is NEVER
|
||
# fatal. Drive it with inherit_errexit ON and a tier that refuses both probes:
|
||
# the function must return empty (the unreadable-pool case) and the caller
|
||
# must still be alive afterwards.
|
||
driver_under_inherit_errexit() {
|
||
# shellcheck disable=SC2016 # the body is the stub's source, expanded by the
|
||
# inner bash, never by this shell.
|
||
env PRISFN="$PRISFN" bash -c '
|
||
set -euo pipefail
|
||
shopt -s inherit_errexit
|
||
incus() {
|
||
case "$*" in
|
||
"profile device get box-net root pool") printf "boxpool\n" ;;
|
||
*) printf "incus: not authorized\n" >&2; return 1 ;;
|
||
esac
|
||
}
|
||
. "$PRISFN"
|
||
d="$(storage_driver)"
|
||
printf "SURVIVED driver=[%s]\n" "$d"
|
||
' 2>&1
|
||
}
|
||
check "pristine: a refused storage probe is an answer, not a fatal (survives inherit_errexit)" 0 "SURVIVED driver=[]" \
|
||
driver_under_inherit_errexit
|
||
|
||
# pris <driver> [env...] — drive snapshot_pristine against a fake pool of
|
||
# <driver>. 'none' makes both probes answer nothing (the unreadable-pool
|
||
# case). Every incus call the function can make is stubbed and echoed, so the
|
||
# assertions read the real control flow, not a mock's opinion of it.
|
||
pris() { # pris <driver> <instance> [VAR=VAL...]
|
||
local driver="$1" instance="$2"; shift 2
|
||
# shellcheck disable=SC2016 # the body is the stub's source, expanded by the
|
||
# inner bash from the environment 'env' sets up — never by this shell.
|
||
env "$@" DRIVER="$driver" INSTANCE="$instance" PRISFN="$PRISFN" bash -c '
|
||
incus() {
|
||
case "$*" in
|
||
"profile device get box-net root pool") printf "boxpool\n" ;;
|
||
"storage show boxpool")
|
||
[ "$DRIVER" = none ] && return 1
|
||
printf "name: boxpool\ndriver: %s\n" "$DRIVER" ;;
|
||
"storage list --format csv")
|
||
[ "$DRIVER" = none ] && return 1
|
||
printf "boxpool,%s,,0,CREATED\n" "$DRIVER" ;;
|
||
"snapshot create inst-x pristine") printf "STUB: snapshot created\n" ;;
|
||
"snapshot create fail-x pristine") printf "STUB: incus refused\n" >&2; return 1 ;;
|
||
*) printf "STUB: unexpected incus call: %s\n" "$*" >&2; return 1 ;;
|
||
esac
|
||
}
|
||
. "$PRISFN"
|
||
snapshot_pristine "$INSTANCE" boxname
|
||
' 2>&1
|
||
}
|
||
# The absence assertions need a command 'check' can run, not a pipeline.
|
||
pris_took_mark() { pris "$@" | grep -q "STUB: snapshot created"; }
|
||
check "pristine: btrfs (the designed backend) takes the mark" 0 "STUB: snapshot created" \
|
||
pris btrfs inst-x
|
||
check "pristine: btrfs names the restore command for the operator" 0 "box restore boxname pristine" \
|
||
pris btrfs inst-x
|
||
# The 'dir' fallback (host/setup-host.sh:294) has no CoW: the mark would be a
|
||
# full multi-GB copy of the root on EVERY mint. Skip — and LOUDLY, naming the
|
||
# by-hand command, because a silent skip teaches an operator to expect a mark
|
||
# that is not there.
|
||
check "pristine: a 'dir' pool SKIPS the mark (no CoW — it would be a full copy)" 0 "NOT taking" \
|
||
pris dir inst-x
|
||
check "pristine: the dir skip is loud and names the by-hand command" 0 "box snapshot boxname pristine" \
|
||
pris dir inst-x
|
||
check "pristine: the dir skip never reaches incus snapshot create" 1 "" \
|
||
pris_took_mark dir inst-x
|
||
# Neither probe answers (an unusual host, or a tier that cannot read the
|
||
# pool). The two mistakes are not symmetric — a mark taken on 'dir' wastes
|
||
# disk the operator can see and delete, a mark NOT taken is the moment gone
|
||
# for good. So proceed, and say what was assumed.
|
||
check "pristine: an unreadable pool takes the mark anyway (the asymmetry)" 0 "STUB: snapshot created" \
|
||
pris none inst-x
|
||
check "pristine: ...and says what it assumed rather than pretending it knew" 0 "could not read the storage driver" \
|
||
pris none inst-x
|
||
# The escape hatch is an environment knob (the BOX_LAUNCH_TIMEOUT shape), not
|
||
# another flag on 'new'.
|
||
check "pristine: BOX_SNAPSHOT_PRISTINE=0 skips it anywhere" 0 "BOX_SNAPSHOT_PRISTINE=0" \
|
||
pris btrfs inst-x BOX_SNAPSHOT_PRISTINE=0
|
||
check "pristine: the opt-out never reaches incus snapshot create" 1 "" \
|
||
pris_took_mark btrfs inst-x BOX_SNAPSHOT_PRISTINE=0
|
||
# A failed snapshot must NOT fail the mint. The mark is an undo, not the
|
||
# mint's product: a mint that worked must not be failed by a checkpoint that
|
||
# didn't.
|
||
check "pristine: a failed snapshot warns and returns 0 (never fails a good mint)" 0 "WARNING" \
|
||
pris btrfs fail-x
|
||
rm -f "$PRISFN"
|
||
|
||
# The durability caveat, pinned in the help text: a snapshot dies with its
|
||
# box, so nothing box says may let anyone read 'pristine' as a backup (#104's
|
||
# closing note; 'box export' is the durable path).
|
||
check "pristine: 'box help snapshot' refuses to sell snapshots as backups" 0 "not a backup" \
|
||
bash -c '"'"$ROOT"'/bin/box" help snapshot'
|
||
check "pristine: 'box help restore' documents the mark and its off-box blind spot" 0 "off-box" \
|
||
bash -c '"'"$ROOT"'/bin/box" help restore'
|
||
check "pristine: 'box help new' documents the mark and the opt-out" 0 "BOX_SNAPSHOT_PRISTINE" \
|
||
bash -c '"'"$ROOT"'/bin/box" help new'
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The 'bootstrapped' mark (#130, the deferred half of #104). Where 'pristine'
|
||
# marks a MOMENT every fresh mint has, this marks an EVENT: a rig hook box ran
|
||
# and WATCHED SUCCEED. So the two load-bearing facts are opposite in shape —
|
||
# 'pristine' is pinned as unconditional, this one is pinned as gated, and the
|
||
# gate is what keeps the label from asserting a convergence that never
|
||
# happened. Position and gating are pinned by line order (a daemon-free run
|
||
# cannot mint); the policy half is DRIVEN against a stubbed incus, exactly as
|
||
# the pristine block above drives it.
|
||
# ---------------------------------------------------------------------------
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "bootstrapped: the mark is taken in the fresh-mint branch" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "snapshot_bootstrapped \"\$instance\""'
|
||
# AFTER the hook: a mark taken before it would be 'pristine' under a name that
|
||
# claims convergence — the same lie #104 refused on the clone path.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "bootstrapped: the mark orders AFTER the rig bootstrap hook" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
run="$(printf "%s\n" "$fn" | grep -Fn "rig bootstrap \"\$T_BOOTSTRAP_ROLE\" </dev/null" | head -1 | cut -d: -f1)"
|
||
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_bootstrapped " | head -1 | cut -d: -f1)"
|
||
[ -n "$run" ] && [ -n "$snap" ] && [ "$run" -lt "$snap" ]'
|
||
# GATED on T_BOOTSTRAP_ROLE — the blank-template asymmetry, chosen. A hookless
|
||
# box has no convergence to mark; marking one anyway would either duplicate
|
||
# 'pristine' byte for byte at twice the disk cost or assert an event that did
|
||
# not happen. This is the exact inverse of the pristine guard above, and both
|
||
# must hold at once.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "bootstrapped: the mark IS gated on a tenant role (the blank asymmetry)" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
guard="$(printf "%s\n" "$fn" | grep -n "if \[ -n \"\$T_BOOTSTRAP_ROLE\" \]" | head -1 | cut -d: -f1)"
|
||
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_bootstrapped " | head -1 | cut -d: -f1)"
|
||
[ -n "$guard" ] && [ -n "$snap" ] && [ "$guard" -lt "$snap" ]'
|
||
# NOT after a FAILED hook. The failure branch ends in a 'die', so the mark is
|
||
# unreachable from it — pinned by asserting the mark sits after that die, i.e.
|
||
# on the far side of a branch that never returns.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "bootstrapped: a FAILED hook dies before ever reaching the mark" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
d="$(printf "%s\n" "$fn" | grep -n "die \"the tenant role did not converge" | head -1 | cut -d: -f1)"
|
||
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_bootstrapped " | head -1 | cut -d: -f1)"
|
||
[ -n "$d" ] && [ -n "$snap" ] && [ "$d" -lt "$snap" ]'
|
||
# ...and the operator is HANDED the command at the one moment they are
|
||
# looking. This is the answer to the sharpest objection in #130: the mark is
|
||
# absent exactly on the boxes whose convergence needed intervention, so the
|
||
# failure path must name the by-hand command rather than leave a silent hole.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "bootstrapped: the hook-failure message hands over the by-hand command" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -q "box snapshot \$name bootstrapped"'
|
||
# The clone branch takes neither mark. Reuses #104's extraction shape.
|
||
CLONEBR2="$(mktemp)"
|
||
awk '/if \[ -n "\$from" \]; then/,/^ else$/' "$ROOT/bin/box" > "$CLONEBR2"
|
||
check "bootstrapped: the clone branch extracted from bin/box (guards the awk)" 0 "incus copy" cat "$CLONEBR2"
|
||
check "bootstrapped: a --from clone takes no 'bootstrapped' either" 1 "" \
|
||
grep -q "snapshot_bootstrapped" "$CLONEBR2"
|
||
rm -f "$CLONEBR2"
|
||
|
||
# The never-fatal contract lives ONCE. Two marks share one policy function on
|
||
# purpose: the failure handling is the part that must not be got subtly
|
||
# different in two places. If a future change copy-pastes the policy instead of
|
||
# calling it, this bites. (The other create is cmd_snapshot's, the by-hand
|
||
# verb, which is deliberately fatal — an explicit 'box snapshot' that fails
|
||
# must fail.)
|
||
check "bootstrapped: exactly one auto-mark policy — 'incus snapshot create' twice in bin/box" 0 "2" \
|
||
bash -c 'grep -c "incus snapshot create" "'"$ROOT"'/bin/box"'
|
||
|
||
# The policy half, DRIVEN. Same stub shape as pris() above, one label over.
|
||
BOOTFN="$(mktemp)"
|
||
awk '/^storage_driver\(\) \{/,/^\}/;/^snapshot_mark\(\) \{/,/^\}/;/^mark_taken\(\)/;/^snapshot_bootstrapped\(\) \{/,/^\}/' \
|
||
"$ROOT/bin/box" > "$BOOTFN"
|
||
check "bootstrapped: the functions extracted from bin/box (guards the awk)" 0 "snapshot_bootstrapped" cat "$BOOTFN"
|
||
check "bootstrapped: the extracted functions are valid bash" 0 "" bash -n "$BOOTFN"
|
||
|
||
boot() { # boot <driver> <instance> [VAR=VAL...]
|
||
local driver="$1" instance="$2"; shift 2
|
||
# shellcheck disable=SC2016 # the body is the stub's source, expanded by the
|
||
# inner bash from the environment 'env' sets up — never by this shell.
|
||
env "$@" DRIVER="$driver" INSTANCE="$instance" BOOTFN="$BOOTFN" bash -c '
|
||
incus() {
|
||
case "$*" in
|
||
"profile device get box-net root pool") printf "boxpool\n" ;;
|
||
"storage show boxpool")
|
||
[ "$DRIVER" = none ] && return 1
|
||
printf "name: boxpool\ndriver: %s\n" "$DRIVER" ;;
|
||
"storage list --format csv")
|
||
[ "$DRIVER" = none ] && return 1
|
||
printf "boxpool,%s,,0,CREATED\n" "$DRIVER" ;;
|
||
"snapshot create inst-x bootstrapped") printf "STUB: snapshot created\n" ;;
|
||
"snapshot create fail-x bootstrapped") printf "STUB: incus refused\n" >&2; return 1 ;;
|
||
*) printf "STUB: unexpected incus call: %s\n" "$*" >&2; return 1 ;;
|
||
esac
|
||
}
|
||
. "$BOOTFN"
|
||
snapshot_bootstrapped "$INSTANCE" boxname
|
||
' 2>&1
|
||
}
|
||
boot_took_mark() { boot "$@" | grep -q "STUB: snapshot created"; }
|
||
check "bootstrapped: btrfs (the designed backend) takes the mark" 0 "STUB: snapshot created" \
|
||
boot btrfs inst-x
|
||
check "bootstrapped: btrfs names the restore command for the operator" 0 "box restore boxname bootstrapped" \
|
||
boot btrfs inst-x
|
||
# The 'dir' skip, and with two marks the disk objection is twice the size: a
|
||
# CoW-less host must not be asked to pay for one full root copy per mint, let
|
||
# alone two. Same refusal, same loudness, same by-hand command.
|
||
check "bootstrapped: a 'dir' pool SKIPS the mark (no CoW — it would be a full copy)" 0 "NOT taking" \
|
||
boot dir inst-x
|
||
check "bootstrapped: the dir skip is loud and names the by-hand command" 0 "box snapshot boxname bootstrapped" \
|
||
boot dir inst-x
|
||
check "bootstrapped: the dir skip never reaches incus snapshot create" 1 "" \
|
||
boot_took_mark dir inst-x
|
||
check "bootstrapped: an unreadable pool takes the mark anyway (the asymmetry)" 0 "STUB: snapshot created" \
|
||
boot none inst-x
|
||
check "bootstrapped: ...and says what it assumed rather than pretending it knew" 0 "could not read the storage driver" \
|
||
boot none inst-x
|
||
check "bootstrapped: BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere" 0 "BOX_SNAPSHOT_BOOTSTRAPPED=0" \
|
||
boot btrfs inst-x BOX_SNAPSHOT_BOOTSTRAPPED=0
|
||
check "bootstrapped: the opt-out never reaches incus snapshot create" 1 "" \
|
||
boot_took_mark btrfs inst-x BOX_SNAPSHOT_BOOTSTRAPPED=0
|
||
# The opt-out knob name is DERIVED from the label, so the message can never
|
||
# drift from the variable an operator actually has to set — and one label's
|
||
# knob must not silently disable the other's.
|
||
check "bootstrapped: the opt-out is a per-label knob — PRISTINE=0 does not silence it" 0 "" \
|
||
boot_took_mark btrfs inst-x BOX_SNAPSHOT_PRISTINE=0
|
||
check "bootstrapped: a failed snapshot warns and returns 0 (never fails a good mint)" 0 "WARNING" \
|
||
boot btrfs fail-x
|
||
|
||
# --- only offer a rollback that EXISTS -------------------------------------
|
||
# The never-fatal contract means snapshot_mark returns 0 whether it took the
|
||
# mark, skipped it, or was refused — so the exit status cannot answer "is
|
||
# there something to restore?" and any message offering one must ask 'marks'.
|
||
# Driven per path rather than asserted once: the three no-mark paths fail
|
||
# differently and a single case would let the other two regress silently.
|
||
took() { # took <driver> <label> [VAR=VAL...] — did THIS run create the mark?
|
||
local driver="$1" label="$2"; shift 2
|
||
# shellcheck disable=SC2016 # the body is the stub's source, expanded by the
|
||
# inner bash from the environment 'env' sets up — never by this shell.
|
||
env "$@" DRIVER="$driver" LABEL="$label" BOOTFN="$BOOTFN" bash -c '
|
||
incus() {
|
||
case "$*" in
|
||
"profile device get box-net root pool") printf "boxpool\n" ;;
|
||
"storage show boxpool")
|
||
[ "$DRIVER" = none ] && return 1
|
||
printf "name: boxpool\ndriver: %s\n" "$DRIVER" ;;
|
||
"storage list --format csv")
|
||
[ "$DRIVER" = none ] && return 1
|
||
printf "boxpool,%s,,0,CREATED\n" "$DRIVER" ;;
|
||
"snapshot create fail-x "*) return 1 ;;
|
||
"snapshot create "*) printf "STUB: snapshot created\n" ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
marks=""
|
||
. "$BOOTFN"
|
||
snapshot_mark "$INSTANCE_X" boxname "$LABEL" "${ENABLED:-1}" "some state" >/dev/null 2>&1
|
||
mark_taken "$LABEL" && echo TAKEN || echo ABSENT
|
||
' 2>&1
|
||
}
|
||
check "rollback: a mark that WAS created is remembered" 0 "TAKEN" \
|
||
took btrfs pristine INSTANCE_X=inst-x
|
||
check "rollback: a 'dir' skip is NOT remembered (no CoW, no mark)" 0 "ABSENT" \
|
||
took dir pristine INSTANCE_X=inst-x
|
||
check "rollback: the opt-out knob is NOT remembered" 0 "ABSENT" \
|
||
took btrfs pristine INSTANCE_X=inst-x ENABLED=0
|
||
check "rollback: a REFUSED create is not remembered (incus said no)" 0 "ABSENT" \
|
||
took btrfs pristine INSTANCE_X=fail-x
|
||
# One label's mark must not answer for another's.
|
||
check "rollback: marks do not bleed between labels" 0 "ABSENT" \
|
||
bash -c 'marks=" bootstrapped "; . "'"$BOOTFN"'"; mark_taken pristine && echo TAKEN || echo ABSENT'
|
||
# The call site itself, pinned statically: the hook-failure message offers the
|
||
# restore only under the guard. On a 'dir' host EVERY hook failure reaches this
|
||
# line with no pristine mark, so an unconditional offer is a copy-pasteable
|
||
# command that errors at the one moment the operator is standing there.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "rollback: the hook-failure restore offer is GATED on the mark existing" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||
| grep -B12 "box restore \$name pristine. is still there" \
|
||
| grep -q "if mark_taken pristine; then"'
|
||
rm -f "$BOOTFN"
|
||
|
||
# The label is one-directional and the docs must say so: its PRESENCE means the
|
||
# hook converged untouched, its ABSENCE means nothing at all. Without that
|
||
# sentence a missing mark reads as "unconverged", which is exactly the false
|
||
# coverage #130 warns about.
|
||
check "bootstrapped: 'box help restore' refuses to let absence imply anything" 0 "absence proves NOTHING" \
|
||
bash -c '"'"$ROOT"'/bin/box" help restore'
|
||
check "bootstrapped: 'box help restore' repeats the off-box blind spot for it too" 0 "cannot undo anything" \
|
||
bash -c '"'"$ROOT"'/bin/box" help restore'
|
||
check "bootstrapped: 'box help new' documents the mark and the opt-out" 0 "BOX_SNAPSHOT_BOOTSTRAPPED" \
|
||
bash -c '"'"$ROOT"'/bin/box" help new'
|
||
check "bootstrapped: 'box help snapshot' names the by-hand fallback" 0 "box snapshot work bootstrapped" \
|
||
bash -c '"'"$ROOT"'/bin/box" help snapshot'
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
|
||
# The help is the PRE-RUN CONTRACT: an operator reads it to decide whether to
|
||
# run the command at all, so it must not promise a mutation that will not
|
||
# happen (or deny one that will). Round 1 of #101 changed what grant/revoke
|
||
# mutate for an incus-admin member and left this prose describing the
|
||
# superseded design — these pins are why that cannot happen silently again.
|
||
# Both directions: the current sentence must be present, and the superseded
|
||
# one must be gone.
|
||
check "help grant: the admin member's group step is a real add, not a no-op" \
|
||
0 "like anyone else" "$BOX" help grant
|
||
check "help revoke: a bare revoke of a granted admin member is 'partial:'" \
|
||
0 "partial:" "$BOX" help revoke
|
||
check "help grant no longer calls the admin group step a no-op" 0 "" \
|
||
bash -c '! "'"$BOX"'" help grant | grep -q "reported no-op"'
|
||
check "help revoke no longer claims there is no membership to drop" 0 "" \
|
||
bash -c '! "'"$BOX"'" help revoke | grep -q "no membership to drop"'
|
||
|
||
# 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). The pre-flight lives in require_stack()
|
||
# since #70 gave it a second caller (import lands on the same contract), so
|
||
# assert both halves: the helper holds the probe, and cmd_new calls it.
|
||
check "require_stack: probes the box-net profile" 0 "" bash -c '
|
||
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
|
||
check "require_stack: the restricted fix names box grant" 0 "" bash -c '
|
||
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
|
||
check "new: pre-flights the stack (require_stack)" 0 "" bash -c '
|
||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
|
||
# 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: an incus-admin member is provisioned, not refused (#99)" 1 "" \
|
||
grep -qF 'there is nothing tighter to grant' "$ROOT/host/grant-user.sh"
|
||
check "revoke: group removal is the lockout" 0 "" \
|
||
grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh"
|
||
# 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 "" \
|
||
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
|
||
# 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"
|
||
# 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"
|
||
# Criterion o is the real-Incus half of #101: the shim cannot model an EACCES
|
||
# on the user socket, so the admin-only grant is measured where the socket has
|
||
# a real owning group. Pinned so it cannot quietly leave the rehearsal.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "rehearsal: grants an incus-admin-ONLY member on real Incus (criterion o)" 0 "" \
|
||
grep -qF 'usermod -aG incus-admin "$U5"' "$ROOT/drill/multiuser.sh"
|
||
# shellcheck disable=SC2016 # ditto
|
||
check "rehearsal: ...and opens the user socket as them, not just the daemon" 0 "" \
|
||
grep -qF 'INCUS_SOCKET="$sockdir/unix.socket.user"' "$ROOT/drill/multiuser.sh"
|
||
# 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"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #99: an incus-admin member is PROVISIONED, not refused. The distinction the
|
||
# old refusal missed is permission (the 'incus' group — theirs already, and
|
||
# stronger) versus provisioning (the user-<uid> project, the boxnet narrowing,
|
||
# snapshots, backups, the box-net profile — theirs not at all). Grepping the
|
||
# new prose would prove only that the prose exists, so both tier scripts are
|
||
# DRIVEN end to end under shims, the same seam setup-host is driven through:
|
||
# every incus and sudo call is logged, and the assertions are made against
|
||
# those logs — what the run did, not what the source says it would do.
|
||
# ---------------------------------------------------------------------------
|
||
GSHIM="$(mktemp -d)"; W99="$(mktemp -d)"
|
||
cat > "$GSHIM/incus" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake incus for the driven grant/revoke: logs every call, answers the
|
||
# existence probes from FAKE_*, and models the two state changes the scripts
|
||
# depend on — the project appearing after the incus-user touch, and
|
||
# disappearing after a purge deletes it.
|
||
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
||
case "$*" in *"profile edit"*) cat >/dev/null ;; esac
|
||
case "$*" in
|
||
"network show boxnet") [ -n "${FAKE_HAVE_BOXNET:-}" ] || exit 1 ;;
|
||
"project show "*)
|
||
[ -e "$FAKE_STATE/deleted" ] && exit 1
|
||
if [ -n "${FAKE_PROJECT_LAZY:-}" ]; then
|
||
# Lazy creation: absent on the first look, present afterwards — i.e.
|
||
# the touch worked. n counts the looks this run has taken.
|
||
n=0; [ -e "$FAKE_STATE/looks" ] && n="$(cat "$FAKE_STATE/looks")"
|
||
printf '%s\n' "$((n + 1))" > "$FAKE_STATE/looks"
|
||
[ "$n" -ge 1 ] || exit 1
|
||
else
|
||
[ -n "${FAKE_HAVE_PROJECT:-}" ] || exit 1
|
||
fi ;;
|
||
"project delete "*) : > "$FAKE_STATE/deleted" ;;
|
||
*"restricted.networks.access"*)
|
||
[ -z "${FAKE_FAIL_NARROW:-}" ] || { echo 'Instance "old" is on incusbr-1000' >&2; exit 1; } ;;
|
||
*"network show "*) exit 1 ;; # the private bridge: never there in these runs
|
||
esac
|
||
exit 0
|
||
SHIM
|
||
cat > "$GSHIM/sudo" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake sudo: logs and swallows — EXCEPT 'sudo test', which is run for real.
|
||
# Both scripts route filesystem probes through it on purpose (/var/lib/incus
|
||
# is not traversable by a non-root admin, so an unprivileged stat lies), and
|
||
# both directions matter here: revoke's absence assert must see incus-user's
|
||
# state directory as genuinely absent on a clean machine, and grant's socket
|
||
# check must see the shimmed unix.socket.user as genuinely present.
|
||
[ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG"
|
||
case "${1:-}" in test) shift; test "$@"; exit $? ;; esac
|
||
exit 0
|
||
SHIM
|
||
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/getent"
|
||
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/systemctl"
|
||
printf '#!/usr/bin/env bash\nexit 1\n' > "$GSHIM/pgrep"
|
||
chmod +x "$GSHIM/incus" "$GSHIM/sudo" "$GSHIM/getent" "$GSHIM/systemctl" "$GSHIM/pgrep"
|
||
|
||
# The pinned incus-user socket. box grant resolves it through INCUS_DIR (the
|
||
# client's own first choice), so a directory here is the whole seam.
|
||
mkdir -p "$W99/incusdir"; : > "$W99/incusdir/unix.socket.user"
|
||
|
||
rungrant() { # rungrant <groups> <state-dir> [VAR=val ...] — the real grant, shimmed
|
||
local groups="$1" state="$2"; shift 2
|
||
mkdir -p "$state"
|
||
env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" \
|
||
FAKE_HAVE_BOXNET=1 FAKE_PROJECT_LAZY=1 INCUS_DIR="$W99/incusdir" \
|
||
FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \
|
||
PATH="$GSHIM:$SHIMDIR:$PATH" "$@" bash "$ROOT/host/grant-user.sh" dev1
|
||
}
|
||
|
||
# --- the admin member: full convergence, no group change, honest caveat -----
|
||
A="$W99/admin"
|
||
check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \
|
||
rungrant "users incus-admin" "$A"
|
||
check "grant: ...and the group step is a real convergence, named as one" 0 "added dev1 to 'incus'" \
|
||
rungrant "users incus-admin" "$W99/a2"
|
||
check "grant: ...saying WHY (the socket is a file, group 'incus', not a privilege)" 0 "mode 0660" \
|
||
rungrant "users incus-admin" "$W99/a2b"
|
||
check "grant: ...the caveat calls it a default placement, not a confinement" 0 "DEFAULT PLACEMENT" \
|
||
rungrant "users incus-admin" "$W99/a3"
|
||
check "grant: ...and names the group that has to go for it to bind" 0 "gpasswd -d dev1 incus-admin" \
|
||
rungrant "users incus-admin" "$W99/a4"
|
||
# The logs: what the run actually did to the machine.
|
||
# #101's decision, pinned at the seam that broke: an incus-admin member IS
|
||
# usermod'ed into 'incus'. It buys them no API privilege they lack — but
|
||
# incus-user's socket is a FILE, group 'incus' mode 0660, and without the
|
||
# membership the pinned touch below takes EACCES, the '|| true' eats it, and
|
||
# the grant dies blaming a healthy incus-user. The shim cannot model that
|
||
# EACCES (it ignores INCUS_SOCKET and permissions entirely), so the decision
|
||
# is pinned here and MEASURED on real Incus in drill/multiuser.sh criterion o.
|
||
check "grant: the admin member IS added to 'incus' — the user socket's group (#101)" 0 "" \
|
||
grep -qF 'usermod -aG incus dev1' "$A/sudo.log"
|
||
check "grant: their project is still narrowed to boxnet" 0 "" \
|
||
grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log"
|
||
check "grant: their project still gets snapshots" 0 "" \
|
||
grep -qF 'project set user-1000 restricted.snapshots allow' "$A/incus.log"
|
||
check "grant: their project still gets backups" 0 "" \
|
||
grep -qF 'project set user-1000 restricted.backups allow' "$A/incus.log"
|
||
check "grant: box-net is still installed INTO their project" 0 "" \
|
||
grep -qF -- '--project user-1000 profile edit box-net' "$A/incus.log"
|
||
# The socket pin (#99's teeth): incus's client takes the DAEMON socket when it
|
||
# is writable, and only falls back to unix.socket.user when it is not — so for
|
||
# an incus-admin member an unpinned touch never reaches incus-user at all, and
|
||
# the project it was supposed to create never appears.
|
||
check "grant: the touch is pinned at incus-user's socket (the admin socket would win)" 0 "" \
|
||
grep -qF "INCUS_SOCKET=$W99/incusdir/unix.socket.user" "$A/sudo.log"
|
||
check "grant: the user-side proof names their project (an unqualified show proves nothing)" 0 "" \
|
||
grep -qF -- '--project user-1000 profile show box-net' "$A/sudo.log"
|
||
# The socket existence probe rides $SUDO, like revoke's: /var/lib/incus is not
|
||
# traversable by a non-root admin, and a bare [ -e ] there false-fails into an
|
||
# exit that blames incus-user for a socket that is present (#101 review).
|
||
check "grant: the socket probe goes through sudo, not a bare [ -e ]" 0 "" \
|
||
grep -qF "test -e $W99/incusdir/unix.socket.user" "$A/sudo.log"
|
||
|
||
# --- the restricted user: unchanged, and unpinned ---------------------------
|
||
R="$W99/restricted"
|
||
check "grant: a plain user is still added to 'incus'" 0 "added dev1 to 'incus'" \
|
||
rungrant "users" "$R"
|
||
check "grant: ...via usermod (the log, not the prose)" 0 "" \
|
||
grep -qF 'usermod -aG incus dev1' "$R/sudo.log"
|
||
check "grant: ...and their client is left to its own socket fallback" 1 "" \
|
||
grep -qF 'INCUS_SOCKET' "$R/sudo.log"
|
||
|
||
# --- the failure path: what this run added comes back, and says what didn't --
|
||
F="$W99/failed"
|
||
check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \
|
||
rungrant "users incus-admin" "$F" FAKE_FAIL_NARROW=1
|
||
check "grant: ...says their admin socket was neither granted nor removed here" 1 "neither granted nor removed" \
|
||
rungrant "users incus-admin" "$W99/f2" FAKE_FAIL_NARROW=1
|
||
# The membership IS this run's now, so the backout IS its business (#101).
|
||
check "grant: ...and DOES roll the 'incus' membership back (this run added it)" 0 "" \
|
||
grep -qF 'gpasswd -d dev1 incus' "$F/sudo.log"
|
||
check "grant: ...while refusing to call that rollback a lockout" 1 "closed incus-user's socket, NOT" \
|
||
rungrant "users incus-admin" "$W99/f3" FAKE_FAIL_NARROW=1
|
||
|
||
# --- revoke, the mirror: it cannot take what it never gave ------------------
|
||
# BOX_YES=1 throughout: --purge is destructive and refuses without a terminal
|
||
# to confirm on, and this suite has none. It changes nothing for a bare revoke.
|
||
runrevoke() { # runrevoke <groups> <state-dir> [script args...]
|
||
local groups="$1" state="$2"; shift 2
|
||
mkdir -p "$state"
|
||
env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" BOX_YES=1 \
|
||
FAKE_HAVE_PROJECT=1 FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \
|
||
PATH="$GSHIM:$SHIMDIR:$PATH" bash "$ROOT/host/revoke-user.sh" dev1 "$@"
|
||
}
|
||
# The granted admin member is in BOTH groups — that is what 'box grant' leaves
|
||
# behind now (#101) — so revoke has a real membership to take back. It takes
|
||
# it, and still refuses to call the result a lockout: 'incus-admin' holds the
|
||
# daemon and is not this script's to remove.
|
||
GRANTED="users incus incus-admin"
|
||
V="$W99/revoke"
|
||
check "revoke: a bare revoke of a granted admin member is 'partial', not 'revoked'" 0 "partial:" \
|
||
runrevoke "$GRANTED" "$V"
|
||
check "revoke: ...and refuses to call it a lockout" 0 "is NOT locked out" \
|
||
runrevoke "$GRANTED" "$W99/v2"
|
||
check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \
|
||
runrevoke "$GRANTED" "$W99/v3"
|
||
# The mirror of grant's flip: there IS a privileged call now, and it is the
|
||
# membership grant added — asserted against the log, not the prose.
|
||
check "revoke: ...having actually dropped the 'incus' membership (the log)" 0 "" \
|
||
grep -qF 'gpasswd -d dev1 incus' "$V/sudo.log"
|
||
check "revoke: ...calling that key incus-user's, not their daemon access" 0 "NOT their daemon access" \
|
||
runrevoke "$GRANTED" "$W99/v4"
|
||
# An admin member who was never granted: nothing to take, and it still says so
|
||
# rather than reporting a revocation it did not perform.
|
||
N="$W99/revoke-ungranted"
|
||
check "revoke: an UNgranted admin member is still a named no-op" 0 "no-op:" \
|
||
runrevoke "users incus-admin" "$N"
|
||
check "revoke: ...saying their access is incus-admin's, untouched here" 0 "which this does not touch" \
|
||
runrevoke "users incus-admin" "$W99/n2"
|
||
# Absence of the LOG, not of a line in it: an ungranted admin member's bare
|
||
# revoke makes no privileged call whatsoever, so the file is never created.
|
||
check "revoke: ...having made NO privileged call at all (no membership to drop)" 1 "" \
|
||
test -e "$N/sudo.log"
|
||
P="$W99/purge"
|
||
check "revoke --purge: still unmakes the provisioning" 0 "purged:" \
|
||
runrevoke "$GRANTED" "$P" --purge
|
||
check "revoke --purge: ...and refuses to call an admin member 'out'" 0 "is NOT out" \
|
||
runrevoke "$GRANTED" "$W99/p2" --purge
|
||
check "revoke --purge: ...the project really was deleted (the log, not the summary)" 0 "" \
|
||
grep -qF 'project delete user-1000' "$P/incus.log"
|
||
rm -rf "$GSHIM" "$W99"
|
||
# 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 confirm gate (#105) — DRIVEN, not grepped.
|
||
#
|
||
# Until #105 the only coverage restore had was the two argument-validation
|
||
# checks above: neither ever reached dispatch, so the verb spent four releases
|
||
# handing a running box straight to 'incus snapshot restore' with no prompt
|
||
# and no --force, and nothing in this suite could have noticed. Both halves of
|
||
# the gate are now exercised against a fake incus that logs what it was asked
|
||
# to do — refusing must leave the log EMPTY (an assertion about an absence is
|
||
# the only way to prove a gate held), and --force must produce the restore.
|
||
#
|
||
# Stdin is closed on every run on purpose: confirm() branches on '[ -t 0 ]',
|
||
# and a suite run from a terminal would otherwise inherit one and sit there
|
||
# waiting for a human to type 'y'.
|
||
# ---------------------------------------------------------------------------
|
||
CSHIM="$(mktemp -d)"; CWORK="$(mktemp -d)"
|
||
cat > "$CSHIM/incus" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake incus for the destructive-path drive. Logs every call, and answers the
|
||
# one probe resolve_box makes so a box called 'work' exists and is ours.
|
||
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
||
case "$*" in
|
||
"config get work user.box") echo 1 ;;
|
||
"config get "*) exit 1 ;;
|
||
esac
|
||
exit 0
|
||
SHIM
|
||
chmod +x "$CSHIM/incus"
|
||
|
||
runbox() { # runbox <logfile> <args...> — the real box, shimmed, no TTY
|
||
local log="$1" rc; shift
|
||
: > "$log"
|
||
# Output is kept in <log>.out as well as replayed, so a check can assert on
|
||
# what the run PRINTED after the fact — check() swallows the output of a run
|
||
# it passes, and the "the prompt does not say 'delete'" assertion is exactly
|
||
# that: a claim about text from a run that already passed on its exit code.
|
||
env FAKE_INCUS_LOG="$log" PATH="$CSHIM:$PATH" "$BOX" "$@" </dev/null >"$log.out" 2>&1
|
||
rc=$?
|
||
cat "$log.out"
|
||
return "$rc"
|
||
}
|
||
|
||
# --- restore: the gate refuses, and nothing is destroyed --------------------
|
||
RLOG="$CWORK/restore.log"
|
||
check "restore: refuses without --force when there is no terminal (#105)" \
|
||
2 "refusing to roll work back to snapshot 'authed'" \
|
||
runbox "$RLOG" restore work authed
|
||
# The exact no-TTY wording, pinned. This is the regression test for the CI
|
||
# failure this PR produced: the multi-user rehearsal drives restore unattended
|
||
# on real Incus, took this refusal, and recorded '(b) restore failed' — a
|
||
# 40-minute job catching what a 15-second suite should have. box refuses
|
||
# rather than assuming consent, and it says which of the two ways out applies.
|
||
check "restore: ...and the refusal names the missing terminal, not a bad usage (#105)" \
|
||
2 "no terminal to confirm on" \
|
||
runbox "$CWORK/r-tty.log" restore work authed
|
||
# The load-bearing assertion: the refusal actually PREVENTED the rollback.
|
||
# 'grep -q' on an absence, so an empty log passes and a logged restore fails.
|
||
check "restore: ...and the refusal reached incus with no restore (#105)" 1 "" \
|
||
grep -qF 'snapshot restore' "$RLOG"
|
||
# The prompt must name the SNAPSHOT and the loss, not rm's wording. This is
|
||
# the entire point of making the prompt row-driven: adding the 'confirm' token
|
||
# alone would have asked the operator to confirm deleting the box.
|
||
check "restore: the prompt names what is lost, not a deletion (#105)" \
|
||
2 "discard everything in the box since it was taken" \
|
||
runbox "$CWORK/r2.log" restore work authed
|
||
check "restore: the prompt does NOT offer to delete the box (#105)" 1 "" \
|
||
grep -qF 'delete work' "$CWORK/r2.log.out"
|
||
|
||
# --- restore: --force is the way through, and it still restores -------------
|
||
FLOG="$CWORK/force.log"
|
||
check "restore --force: skips the prompt and restores (#105)" 0 "restored work to authed" \
|
||
runbox "$FLOG" restore work authed --force
|
||
check "restore --force: ...and incus was really asked for the rollback (#105)" 0 "" \
|
||
grep -qF 'incus snapshot restore work authed' "$FLOG"
|
||
|
||
# --- rm: its wording is unchanged, and its gate still holds -----------------
|
||
# #105 moved the prompt out of the dispatch line and into the rows. rm's text
|
||
# was the string that lived there, so it is pinned verbatim: a refactor that
|
||
# rewords the ONE verb that already asked correctly is a regression.
|
||
MLOG="$CWORK/rm.log"
|
||
check "rm: still refuses without --force, in its own words (#105 refactor)" \
|
||
2 "refusing to delete work and all its snapshots" \
|
||
runbox "$MLOG" rm work
|
||
check "rm: ...and nothing was deleted" 1 "" grep -qF 'delete' "$MLOG"
|
||
check "rm --force: still deletes" 0 "removed work" runbox "$CWORK/rmf.log" rm work --force
|
||
check "rm --force: ...via 'incus delete -f'" 0 "" \
|
||
grep -qF 'incus delete -f work' "$CWORK/rmf.log"
|
||
|
||
# --- the table invariant: a confirm row must carry its own words ------------
|
||
# Fail-closed on the shape itself, so a future 'confirm' row cannot ship with
|
||
# an empty prompt field and inherit whatever the dispatch happens to say.
|
||
# shellcheck disable=SC2016 # $3/$7/$1 are awk's fields, not the shell's
|
||
check "table: every 'confirm' row supplies a prompt (#105)" 0 "" \
|
||
awk -F'^' '
|
||
/^CMDS=\(/ { in_t = 1; next }
|
||
in_t && /^\)/ { exit }
|
||
in_t && /^ "/ && $3 ~ /(^|,)confirm(,|$)/ {
|
||
seen = 1
|
||
if (NF < 7) { print "row for " $1 " is marked confirm with no prompt field"; bad = 1; next }
|
||
p = $7; sub(/"$/, "", p)
|
||
if (p == "") { print "row for " $1 " has an empty confirm prompt"; bad = 1 }
|
||
}
|
||
END { if (!seen) { print "no confirm rows found — the pin is not reading the table"; bad = 1 }
|
||
exit (bad ? 1 : 0) }
|
||
' "$ROOT/bin/box"
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "dispatch: the confirm prompt comes from the row, not a constant (#105)" 0 "" \
|
||
grep -qF 'confirm "$(fill "$cnf" "$inst")"' "$ROOT/bin/box"
|
||
# The rehearsal drives restore unattended on real Incus, so it must consent
|
||
# EXPLICITLY — the gate is only real if the one automated caller had to change.
|
||
# Pinned here because the rehearsal itself needs a daemon and this suite has none.
|
||
check "rehearsal: the unattended restore passes --force (#105)" 0 "" \
|
||
grep -qF 'box restore mine s1 --force' "$ROOT/drill/multiuser.sh"
|
||
# --- the three answers a human can give — DRIVEN ON A REAL PTY (#111) -------
|
||
# Everything above stops at the no-TTY refusal, because confirm() branches on
|
||
# '[ -t 0 ]' and this suite has no terminal. So the interactive half — 'y',
|
||
# 'n', and Ctrl-D — had never been executed here at all, which is precisely
|
||
# how #111 survived: an unguarded 'read' returns non-zero on EOF, 'set -e'
|
||
# ends the run before the 'case', and the abort happens in total silence.
|
||
#
|
||
# 'script' from util-linux gives the child a pty, so box takes the interactive
|
||
# branch for real and reads the answer we write to the master side. This does
|
||
# NOT hang a suite run from a terminal: script's own stdin is a file or
|
||
# /dev/null on every run below, never the developer's tty, so the answer (or
|
||
# the EOF) is always already waiting.
|
||
if command -v script >/dev/null 2>&1 && script --version 2>/dev/null | grep -q util-linux; then
|
||
PWORK="$(mktemp -d)"; PLOG="$PWORK/pty.log"
|
||
printf 'y\n' > "$PWORK/yes"; printf 'n\n' > "$PWORK/no"
|
||
# Invoked through a file so 'script -c' needs no quoting of its own; the log
|
||
# path and the shim PATH ride the environment script hands to the child.
|
||
cat > "$PWORK/run" <<RUNNER
|
||
#!/usr/bin/env bash
|
||
exec env PATH="$CSHIM:\$PATH" "$BOX" rm work
|
||
RUNNER
|
||
chmod +x "$PWORK/run"
|
||
ptybox() { # ptybox <answers-file> — 'box rm work' on a pty, answered
|
||
: > "$PLOG"
|
||
FAKE_INCUS_LOG="$PLOG" script -qec "$PWORK/run" /dev/null < "$1"
|
||
}
|
||
# The load-bearing assertion is the MESSAGE, not the exit code: before the
|
||
# fix Ctrl-D also exited 1, just without ever saying why. Asserting on the
|
||
# code alone would pass against the bug.
|
||
check "rm: Ctrl-D at the prompt aborts OUT LOUD, not in silence (#111)" \
|
||
1 "aborted." ptybox /dev/null
|
||
check "rm: ...and the Ctrl-D abort really deleted nothing (#111)" 1 "" \
|
||
grep -qF 'incus delete' "$PLOG"
|
||
check "rm: 'n' at the prompt aborts (#111)" 1 "aborted." ptybox "$PWORK/no"
|
||
check "rm: ...and 'n' really deleted nothing (#111)" 1 "" \
|
||
grep -qF 'incus delete' "$PLOG"
|
||
# The accept path, so the pty rig is proven to be able to reach the work —
|
||
# three checks that can only ever refuse would pass against a box that
|
||
# refuses everything.
|
||
check "rm: 'y' at the prompt goes through (#111)" 0 "removed work" \
|
||
ptybox "$PWORK/yes"
|
||
check "rm: ...and 'y' really reached 'incus delete -f' (#111)" 0 "" \
|
||
grep -qF 'incus delete -f work' "$PLOG"
|
||
rm -rf "$PWORK"
|
||
else
|
||
echo "skip: the interactive confirm answers (no util-linux 'script' here; CI has it)"
|
||
fi
|
||
|
||
# --- the sweep: no prompt-shaped 'read' under 'set -e' may go unguarded (#111)
|
||
# The pty checks above prove the two 'bin/box' gates. This proves the CLASS,
|
||
# repo-wide, and it exists because the class is exactly what the first pass at
|
||
# #111 missed: 'host/revoke-user.sh' and 'host/teardown-host.sh' carried the
|
||
# identical defect and survived, because nothing here was looking for the shape.
|
||
#
|
||
# The shape: a 'read' at the start of a statement, fed from the script's own
|
||
# stdin (so a human, or an EOF), inside a file that turns on errexit. On EOF
|
||
# 'read' returns non-zero and 'set -e' ends the run BEFORE the 'case' that was
|
||
# going to name the abort — the tool goes mute at the moment it asked.
|
||
#
|
||
# What is deliberately NOT flagged, because it is not the shape:
|
||
# · 'while IFS= read -r' loops — fed by a redirect at 'done', and a non-zero
|
||
# read is how the loop is supposed to end;
|
||
# · '<<<' herestring reads — fed from a string, never from a human;
|
||
# · files without errexit ('drill/wipe.sh', 'drill/drill.sh',
|
||
# 'drill/multiuser.sh' run under 'set -u' only, wipe.sh documents why), where
|
||
# EOF simply falls through to the '*)' arm and aborts out loud on its own.
|
||
# A guard is any '||' on the read's own line: '|| die', '|| reply=""',
|
||
# '|| { echo …; exit 1; }' — the spelling is each script's to choose, the
|
||
# guard is not.
|
||
eof_guard_sweep() {
|
||
local f n line bad=0 files
|
||
# dotglob alongside globstar for the same reason CI's shellcheck step carries
|
||
# it (#116): globstar descends, but a glob does not MATCH a dot-prefixed name,
|
||
# so this sweep skipped '.github/scripts/*.sh' — the release path — exactly as
|
||
# the linter did. Those three set errexit, so they are in scope for this class
|
||
# by construction; today none of them reads at all, which is why widening the
|
||
# set is a no-op on current code rather than a bug fix.
|
||
files="$(cd "$ROOT" && shopt -s globstar dotglob && printf '%s\n' bin/* ./**/*.sh | sed 's|^\./||' | sort -u)"
|
||
while IFS= read -r f; do
|
||
[ -f "$ROOT/$f" ] || continue
|
||
grep -qE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*e' "$ROOT/$f" || continue
|
||
while IFS=: read -r n line; do
|
||
case "$line" in
|
||
*'<<<'*) continue ;; # herestring, not a prompt
|
||
*'||'*) continue ;; # guarded — the whole point
|
||
esac
|
||
echo "$f:$n: prompt-shaped 'read' under 'set -e' with no '||' guard:$line"
|
||
bad=1
|
||
done < <(grep -nE '^[[:space:]]*(IFS=[^[:space:]]+[[:space:]]+)?read([[:space:]]|$)' "$ROOT/$f")
|
||
done <<<"$files"
|
||
return "$bad"
|
||
}
|
||
check "no prompt-shaped 'read' under 'set -e' goes unguarded, repo-wide (#111)" \
|
||
0 "" eof_guard_sweep
|
||
|
||
rm -rf "$CSHIM" "$CWORK"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# export / import (#70) — a box's state that survives the box and the host.
|
||
# Usage errors and the pure pre-incus refusals are DRIVEN; every daemon-gated
|
||
# invariant is grep-guarded or line-order-asserted (fail-closed: an empty
|
||
# grep is a FAIL, so a deleted guard cannot ship green).
|
||
# ---------------------------------------------------------------------------
|
||
check "export without a box exits 2" 2 "usage: box export" "$BOX" export
|
||
check "export of an unknown box exits 1" 1 "no such box" "$BOX" export nosuchbox
|
||
check "import without a file exits 2" 2 "usage: box import" "$BOX" import
|
||
check "import of a missing file exits 1" 1 "no such file" "$BOX" import /nope/nothing.tar.gz
|
||
check "import --name with no value exits 2" 2 "--name needs a value" "$BOX" import x.tar.gz --name
|
||
# A file that is not an export artifact is named as such, before any incus
|
||
# call — pure (tar + awk), so it is driven, not grepped.
|
||
NOTATARBALL="$(mktemp)"; echo "not a tarball" > "$NOTATARBALL"
|
||
check "import: a non-artifact file is refused" 1 "not an incus/box export" "$BOX" import "$NOTATARBALL"
|
||
rm -f "$NOTATARBALL"
|
||
check "help export names the credential risk" 0 "CREDENTIAL" "$BOX" help export
|
||
check "help import names the re-stamping" 0 "user.box=1" "$BOX" help import
|
||
# Export refuses a running box — require_stopped fires BEFORE incus export
|
||
# (line order inside cmd_export, fail-closed on either grep missing).
|
||
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||
check "export: requires the box stopped, before exporting" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
guard="$(printf "%s\n" "$fn" | grep -n "require_stopped" | head -1 | cut -d: -f1)"
|
||
run="$(printf "%s\n" "$fn" | grep -n "incus export" | head -1 | cut -d: -f1)"
|
||
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
|
||
# Snapshots ride along by default; --instance-only is the explicit opt-out.
|
||
check "export: snapshots included unless --instance-only" 0 "" bash -c '
|
||
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q -- "--instance-only"'
|
||
# The credential SHOUT (#70's scrub-or-shout decision: box shouts).
|
||
check "export: shouts that the file is a credential" 0 "" bash -c '
|
||
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "treat the file itself as a credential"'
|
||
# Import re-stamps the boundary tag onto the current stack.
|
||
check "import: re-stamps user.box=1" 0 "" bash -c '
|
||
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "user.box=1"'
|
||
# The name-collision guard fires BEFORE incus import — the resolve_box
|
||
# boundary from the other side: never occupy an existing instance's name.
|
||
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||
check "import: the collision guard precedes the import" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
guard="$(printf "%s\n" "$fn" | grep -n "already exists" | head -1 | cut -d: -f1)"
|
||
run="$(printf "%s\n" "$fn" | grep -n "incus import" | head -1 | cut -d: -f1)"
|
||
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
|
||
# Import lands on the placement contract: same pre-flight as a mint.
|
||
check "import: pre-flights the stack (require_stack)" 0 "" bash -c '
|
||
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
|
||
# The artifact's MAC comes back verbatim, and a re-import beside a sibling
|
||
# collides at start (measured live: "MAC address already defined on another
|
||
# NIC") — the hwaddr unset must precede the start. Line order, fail-closed.
|
||
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||
check "import: regenerates the NIC MAC before the start" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
mac="$(printf "%s\n" "$fn" | grep -n "hwaddr" | head -1 | cut -d: -f1)"
|
||
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
|
||
[ -n "$mac" ] && [ -n "$start" ] && [ "$mac" -lt "$start" ]'
|
||
# reset_identity runs AFTER the imported box is started — the clone trust
|
||
# boundary (machine-id → DHCP lease), line-order-asserted, fail-closed.
|
||
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||
check "import: reset_identity follows the start" 0 "" bash -c '
|
||
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
|
||
reset="$(printf "%s\n" "$fn" | grep -n "reset_identity" | head -1 | cut -d: -f1)"
|
||
[ -n "$start" ] && [ -n "$reset" ] && [ "$start" -lt "$reset" ]'
|
||
# The restricted tier can export: grant converges restricted.backups (the
|
||
# backup API is what 'incus export' rides; blocked by default — #70).
|
||
check "grant: allows backups (the export workflow)" 0 "" \
|
||
grep -qF 'restricted.backups allow' "$ROOT/host/grant-user.sh"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The mint stamp (#103) — DRIVEN on both halves, write and read.
|
||
#
|
||
# There is no host-side per-box store: the Incus instance config IS the
|
||
# database, so the only proof that a fact survives the mint is the argument
|
||
# list box hands 'incus launch'. A fake incus logs every call verbatim and
|
||
# answers just enough for cmd_new and cmd_info to run to completion with no
|
||
# daemon anywhere — the same trick the confirm-gate drive uses above.
|
||
#
|
||
# The read half matters as much as the write half, and legacy boxes most of
|
||
# all: every box minted before this stamp existed carries none of these keys,
|
||
# and a box outlives the release that minted it. 'incus config get' on an
|
||
# unset key prints EMPTY and exits 0 (audit B4), so "no stamp" and "daemon
|
||
# said no" arrive identically — 'box info' must render both as a box with
|
||
# blanks, never as an error.
|
||
# ---------------------------------------------------------------------------
|
||
MSHIM="$(mktemp -d)"; MWORK="$(mktemp -d)"
|
||
cat > "$MSHIM/incus" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake incus for the mint-stamp drive. Knobs, all optional:
|
||
# FAKE_BASE_IMAGE what 'config get <i> volatile.base_image' resolves to;
|
||
# empty = incus does not know it (the degraded mint)
|
||
# FAKE_CFG a file of "<key> <value>" lines answering 'config get'
|
||
# FAKE_ROW the csv row 'list --columns nstS' returns
|
||
# The launch carries a whole cloud-init seed, so the call is logged with its
|
||
# newlines flattened — an assertion about "the launch line" must see one line.
|
||
printf 'incus %s\n' "$*" | tr '\n' ' ' >> "$FAKE_INCUS_LOG"
|
||
printf '\n' >> "$FAKE_INCUS_LOG"
|
||
case "$*" in
|
||
*volatile.base_image) printf '%s\n' "${FAKE_BASE_IMAGE-}" ;;
|
||
"config get "*)
|
||
[ -n "${FAKE_CFG:-}" ] || exit 0
|
||
key="$*"; key="${key##* }"
|
||
awk -v k="$key" '$1 == k { $1 = ""; sub(/^ /, ""); print }' "$FAKE_CFG" ;;
|
||
*"--columns nstS") printf '%s\n' "${FAKE_ROW-}" ;;
|
||
*"--columns 4") echo '10.1.2.3 (enp5s0)' ;;
|
||
esac
|
||
exit 0
|
||
SHIM
|
||
chmod +x "$MSHIM/incus"
|
||
|
||
export FAKE_BASE_IMAGE=deadbeefcafe0123456789 # what the alias resolves to
|
||
mintbox() { # mintbox <logfile> <args...> — the real box, shimmed, no TTY
|
||
local log="$1"; shift
|
||
: > "$log"
|
||
env FAKE_INCUS_LOG="$log" PATH="$MSHIM:$PATH" "$BOX" "$@" </dev/null >"$log.out" 2>&1
|
||
local rc=$?
|
||
cat "$log.out"
|
||
return "$rc"
|
||
}
|
||
# The launch line, isolated: every assertion below is about ONE incus call, and
|
||
# grepping the whole log would let a key stamped by some other call pass.
|
||
launchline() { grep -m1 '^incus launch ' "$1"; }
|
||
# launch_has/restamp_has <log> <ere> — a matcher per surface, so the ABSENCE
|
||
# assertions (a key that must not be stamped) are a plain non-zero exit rather
|
||
# than a nest of quoting.
|
||
launch_has() { launchline "$1" | grep -qE "$2"; }
|
||
restamp_has() { grep -F 'config set' "$1" | grep -qE "$2"; }
|
||
|
||
# --- the write half: a fresh mint ------------------------------------------
|
||
MLOG="$MWORK/mint.log"
|
||
check "mint: a shimmed 'box new' runs to completion" 0 "ready" \
|
||
mintbox "$MLOG" new --name w1 --template claude-box --container
|
||
# Each key on its own check: a single grep for the whole block would go green
|
||
# on a partial stamp, and "which fact was dropped" is the useful failure.
|
||
check "mint: stamps the schema — the stamp's SHAPE, not the box version (#103)" \
|
||
0 "user.box.schema=1" launchline "$MLOG"
|
||
check "mint: stamps the box version that minted it (#103)" \
|
||
0 "user.box.version=$(cat "$ROOT/VERSION")" launchline "$MLOG"
|
||
check "mint: stamps the base image ALIAS asked for (#103)" \
|
||
0 "user.box.image=images:debian/13/cloud" launchline "$MLOG"
|
||
check "mint: stamps the mode it minted as (#103)" \
|
||
0 "user.box.mode=container" launchline "$MLOG"
|
||
# The demand, not just the outcome: TYPE already says CT afterwards, but only
|
||
# the mint knew whether a container was ASKED for or fallen back into.
|
||
check "mint: stamps the mode that was ASKED, not only the outcome (#103)" \
|
||
0 "user.box.mode.asked=container" launchline "$MLOG"
|
||
check "mint: stamps the rig role box will auto-run (#103)" \
|
||
0 "user.box.role=claude-box" launchline "$MLOG"
|
||
check "mint: stamps which rig converged it — repo (#103)" \
|
||
0 "user.box.rig.repo=heavy-duty/rig" launchline "$MLOG"
|
||
check "mint: stamps which rig converged it — ref (#103)" \
|
||
0 "user.box.rig.ref=main" launchline "$MLOG"
|
||
check "mint: stamps the origin (#103)" 0 "user.box.origin=mint" launchline "$MLOG"
|
||
# The timestamp's SHAPE, so a local-time or seconds-since-epoch spelling fails
|
||
# here: UTC ISO 8601, which is the only form that sorts and travels.
|
||
check "mint: stamps the mint time as UTC ISO 8601 (#103)" 0 "" \
|
||
launch_has "$MLOG" 'user\.box\.created=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z'
|
||
# The existing three keys are UNTOUCHED — the stamp extends the namespace, it
|
||
# does not rewrite it, and box_user()/the login hint read two of them.
|
||
check "mint: the pre-existing boundary tag still rides the same line" \
|
||
0 "user.box=1" launchline "$MLOG"
|
||
check "mint: the pre-existing template stamp is untouched" \
|
||
0 "user.box.template=claude-box" launchline "$MLOG"
|
||
check "mint: the pre-existing user stamp is untouched" \
|
||
0 "user.box.user=claude" launchline "$MLOG"
|
||
# Deliberately NOT stamped. limits.* already hold cpu/memory and a duplicate
|
||
# drifts the first time someone edits a limit by hand; a container's disk does
|
||
# not exist (its root rides the pool); and the tier is a fact about whoever is
|
||
# ASKING. Absence assertions, so a well-meant addition has to argue here first.
|
||
check "mint: does NOT duplicate cpu into the user.box namespace (#103)" 1 "" \
|
||
launch_has "$MLOG" 'user\.box\.cpu'
|
||
check "mint: does NOT duplicate memory into the user.box namespace (#103)" 1 "" \
|
||
launch_has "$MLOG" 'user\.box\.memory'
|
||
check "mint: does NOT stamp a disk — a container's does not exist (#103)" 1 "" \
|
||
launch_has "$MLOG" 'user\.box\.disk'
|
||
check "mint: does NOT stamp the tier — it describes the asker, not the box (#103)" 1 "" \
|
||
launch_has "$MLOG" 'user\.box\.tier'
|
||
|
||
# --- the resolved fingerprint: the alias is not a reproducible fact ---------
|
||
# $T_IMAGE is an unpinned alias on a moving remote. Incus resolves it during
|
||
# the launch and records what it landed on; box reads that back and pins it.
|
||
check "mint: pins the RESOLVED image fingerprint after the launch (#103)" 0 "" \
|
||
grep -qF "config set w1 user.box.image.fingerprint=$FAKE_BASE_IMAGE" "$MLOG"
|
||
check "mint: ...and it is a SECOND call, not something the launch could know" 1 "" \
|
||
launch_has "$MLOG" 'image\.fingerprint'
|
||
# The load-bearing half of that design: a box that exists and boots must never
|
||
# be failed over a provenance field. With no fingerprint to be had, the mint
|
||
# still succeeds and the alias stands alone as the honest partial answer.
|
||
NOFP="$MWORK/nofp.log"
|
||
FAKE_BASE_IMAGE="" # exported above; incus does not know what the alias resolved to
|
||
check "mint: an unknowable fingerprint does not fail the mint (#103)" 0 "ready" \
|
||
mintbox "$NOFP" new --name w4 --template blank --container
|
||
check "mint: ...and it stamped no empty fingerprint key either (#103)" 1 "" \
|
||
grep -q 'image.fingerprint' "$NOFP"
|
||
FAKE_BASE_IMAGE=deadbeefcafe0123456789
|
||
|
||
# --- a seed that installs no rig is stamped no rig pin ----------------------
|
||
# 'blank' carries no @RIG_REPO@ token, so there is no rig to name. A stamp
|
||
# that named one anyway would be fiction, which is worse than a missing key.
|
||
check "mint: the 'blank' seed installs no rig, so no rig pin is stamped (#103)" 1 "" \
|
||
launch_has "$NOFP" 'user\.box\.rig\.'
|
||
check "mint: ...and no role either — blank names none (#103)" 1 "" \
|
||
launch_has "$NOFP" 'user\.box\.role'
|
||
|
||
# --- the rig pin has ONE definition, and both readers get the same answer ---
|
||
# The seed substitutes it and the stamp records it. Two spellings of the same
|
||
# default would eventually disagree, and a stamp that disagrees with the seed
|
||
# it shipped alongside is worse than no stamp. Driven through the environment
|
||
# override, so the two are compared on a value neither can have hardcoded.
|
||
RIGLOG="$MWORK/rig.log"
|
||
export RIG_REPO=someone/rig RIG_REF=probe-ref
|
||
mintbox "$RIGLOG" new --name w5 --template claude-box --container >/dev/null 2>&1
|
||
unset RIG_REPO RIG_REF
|
||
check "mint: the rig pin override reaches the STAMP (#103)" 0 "" \
|
||
launch_has "$RIGLOG" 'user\.box\.rig\.repo=someone/rig'
|
||
check "mint: ...both halves of it (#103)" 0 "" \
|
||
launch_has "$RIGLOG" 'user\.box\.rig\.ref=probe-ref'
|
||
check "mint: ...and the SEED it shipped with carries the same pin (#103)" 0 "" \
|
||
launch_has "$RIGLOG" 'someone/rig/probe-ref'
|
||
|
||
# --- the clone: the sharpest edge of the whole stamp ------------------------
|
||
# 'incus copy' carries every user.* key forward (audit B2) — which is what
|
||
# makes a clone know its template for free, and is exactly why the stamp
|
||
# cannot ride along untouched. An inherited stamp does not go stale, it goes
|
||
# FALSE: the clone would claim a mint time it was not present for, by a box
|
||
# version that never saw it.
|
||
CLONELOG="$MWORK/clone.log"
|
||
check "clone: a shimmed 'box new --from' runs to completion" 0 "cloned" \
|
||
mintbox "$CLONELOG" new --name w2 --from work/authed
|
||
check "clone: re-stamps the origin as a clone, not a mint (#103)" 0 "" \
|
||
grep -qF 'user.box.origin=clone' "$CLONELOG"
|
||
check "clone: names the source it was taken from — one hop (#103)" 0 "" \
|
||
grep -qF 'user.box.origin.from=work/authed' "$CLONELOG"
|
||
check "clone: re-stamps the box version that made THIS instance (#103)" 0 "" \
|
||
grep -qF "user.box.version=$(cat "$ROOT/VERSION")" "$CLONELOG"
|
||
check "clone: re-stamps a fresh created time (#103)" 0 "" \
|
||
grep -qE 'user\.box\.created=[0-9]{4}-[0-9]{2}-[0-9]{2}T' "$CLONELOG"
|
||
# The other half of the decision, and the reason it is a decision at all: the
|
||
# LINEAGE keys are left alone on purpose. The clone's disk genuinely came from
|
||
# that image, that template, that user, that role — re-stamping them from the
|
||
# cloning process's own template lookup would be the actual lie, and would
|
||
# break the login hint that reads user.box.template off the instance.
|
||
check "clone: does NOT re-stamp the template — it is inherited lineage (#103)" 1 "" \
|
||
restamp_has "$CLONELOG" 'user\.box\.template'
|
||
check "clone: does NOT re-stamp the user — box_user() reads the source's (#103)" 1 "" \
|
||
restamp_has "$CLONELOG" 'user\.box\.user'
|
||
check "clone: does NOT re-stamp the image — the disk really came from it (#103)" 1 "" \
|
||
restamp_has "$CLONELOG" 'user\.box\.image'
|
||
check "clone: does NOT re-stamp the role — the source's rig converged it (#103)" 1 "" \
|
||
restamp_has "$CLONELOG" 'user\.box\.role'
|
||
# The third column, and the one review found: 'mode.asked' is neither lineage
|
||
# nor re-stampable. It is a mint-event fact whose asker was the SOURCE's
|
||
# operator, and a clone refuses --vm/--container, so nobody was asked anything
|
||
# here. It is CLEARED — there is no true value to give it.
|
||
check "clone: clears the inherited mode.asked — nobody asked THIS box (#103)" 0 "" \
|
||
grep -qF 'config unset w2 user.box.mode.asked' "$CLONELOG"
|
||
check "clone: ...and does not re-stamp it with a fabricated answer (#103)" 1 "" \
|
||
restamp_has "$CLONELOG" 'user\.box\.mode\.asked'
|
||
# Cleared, never set-to-empty: an empty value is still a key on the instance.
|
||
check "clone: clears it rather than setting it empty (#103)" 1 "" \
|
||
grep -qE 'config set .*user\.box\.mode\.asked=($|[[:space:]])' "$CLONELOG"
|
||
# Order: the re-stamp lands on the copied instance BEFORE it is started, so a
|
||
# clone is never observable wearing its source's provenance. Fail-closed — an
|
||
# absent line makes the arithmetic fail, not pass.
|
||
restamp_precedes_start() {
|
||
local set start
|
||
set="$(grep -n 'config set .* user.box.origin=clone' "$1" | head -1 | cut -d: -f1)"
|
||
start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)"
|
||
[ -n "$set" ] && [ -n "$start" ] && [ "$set" -lt "$start" ]
|
||
}
|
||
check "clone: the re-stamp precedes the start (#103)" 0 "" \
|
||
restamp_precedes_start "$CLONELOG"
|
||
# The clear rides the same rule for the same reason: a clone must never be
|
||
# observable — not for one moment, not to 'box info' — wearing an 'asked' its
|
||
# operator never gave. Fail-closed the same way.
|
||
clear_precedes_start() {
|
||
local unset_ln start
|
||
unset_ln="$(grep -n 'config unset .* user.box.mode.asked' "$1" | head -1 | cut -d: -f1)"
|
||
start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)"
|
||
[ -n "$unset_ln" ] && [ -n "$start" ] && [ "$unset_ln" -lt "$start" ]
|
||
}
|
||
check "clone: the mode.asked clear precedes the start too (#103)" 0 "" \
|
||
clear_precedes_start "$CLONELOG"
|
||
|
||
# --- the read half: 'box info' surfaces it ---------------------------------
|
||
# A stamp nothing can read is not done. cmd_info printed NAME/STATE/TYPE/IPV4
|
||
# and surfaced none of the keys that already existed.
|
||
STAMPED="$MWORK/stamped.cfg"
|
||
cat > "$STAMPED" <<'CFG'
|
||
user.box 1
|
||
user.box.schema 1
|
||
user.box.created 2026-07-19T14:22:07Z
|
||
user.box.version 0.8.0
|
||
user.box.image images:debian/13/cloud
|
||
user.box.image.fingerprint 8a2f1c9d4e5b6a7c8d9e
|
||
user.box.mode vm
|
||
user.box.mode.asked auto
|
||
user.box.template claude
|
||
user.box.user claude
|
||
user.box.role claude
|
||
user.box.rig.repo heavy-duty/rig
|
||
user.box.rig.ref main
|
||
user.box.origin mint
|
||
CFG
|
||
infobox() { # infobox <cfg-file> — 'box info work' against a canned config
|
||
env FAKE_INCUS_LOG=/dev/null FAKE_CFG="$1" FAKE_ROW='work,RUNNING,VIRTUAL-MACHINE,0' \
|
||
PATH="$MSHIM:$PATH" "$BOX" info work </dev/null 2>&1
|
||
}
|
||
check "info: surfaces the mint time and the box that minted it (#103)" \
|
||
0 "MINTED 2026-07-19T14:22:07Z by box 0.8.0" infobox "$STAMPED"
|
||
check "info: surfaces the image alias AND what it resolved to (#103)" \
|
||
0 "IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b" infobox "$STAMPED"
|
||
check "info: surfaces the template with its user and role (#103)" \
|
||
0 "TEMPLATE claude (user claude, role claude)" infobox "$STAMPED"
|
||
check "info: surfaces which rig converged it (#103)" \
|
||
0 "RIG heavy-duty/rig@main" infobox "$STAMPED"
|
||
check "info: surfaces the origin (#103)" 0 "ORIGIN mint" infobox "$STAMPED"
|
||
# Still the box it always was: the new block is additive, above the snapshots.
|
||
check "info: still prints the state block it always did" 0 "IPV4 10.1.2.3" infobox "$STAMPED"
|
||
|
||
# A clone reads back as a clone, naming its source. Modelled on what the
|
||
# --from branch actually leaves behind: origin re-stamped, mode.asked cleared.
|
||
CLONECFG="$MWORK/clone.cfg"
|
||
{ grep -v -e '^user.box.origin ' -e '^user.box.mode.asked ' "$STAMPED"
|
||
echo 'user.box.origin clone'
|
||
echo 'user.box.origin.from work/authed'; } > "$CLONECFG"
|
||
check "info: a clone says so, and names the box it came from (#103)" \
|
||
0 "ORIGIN clone of work/authed" infobox "$CLONECFG"
|
||
# ...and stays silent about a demand nobody made of it. The MODE line is gated
|
||
# on 'asked' precisely so absence renders as silence rather than as a guess;
|
||
# TYPE above still reports VM off the instance type, so nothing is lost.
|
||
info_has_mode() { infobox "$1" | grep -q '^MODE'; }
|
||
check "info: a clone prints no MODE line — nobody asked IT anything (#103)" 1 "" \
|
||
info_has_mode "$CLONECFG"
|
||
check "info: ...while TYPE still reports what it actually is (#103)" \
|
||
0 "TYPE VM" infobox "$CLONECFG"
|
||
# The mint keeps its MODE line — there, the operator really was asked.
|
||
check "info: a MINT still surfaces what was asked for (#103)" \
|
||
0 "MODE vm (asked: auto)" infobox "$STAMPED"
|
||
|
||
# --- legacy boxes: the promise that they keep working under every verb ------
|
||
# A box carrying the boundary tag and NOTHING else — every box minted before
|
||
# this stamp existed. It must still render, exit 0, and say plainly that the
|
||
# mint was not recorded rather than inventing one or erroring out.
|
||
LEGACY="$MWORK/legacy.cfg"; printf 'user.box 1\n' > "$LEGACY"
|
||
check "info: a box with NO stamp at all still renders, exit 0 (#103)" \
|
||
0 "NAME work" infobox "$LEGACY"
|
||
check "info: ...and says the mint was not recorded, rather than inventing one" \
|
||
0 "predates the mint stamp" infobox "$LEGACY"
|
||
info_has() { infobox "$1" | grep -qE "$2"; }
|
||
check "info: ...and prints no half-empty IMAGE/ORIGIN lines for keys it lacks" 1 "" \
|
||
info_has "$LEGACY" '^(IMAGE|ORIGIN|RIG|MODE) '
|
||
# A pre-rename box carries user.claudebox=1 and no metadata at all, and is
|
||
# always a Claude box — the same mapping box_user() makes, honored forever.
|
||
PRERENAME="$MWORK/prerename.cfg"; printf 'user.claudebox 1\n' > "$PRERENAME"
|
||
check "info: a pre-rename box still reads as the claude template (#103)" \
|
||
0 "TEMPLATE claude" infobox "$PRERENAME"
|
||
|
||
# --- a schema from the future is not a broken box --------------------------
|
||
# A box outlives the release that minted it, so an OLDER box will one day read
|
||
# a NEWER box's stamp. It shows what it understands and says so; refusing to
|
||
# describe a box a later release minted perfectly well is the wrong answer.
|
||
FUTURE="$MWORK/future.cfg"
|
||
{ grep -v '^user.box.schema ' "$STAMPED"; echo 'user.box.schema 99'; } > "$FUTURE"
|
||
check "info: an unrecognised (newer) schema is noted, not refused (#103)" \
|
||
0 "NOTE" infobox "$FUTURE"
|
||
check "info: ...and it still shows every key it DOES understand (#103)" \
|
||
0 "MINTED 2026-07-19T14:22:07Z" infobox "$FUTURE"
|
||
check "info: ...and it still exits 0 — a future box is not a broken box (#103)" \
|
||
0 "ORIGIN" infobox "$FUTURE"
|
||
# A non-integer schema lands on the same side: noted, never fatal under set -e.
|
||
GARBAGE="$MWORK/garbage.cfg"
|
||
{ grep -v '^user.box.schema ' "$STAMPED"; echo 'user.box.schema not-a-number'; } > "$GARBAGE"
|
||
check "info: a non-integer schema is noted, not fatal (#103)" 0 "NOTE" infobox "$GARBAGE"
|
||
|
||
# VERSION has ONE reader in bin/box — box_version() — and both 'box --version'
|
||
# and the mint stamp go through it. A second 'cat $root/VERSION' is how the two
|
||
# would eventually disagree about what minted a box.
|
||
# shellcheck disable=SC2016 # '$root' is bin/box's variable, matched literally
|
||
one_version_reader() {
|
||
[ "$(grep -cF 'cat "$root/VERSION"' "$ROOT/bin/box")" -eq 1 ]
|
||
}
|
||
check "the tree's VERSION has a single reader, box_version() (#103)" 0 "" \
|
||
one_version_reader
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The import event (#131) — DRIVEN, on both halves, like the mint stamp above.
|
||
#
|
||
# An imported box keeps the artifact's mint stamp verbatim: the mint time, the
|
||
# box version, the image and the origin belong to the originating host and
|
||
# survive the trip on purpose (#129). What was missing is any record that the
|
||
# trip HAPPENED — and the obvious fix, 'origin=import', is the wrong one: it
|
||
# would overwrite whether the thing was a mint or a clone before it was
|
||
# exported, and leave 'origin.from' naming a lineage nothing explains. So the
|
||
# import takes its own keys, and the assertions that matter most here are the
|
||
# ABSENCE ones: every key the artifact carried must come out the far side
|
||
# untouched.
|
||
#
|
||
# The write half needs its own shim, because cmd_import reads 'incus config
|
||
# show <target>' as the name-collision guard and must see the name FREE — the
|
||
# opposite answer from the one the mint shim gives.
|
||
ISHIM="$(mktemp -d)"; IWORK="$(mktemp -d)"
|
||
cat > "$ISHIM/incus" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake incus for the import drive. FAKE_CFG answers 'config get' with a file
|
||
# of "<key> <value>" lines — it stands in for the config that rode inside the
|
||
# artifact and that 'incus import' has just restored.
|
||
printf 'incus %s\n' "$*" | tr '\n' ' ' >> "$FAKE_INCUS_LOG"
|
||
printf '\n' >> "$FAKE_INCUS_LOG"
|
||
case "$*" in
|
||
# Nothing exists under that name: the collision guard passes. The same call
|
||
# enumerates volatile hwaddrs later, where an empty answer is also correct.
|
||
"config show "*) exit 1 ;;
|
||
"config get "*)
|
||
[ -n "${FAKE_CFG:-}" ] || exit 0
|
||
key="$*"; key="${key##* }"
|
||
awk -v k="$key" '$1 == k { $1 = ""; sub(/^ /, ""); print }' "$FAKE_CFG" ;;
|
||
*"--columns P") printf '"box-net"\n' ;; # already on the contract, no re-home
|
||
esac
|
||
exit 0
|
||
SHIM
|
||
chmod +x "$ISHIM/incus"
|
||
|
||
# A real tarball, because cmd_import reads the instance name out of the
|
||
# artifact with tar before incus is ever called — a stub cannot fake that.
|
||
ARTIFACT="$IWORK/work-20260718T120000Z.tar.gz"
|
||
mkdir -p "$IWORK/backup" && printf 'name: work\n' > "$IWORK/backup/index.yaml"
|
||
tar -czf "$ARTIFACT" -C "$IWORK" backup/index.yaml
|
||
|
||
importbox() { # importbox <logfile> <cfg-file|""> — the real box, shimmed
|
||
local log="$1" cfg="$2"
|
||
: > "$log"
|
||
env FAKE_INCUS_LOG="$log" FAKE_CFG="$cfg" PATH="$ISHIM:$PATH" \
|
||
"$BOX" import "$ARTIFACT" </dev/null >"$log.out" 2>&1
|
||
local rc=$?
|
||
cat "$log.out"
|
||
return "$rc"
|
||
}
|
||
# One matcher per surface, so an absence assertion is a plain non-zero exit.
|
||
# 'config set' is the ONLY call that can write a key, so grepping it is what
|
||
# separates "box stamped this" from "the artifact carried it".
|
||
import_set() { grep -F 'config set' "$1" | grep -qE "$2"; }
|
||
|
||
# --- the first trip ---------------------------------------------------------
|
||
# The artifact of a box that was MINTED elsewhere and never imported before.
|
||
MINTED_ART="$IWORK/minted.cfg"
|
||
cat > "$MINTED_ART" <<'CFG'
|
||
user.box 1
|
||
user.box.schema 1
|
||
user.box.created 2026-06-01T10:00:00Z
|
||
user.box.version 0.7.0
|
||
user.box.template claude
|
||
user.box.user claude
|
||
user.box.origin mint
|
||
CFG
|
||
ILOG="$IWORK/import.log"
|
||
check "import: a shimmed 'box import' runs to completion (#131)" 0 "imported work" \
|
||
importbox "$ILOG" "$MINTED_ART"
|
||
check "import: records WHEN the box landed here, UTC ISO 8601 (#131)" 0 "" \
|
||
import_set "$ILOG" 'user\.box\.imported\.last=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z'
|
||
check "import: records WHICH box version performed the import (#131)" 0 "" \
|
||
import_set "$ILOG" "user\\.box\\.imported\\.last\\.by=$(cat "$ROOT/VERSION")"
|
||
check "import: pins the FIRST trip as its own key (birth pair, rig#61) (#131)" 0 "" \
|
||
import_set "$ILOG" 'user\.box\.imported=[0-9]{4}-'
|
||
check "import: ...with the box version that made it (#131)" 0 "" \
|
||
import_set "$ILOG" "user\\.box\\.imported\\.by=$(cat "$ROOT/VERSION")"
|
||
check "import: counts the trip — the first one is 1 (#131)" 0 "" \
|
||
import_set "$ILOG" 'user\.box\.imported\.count=1'
|
||
|
||
# --- the absence assertions: this is the whole point of the issue -----------
|
||
# 'origin=import' is the road not taken. 'origin' says how the instance came
|
||
# into BEING — mint or clone — and the import is a third, orthogonal fact.
|
||
check "import: does NOT overwrite origin — an import is not a coming-into-being (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.origin='
|
||
check "import: does NOT restamp the mint time — it is the origin host's (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.created='
|
||
check "import: does NOT restamp the box version that MINTED it (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.version='
|
||
check "import: does NOT restamp the template — lineage rides in the artifact (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.template='
|
||
check "import: does NOT restamp the image the artifact was built on (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.image'
|
||
check "import: does NOT restamp the rig that converged it (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.rig\.'
|
||
# Adding a key is not a breaking change (#103's schema contract), so the schema
|
||
# does not move — and is not written here at all: stamping schema=1 onto a
|
||
# legacy artifact would claim a mint stamp shape it does not have.
|
||
check "import: does NOT touch user.box.schema — adding a key is not breaking (#131)" 1 "" \
|
||
import_set "$ILOG" 'user\.box\.schema'
|
||
# Before the start, like the clone re-stamp: an imported box is never
|
||
# observable without the record of how it got here. Fail-closed — a missing
|
||
# line makes the arithmetic fail, not pass.
|
||
import_stamp_precedes_start() {
|
||
local set start
|
||
set="$(grep -n 'config set .* user.box.imported.last=' "$1" | head -1 | cut -d: -f1)"
|
||
start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)"
|
||
[ -n "$set" ] && [ -n "$start" ] && [ "$set" -lt "$start" ]
|
||
}
|
||
check "import: the import stamp precedes the start (#131)" 0 "" \
|
||
import_stamp_precedes_start "$ILOG"
|
||
|
||
# --- a CLONE that was exported and re-imported ------------------------------
|
||
# The case that makes 'origin=import' indefensible: it would come back
|
||
# claiming to be an import, with nothing left saying it was ever a clone and
|
||
# an origin.from pointing at a lineage no key explains.
|
||
CLONE_ART="$IWORK/clone-artifact.cfg"
|
||
{ grep -v '^user.box.origin ' "$MINTED_ART"
|
||
echo 'user.box.origin clone'
|
||
echo 'user.box.origin.from work/authed'; } > "$CLONE_ART"
|
||
CLOG="$IWORK/clone-import.log"
|
||
check "import: an exported CLONE imports cleanly (#131)" 0 "imported work" \
|
||
importbox "$CLOG" "$CLONE_ART"
|
||
check "import: ...and is still a clone afterwards — origin untouched (#131)" 1 "" \
|
||
import_set "$CLOG" 'user\.box\.origin='
|
||
check "import: ...and still names the box it was cloned from (#131)" 1 "" \
|
||
import_set "$CLOG" 'user\.box\.origin\.from'
|
||
check "import: ...while still recording that the trip happened (#131)" 0 "" \
|
||
import_set "$CLOG" 'user\.box\.imported\.last='
|
||
|
||
# --- the second trip: first-wins, last-wins, and a count --------------------
|
||
# The artifact of a box that has already been imported twice. Last-wins alone
|
||
# would erase the evidence of the first trip, which is the same mistake
|
||
# 'origin=import' makes one level up.
|
||
TWICE="$IWORK/twice.cfg"
|
||
{ cat "$MINTED_ART"
|
||
echo 'user.box.imported 2026-06-15T08:00:00Z'
|
||
echo 'user.box.imported.by 0.7.0'
|
||
echo 'user.box.imported.last 2026-07-01T09:00:00Z'
|
||
echo 'user.box.imported.last.by 0.8.0'
|
||
echo 'user.box.imported.count 2'; } > "$TWICE"
|
||
RLOG="$IWORK/reimport.log"
|
||
check "re-import: an already-imported artifact imports again (#131)" 0 "imported work" \
|
||
importbox "$RLOG" "$TWICE"
|
||
check "re-import: the FIRST trip is pinned, never rewritten (#131)" 1 "" \
|
||
import_set "$RLOG" 'user\.box\.imported=[0-9]'
|
||
check "re-import: ...nor is the version that made it (#131)" 1 "" \
|
||
import_set "$RLOG" 'user\.box\.imported\.by='
|
||
check "re-import: the LATEST trip IS refreshed (#131)" 0 "" \
|
||
import_set "$RLOG" 'user\.box\.imported\.last=[0-9]{4}-'
|
||
check "re-import: ...by this box version (#131)" 0 "" \
|
||
import_set "$RLOG" "user\\.box\\.imported\\.last\\.by=$(cat "$ROOT/VERSION")"
|
||
check "re-import: the count advances 2 → 3 (#131)" 0 "" \
|
||
import_set "$RLOG" 'user\.box\.imported\.count=3'
|
||
# A count that is not an integer (hand-edited config, a foreign key) must not
|
||
# fail an import that has already happened — arithmetic under 'set -e' would.
|
||
BADN="$IWORK/badcount.cfg"
|
||
{ cat "$MINTED_ART"; echo 'user.box.imported.count not-a-number'; } > "$BADN"
|
||
BLOG="$IWORK/badcount.log"
|
||
check "re-import: a non-integer count does not fail the import (#131)" 0 "imported work" \
|
||
importbox "$BLOG" "$BADN"
|
||
check "re-import: ...it restarts the count rather than inventing a total (#131)" 0 "" \
|
||
import_set "$BLOG" 'user\.box\.imported\.count=1'
|
||
# A leading zero is the hole the non-integer fixture CANNOT catch: '08' passes
|
||
# an -eq guard (test parses decimal) and then dies in arithmetic, which reads
|
||
# it as octal. That abort would land after the physical 'incus import' and
|
||
# before the stamp, the placement fix and the start — the exact window the
|
||
# degrade-never-die contract exists to protect. A zero-padded count is not
|
||
# exotic either: it is what any external tool that formats numbers writes.
|
||
ZEROPAD="$IWORK/zeropad.cfg"
|
||
{ cat "$MINTED_ART"; echo 'user.box.imported.count 08'; } > "$ZEROPAD"
|
||
ZLOG="$IWORK/zeropad.log"
|
||
check "re-import: a zero-padded count does not fail the import (#131)" 0 "imported work" \
|
||
importbox "$ZLOG" "$ZEROPAD"
|
||
# Counted as decimal 8, not degraded to 0 and not read as octal: '08' is a
|
||
# real previous total, so the honest next value is 9.
|
||
check "re-import: ...and counts it as decimal, so 08 advances to 9 (#131)" 0 "" \
|
||
import_set "$ZLOG" 'user\.box\.imported\.count=9'
|
||
|
||
# --- a legacy artifact with no stamp at all ---------------------------------
|
||
# A pre-stamp box export, or a hand-rolled 'incus export' of an unmanaged VM.
|
||
# It must import cleanly, get the boundary tag, get the import record — and NOT
|
||
# acquire a fabricated mint, which is what 'not recorded' exists to say.
|
||
LLOG="$IWORK/legacy-import.log"
|
||
check "import: a legacy artifact with NO stamp imports cleanly (#131)" 0 "imported work" \
|
||
importbox "$LLOG" /dev/null
|
||
check "import: ...it still gets the boundary tag (importing is minting) (#131)" 0 "" \
|
||
grep -qF 'config set work user.box=1' "$LLOG"
|
||
check "import: ...and the import record (#131)" 0 "" \
|
||
import_set "$LLOG" 'user\.box\.imported\.last='
|
||
check "import: ...but NO invented mint time (#131)" 1 "" \
|
||
import_set "$LLOG" 'user\.box\.created='
|
||
check "import: ...and no invented mint version either (#131)" 1 "" \
|
||
import_set "$LLOG" 'user\.box\.version='
|
||
|
||
# --- the read half: 'box info' must not let the mint be misread -------------
|
||
# The whole hazard: MINTED carries a time that is deliberately NOT this host's,
|
||
# and a reader who meets it alone will take it for one. The IMPORTED line sits
|
||
# directly under it and states the only thing box actually knows — the
|
||
# ORDERING. It does not claim another host: a box can be exported and
|
||
# re-imported onto the SAME host (#66's upgrade advice), and nothing records
|
||
# which host minted it.
|
||
IMPCFG="$MWORK/imported.cfg"
|
||
{ cat "$STAMPED"
|
||
echo 'user.box.imported 2026-07-20T09:14:03Z'
|
||
echo 'user.box.imported.by 0.8.1'
|
||
echo 'user.box.imported.last 2026-07-20T09:14:03Z'
|
||
echo 'user.box.imported.last.by 0.8.1'
|
||
echo 'user.box.imported.count 1'; } > "$IMPCFG"
|
||
check "info: surfaces when the box arrived, and by which box (#131)" \
|
||
0 "IMPORTED 2026-07-20T09:14:03Z by box 0.8.1" infobox "$IMPCFG"
|
||
check "info: ...and says the mint above is NOT this arrival (#131)" \
|
||
0 "the mint above predates it" infobox "$IMPCFG"
|
||
check "info: ...while the artifact's mint time still reads unchanged (#131)" \
|
||
0 "MINTED 2026-07-19T14:22:07Z by box 0.8.0" infobox "$IMPCFG"
|
||
# It must not invent a location for the mint — box has no record of one.
|
||
check "info: ...and never claims the mint happened on another host (#131)" 1 "" \
|
||
info_has "$IMPCFG" 'another host|elsewhere|remote host'
|
||
# A single trip prints ONE line: both pairs hold the same values and a
|
||
# 'first was...' continuation would be noise.
|
||
check "info: a single import prints no redundant 'first was' line (#131)" 1 "" \
|
||
info_has "$IMPCFG" 'the first was'
|
||
|
||
# A box that made the trip more than once shows both ends and the count.
|
||
IMPCFG2="$MWORK/imported-twice.cfg"
|
||
{ cat "$STAMPED"
|
||
echo 'user.box.imported 2026-06-15T08:00:00Z'
|
||
echo 'user.box.imported.by 0.7.0'
|
||
echo 'user.box.imported.last 2026-07-20T09:14:03Z'
|
||
echo 'user.box.imported.last.by 0.8.1'
|
||
echo 'user.box.imported.count 3'; } > "$IMPCFG2"
|
||
check "info: a repeat traveller shows the latest trip (#131)" \
|
||
0 "IMPORTED 2026-07-20T09:14:03Z by box 0.8.1" infobox "$IMPCFG2"
|
||
check "info: ...and the first one, with the count (#131)" \
|
||
0 "import 3 — the first was 2026-06-15T08:00:00Z by box 0.7.0" infobox "$IMPCFG2"
|
||
|
||
# An imported CLONE reads as a clone that also travelled — the two facts sit
|
||
# side by side, neither having eaten the other.
|
||
IMPCLONE="$MWORK/imported-clone.cfg"
|
||
# mode.asked is dropped, not merely unasserted: since #129 the clone path
|
||
# clears it (nobody asked THIS box anything), so a fixture built from the mint
|
||
# shape that kept the key would describe a box the clone path cannot produce.
|
||
# No assertion here reads it — which is exactly why it would rot unnoticed.
|
||
{ grep -v '^user.box.origin ' "$IMPCFG" | grep -v '^user.box.mode.asked '
|
||
echo 'user.box.origin clone'
|
||
echo 'user.box.origin.from work/authed'; } > "$IMPCLONE"
|
||
check "info: an imported clone is still a clone (#131)" \
|
||
0 "ORIGIN clone of work/authed" infobox "$IMPCLONE"
|
||
check "info: ...and still says it was imported (#131)" 0 "IMPORTED" infobox "$IMPCLONE"
|
||
|
||
# A box that was never imported says nothing at all — no empty IMPORTED line,
|
||
# the same rule every other key in the block follows.
|
||
check "info: a never-imported box prints no IMPORTED line (#131)" 1 "" \
|
||
info_has "$STAMPED" '^IMPORTED'
|
||
# And a legacy box with no stamp whatsoever still reads as 'not recorded'.
|
||
check "info: a stampless box still says the mint was not recorded (#131)" \
|
||
0 "predates the mint stamp" infobox "$LEGACY"
|
||
check "info: ...and prints no IMPORTED line for a key it does not have (#131)" 1 "" \
|
||
info_has "$LEGACY" '^IMPORTED'
|
||
|
||
check "help import: names the import record it writes (#131)" 0 "import EVENT" \
|
||
"$BOX" help import
|
||
check "help import: says the mint stamp is NOT rewritten (#131)" 0 "does not overwrite" \
|
||
"$BOX" help import
|
||
|
||
rm -rf "$ISHIM" "$IWORK"
|
||
|
||
rm -rf "$MSHIM" "$MWORK"
|
||
|
||
# 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"
|
||
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 #80 guard and BOX_SUBNET. setup-host run inside a box used to build a
|
||
# nested boxnet on the guest's own uplink subnet — captured gateway, duplicate
|
||
# routes, intermittent egress blackouts. The guard's two pure functions are
|
||
# extracted and DRIVEN (a shim ip serves canned route tables, the same seam as
|
||
# the shim id), and then the WHOLE script is driven end to end under shims:
|
||
# the refusal paths must exit 1 having touched nothing (the incus/sudo shims
|
||
# log every call, and the log must not exist), the converge path must still
|
||
# run, and BOX_SUBNET must plumb through to every derived value.
|
||
# ---------------------------------------------------------------------------
|
||
cat > "$SHIMDIR/ip" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake `ip`: canned tables for the #80 guard and signature — just the reads
|
||
# setup-host and doctor make. Specific patterns first: case takes the first hit.
|
||
case "$*" in
|
||
"-4 -o addr show dev boxnet") printf '%s\n' "${FAKE_IP4_BOXNET:-}" ;;
|
||
"-4 route show default") printf '%s\n' "${FAKE_IP4_DEFAULT:-}" ;;
|
||
"-4 route show") printf '%s\n' "${FAKE_IP4_ROUTES:-}" ;;
|
||
"-4 -o addr show") printf '%s\n' "${FAKE_IP4_ADDRS:-}" ;;
|
||
esac
|
||
exit 0
|
||
SHIM
|
||
chmod +x "$SHIMDIR/ip"
|
||
|
||
# The route tables, verbatim from issue #80's capture (the poisoned guest) and
|
||
# from the states around it.
|
||
D_INBOX='default via 10.88.0.1 dev enp5s0 proto dhcp src 10.88.0.202 metric 1024'
|
||
D_LAN='default via 192.168.1.1 dev eno1 proto dhcp metric 100'
|
||
A_GUEST='2: enp5s0 inet 10.88.0.202/24 metric 1024 brd 10.88.0.255 scope global dynamic enp5s0'
|
||
A_HOSTSTACK='2: eno1 inet 192.168.1.50/24 brd 192.168.1.255 scope global dynamic eno1
|
||
5: boxnet inet 10.88.0.1/24 scope global boxnet'
|
||
A_FOREIGN='2: eno1 inet 192.168.1.50/24 brd 192.168.1.255 scope global dynamic eno1
|
||
3: virbr7 inet 10.88.0.7/24 brd 10.88.0.255 scope global virbr7'
|
||
|
||
SUBFN="$(mktemp)"
|
||
awk '/^valid_subnet\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$SUBFN"
|
||
check "valid_subnet: extracted from setup-host.sh (guards the awk)" 0 "return 1" cat "$SUBFN"
|
||
check "valid_subnet: the extracted function is valid bash" 0 "" bash -n "$SUBFN"
|
||
vsub() { bash -c ". '$SUBFN'; valid_subnet \"\$1\"" _ "$1"; }
|
||
check "valid_subnet: the default is valid" 0 "" vsub 10.88.0.0/24
|
||
check "valid_subnet: the documented escape hatch is valid" 0 "" vsub 10.89.0.0/24
|
||
check "valid_subnet: any a.b.c.0/24 is valid" 0 "" vsub 192.168.7.0/24
|
||
check "valid_subnet: not-a-/24 is refused" 1 "" vsub 10.88.0.0/16
|
||
check "valid_subnet: a nonzero host octet is refused" 1 "" vsub 10.88.0.5/24
|
||
check "valid_subnet: an octet past 255 is refused" 1 "" vsub 300.88.0.0/24
|
||
check "valid_subnet: a bare address is refused" 1 "" vsub 10.88.0.0
|
||
check "valid_subnet: garbage is refused" 1 "" vsub banana
|
||
check "valid_subnet: an empty value is refused" 1 "" vsub ""
|
||
rm -f "$SUBFN"
|
||
|
||
CLMFN="$(mktemp)"
|
||
awk '/^subnet_claimant\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$CLMFN"
|
||
check "subnet_claimant: extracted from setup-host.sh (guards the awk)" 0 "DEFAULT GATEWAY" cat "$CLMFN"
|
||
check "subnet_claimant: the extracted function is valid bash" 0 "" bash -n "$CLMFN"
|
||
claim() { # claim <subnet> <default-route> <addrs>
|
||
FAKE_IP4_DEFAULT="$2" FAKE_IP4_ADDRS="$3" PATH="$SHIMDIR:$PATH" \
|
||
bash -c ". '$CLMFN'; subnet_claimant \"\$1\"" _ "$1"
|
||
}
|
||
check "claimant: the default gateway inside the target is the smoking gun" \
|
||
0 "DEFAULT GATEWAY" claim 10.88.0.0/24 "$D_INBOX" "$A_GUEST"
|
||
check "claimant: a foreign interface inside the target is named" \
|
||
0 "virbr7" claim 10.88.0.0/24 "$D_LAN" "$A_FOREIGN"
|
||
check "claimant: boxnet's own prior claim is the converge path — CLEAN" \
|
||
1 "" claim 10.88.0.0/24 "$D_LAN" "$A_HOSTSTACK"
|
||
check "claimant: a free subnet is clean" \
|
||
1 "" claim 10.89.0.0/24 "$D_LAN" "$A_HOSTSTACK"
|
||
check "claimant: 10.8.0.0/24 does not prefix-match 10.88.x (the dot terminates)" \
|
||
1 "" claim 10.8.0.0/24 "$D_INBOX" "$A_GUEST"
|
||
rm -f "$CLMFN"
|
||
|
||
# --- choose_subnet: the four-case decision, driven case by case -------------
|
||
# 1 explicit pin: honored or refused, never overridden. 2 no pin + bridge:
|
||
# converge to the bridge (the bridge IS the pin) — the scan never runs with a
|
||
# bridge present. 3 no pin, no bridge, default free: default. 4 default
|
||
# claimed: scan 10.89…10.127, first free wins, loudly; refuse when all claimed.
|
||
PICKFN="$(mktemp)"
|
||
awk '/^(valid_subnet|subnet_claimant|choose_subnet)\(\) \{/,/^\}/' \
|
||
"$ROOT/host/setup-host.sh" > "$PICKFN"
|
||
check "choose_subnet: extracted with its helpers (guards the awk)" 0 "auto-picked" cat "$PICKFN"
|
||
check "choose_subnet: subnet_claimant came along" 0 "DEFAULT GATEWAY" cat "$PICKFN"
|
||
check "choose_subnet: the extracted functions are valid bash" 0 "" bash -n "$PICKFN"
|
||
pick() { # pick <pin> <default-route> <addrs> [boxnet-addr]
|
||
FAKE_IP4_DEFAULT="$2" FAKE_IP4_ADDRS="$3" FAKE_IP4_BOXNET="${4:-}" PATH="$SHIMDIR:$PATH" \
|
||
bash -c ". '$PICKFN'; choose_subnet \"\$1\"" _ "$1"
|
||
}
|
||
pickout() { pick "$@" 2>/dev/null; } # stdout only: the choice itself
|
||
pickquiet() { [ -z "$(pick "$@" 2>&1 >/dev/null)" ]; } # stderr must be EMPTY
|
||
picknoscan(){ ! pick "$@" 2>&1 | grep -qF auto-picked; }
|
||
|
||
# The bridge lines and the both-claimed / all-claimed address tables.
|
||
B_88='5: boxnet inet 10.88.0.1/24 scope global boxnet'
|
||
B_89='5: boxnet inet 10.89.0.1/24 scope global boxnet'
|
||
A_TWOCLAIM="$A_GUEST
|
||
3: virbr7 inet 10.89.0.7/24 brd 10.89.0.255 scope global virbr7"
|
||
A_ALLCLAIM="$(for b in $(seq 88 127); do
|
||
printf '%d: virbr%d inet 10.%d.0.7/24 brd 10.%d.0.255 scope global virbr%d\n' \
|
||
"$((b - 85))" "$((b - 87))" "$b" "$b" "$((b - 87))"
|
||
done)"
|
||
|
||
# Case 1 — the pin. Refusals identical in spirit to the pre-autopick gate.
|
||
check "pick: pinned + gw-in-subnet REFUSES, names issue #80" \
|
||
1 "issue #80" pick 10.88.0.0/24 "$D_INBOX" "$A_GUEST"
|
||
check "pick: pinned + foreign interface REFUSES, names it" \
|
||
1 "virbr7" pick 10.88.0.0/24 "$D_LAN" "$A_FOREIGN"
|
||
check "pick: a pinned refusal still names BOX_SUBNET" \
|
||
1 "BOX_SUBNET" pick 10.88.0.0/24 "$D_INBOX" "$A_GUEST"
|
||
check "pick: pinned against a disagreeing bridge REFUSES (never re-addresses)" \
|
||
1 "never re-addresses" pick 10.88.0.0/24 "$D_LAN" "$A_HOSTSTACK" "$B_89"
|
||
check "pick: a garbage pin is refused by name" \
|
||
1 "not a sane subnet" pick banana "$D_LAN" "$A_HOSTSTACK"
|
||
check "pick: a pin that clears the gate is used verbatim" \
|
||
0 "10.89.0.0/24" pickout 10.89.0.0/24 "$D_INBOX" "$A_GUEST"
|
||
check "pick: ...silently — a pin is the operator talking, not us" \
|
||
0 "" pickquiet 10.89.0.0/24 "$D_INBOX" "$A_GUEST"
|
||
|
||
# Case 2 — no pin, a bridge: converge to ITS subnet. No refusal, no scan —
|
||
# even when the default is claimed (THIS machine: nested stack, uplink on
|
||
# 10.88, bridge remapped to 10.89 — the #80 workaround host, bare re-run).
|
||
check "pick: bridge present converges to the bridge's own subnet" \
|
||
0 "10.89.0.0/24" pickout "" "$D_INBOX" "$A_GUEST
|
||
$B_89" "$B_89"
|
||
check "pick: ...announcing the convergence (an off-default bridge is worth a line)" \
|
||
0 "converging" pick "" "$D_INBOX" "$A_GUEST
|
||
$B_89" "$B_89"
|
||
check "pick: ...and the scan never ran (case 2 precedes case 4)" \
|
||
0 "" picknoscan "" "$D_INBOX" "$A_GUEST
|
||
$B_89" "$B_89"
|
||
check "pick: bridge on the DEFAULT subnet converges silently (plain re-run)" \
|
||
0 "" pickquiet "" "$D_LAN" "$A_HOSTSTACK" "$B_88"
|
||
check "pick: ...to the default" \
|
||
0 "10.88.0.0/24" pickout "" "$D_LAN" "$A_HOSTSTACK" "$B_88"
|
||
# The poisoned state (#80 verbatim: bridge AND uplink both on 10.88) must not
|
||
# converge — rebuilding there re-arms the blackouts. Refuse, name the fix.
|
||
check "pick: a bridge on a FOREIGN-claimed subnet refuses (the poisoned state)" \
|
||
1 "poisoned" pick "" "$D_INBOX" "$A_GUEST
|
||
$B_88" "$B_88"
|
||
check "pick: ...naming the bridge move as the fix" \
|
||
1 "ipv4.address" pick "" "$D_INBOX" "$A_GUEST
|
||
$B_88" "$B_88"
|
||
|
||
# Case 3 — no pin, no bridge, default free: the default, silently.
|
||
check "pick: a free default host gets 10.88.0.0/24" \
|
||
0 "10.88.0.0/24" pickout "" "$D_LAN" ""
|
||
check "pick: ...with no announcement" 0 "" pickquiet "" "$D_LAN" ""
|
||
|
||
# Case 4 — no pin, no bridge, default claimed: the nested case. First free
|
||
# candidate wins, the announcement names the claimant and the pin.
|
||
check "pick: default claimed by the gateway auto-picks 10.89.0.0/24" \
|
||
0 "10.89.0.0/24" pickout "" "$D_INBOX" "$A_GUEST"
|
||
check "pick: ...saying so loudly" \
|
||
0 "auto-picked 10.89.0.0/24" pick "" "$D_INBOX" "$A_GUEST"
|
||
check "pick: ...naming WHY (the machine's own gateway = inside a box)" \
|
||
0 "DEFAULT GATEWAY" pick "" "$D_INBOX" "$A_GUEST"
|
||
check "pick: ...and how to pin it for scripts" \
|
||
0 "BOX_SUBNET=10.89.0.0/24" pick "" "$D_INBOX" "$A_GUEST"
|
||
check "pick: default AND 10.89 claimed skips to 10.90.0.0/24" \
|
||
0 "10.90.0.0/24" pickout "" "$D_INBOX" "$A_TWOCLAIM"
|
||
check "pick: every candidate claimed → the old refusal" \
|
||
1 "refusing to build boxnet" pick "" "$D_LAN" "$A_ALLCLAIM"
|
||
check "pick: ...naming the end of the scan range" \
|
||
1 "10.127.0.0/24" pick "" "$D_LAN" "$A_ALLCLAIM"
|
||
check "pick: ...and BOX_SUBNET as the way out" \
|
||
1 "BOX_SUBNET" pick "" "$D_LAN" "$A_ALLCLAIM"
|
||
rm -f "$PICKFN"
|
||
|
||
# --- the whole script, driven: refuse-before-mutation, converge, plumb-through
|
||
SETUPSHIM="$(mktemp -d)"
|
||
cat > "$SETUPSHIM/incus" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake incus for the driven setup-host: records every call (and, for the
|
||
# stdin verbs, the stdin) to $FAKE_INCUS_LOG, answers the existence probes
|
||
# from FAKE_HAVE_*, and never goes near a daemon.
|
||
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
||
case "$*" in
|
||
*"admin init --preseed"*|*"acl edit"*|*"profile edit"*)
|
||
if [ -n "${FAKE_INCUS_LOG:-}" ]; then sed 's/^/ | /' >> "$FAKE_INCUS_LOG"; else cat >/dev/null; fi ;;
|
||
esac
|
||
case "$*" in
|
||
"storage show default") [ -n "${FAKE_HAVE_STORAGE:-}" ] || exit 1 ;;
|
||
"network show boxnet") [ -n "${FAKE_HAVE_BOXNET:-}" ] || exit 1 ;;
|
||
"network acl show box-isolate") [ -n "${FAKE_HAVE_ACL:-}" ] || exit 1 ;;
|
||
"profile show box-net") [ -n "${FAKE_HAVE_PROFILE:-}" ] || exit 1 ;;
|
||
esac
|
||
exit 0
|
||
SHIM
|
||
cat > "$SETUPSHIM/sudo" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake sudo: logs to $FAKE_SUDO_LOG and swallows everything — the driven
|
||
# setup-host must never mutate the machine running this suite.
|
||
[ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG"
|
||
exit 0
|
||
SHIM
|
||
chmod +x "$SETUPSHIM/incus" "$SETUPSHIM/sudo"
|
||
|
||
runsetup() { # runsetup [VAR=val ...] — the real setup-host, under shims
|
||
env FAKE_UID=1000 FAKE_GROUPS="users incus-admin" \
|
||
PATH="$SETUPSHIM:$SHIMDIR:$PATH" "$@" bash "$ROOT/host/setup-host.sh"
|
||
}
|
||
|
||
W80="$(mktemp -d)"
|
||
# Refusal 1: an EXPLICIT pin on the subnet the default gateway sits inside —
|
||
# the inside of a box, and the operator said 10.88 out loud. A pin is never
|
||
# silently overridden, so this refuses exactly as it did pre-autopick.
|
||
check "setup-host: a pinned gw-claimed subnet REFUSES and names issue #80" 1 "issue #80" \
|
||
runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \
|
||
FAKE_INCUS_LOG="$W80/g1.log" FAKE_SUDO_LOG="$W80/s1.log"
|
||
check "setup-host: ...naming BOX_SUBNET as the way out" 1 "BOX_SUBNET" \
|
||
runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST"
|
||
check "setup-host: the refusal made NO incus call (refuse precedes mutation)" 1 "" \
|
||
test -e "$W80/g1.log"
|
||
check "setup-host: the refusal made NO sudo call either" 1 "" \
|
||
test -e "$W80/s1.log"
|
||
# Refusal 2: a pin on a subnet a foreign interface owns an address inside.
|
||
check "setup-host: a pinned foreign-claimed subnet REFUSES" 1 "virbr7" \
|
||
runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_FOREIGN"
|
||
# Refusal 3: garbage BOX_SUBNET dies at the gate.
|
||
check "setup-host: a garbage BOX_SUBNET is refused by name" 1 "not a sane subnet" \
|
||
runsetup BOX_SUBNET=banana
|
||
check "setup-host: a /16 BOX_SUBNET is refused" 1 "not a sane subnet" \
|
||
runsetup BOX_SUBNET=10.88.0.0/16
|
||
# Refusal 4: an existing bridge on ANOTHER subnet is never re-addressed.
|
||
check "setup-host: a bridge on another subnet refuses (converge, don't re-address)" \
|
||
1 "never re-addresses" \
|
||
runsetup FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_HOSTSTACK" \
|
||
FAKE_IP4_BOXNET='5: boxnet inet 10.89.0.1/24 scope global boxnet' \
|
||
BOX_SUBNET=10.88.0.0/24
|
||
# The legitimate re-run: boxnet itself owns the subnet — setup-host converges.
|
||
check "setup-host: a prior boxnet claiming the subnet CONVERGES (no false positive)" \
|
||
0 "Host ready" \
|
||
runsetup FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_HOSTSTACK" \
|
||
FAKE_IP4_BOXNET='5: boxnet inet 10.88.0.1/24 scope global boxnet' \
|
||
FAKE_HAVE_STORAGE=1 FAKE_HAVE_BOXNET=1 FAKE_HAVE_ACL=1 FAKE_HAVE_PROFILE=1
|
||
# BOX_SUBNET plumbs through: a fresh build on 10.89.0.0/24 must derive EVERY
|
||
# value from it — the bridge address and the ACL's gateway carve-out.
|
||
check "setup-host: BOX_SUBNET drives a fresh build to completion" 0 "Host ready" \
|
||
runsetup BOX_SUBNET=10.89.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \
|
||
FAKE_INCUS_LOG="$W80/g2.log" FAKE_SUDO_LOG="$W80/s2.log"
|
||
check "setup-host: ...the bridge derives from BOX_SUBNET" 0 "" \
|
||
grep -qF 'network create boxnet ipv4.address=10.89.0.1/24' "$W80/g2.log"
|
||
check "setup-host: ...and so does the ACL's gateway carve-out" 0 "" \
|
||
grep -qF 'destination: 10.89.0.1/32' "$W80/g2.log"
|
||
# The nested case with ZERO flags — #80's tables, no pin, no bridge: the
|
||
# auto-pick must land the whole build on 10.89, announced, and every derived
|
||
# value must follow the pick, not the default.
|
||
check "setup-host: nested with no flags auto-picks and completes" 0 "Host ready" \
|
||
runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \
|
||
FAKE_INCUS_LOG="$W80/g3.log" FAKE_SUDO_LOG="$W80/s3.log"
|
||
check "setup-host: ...announcing the auto-pick" 0 "auto-picked 10.89.0.0/24" \
|
||
runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST"
|
||
check "setup-host: ...the bridge follows the pick" 0 "" \
|
||
grep -qF 'network create boxnet ipv4.address=10.89.0.1/24' "$W80/g3.log"
|
||
check "setup-host: ...the ACL carve-out follows the pick" 0 "" \
|
||
grep -qF 'destination: 10.89.0.1/32' "$W80/g3.log"
|
||
rm -rf "$W80" "$SETUPSHIM"
|
||
|
||
# The decision must be the FIRST effective act — before the incus install, the
|
||
# usermod, every apt call. Line order, fail-closed on either grep missing.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "setup-host: the subnet decision precedes the first mutation" 0 "" bash -c '
|
||
guard="$(grep -n "^BOX_SUBNET=\"\$(choose_subnet " "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
||
mut="$(grep -n "^if ! command -v incus" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
||
[ -n "$guard" ] && [ -n "$mut" ] && [ "$guard" -lt "$mut" ]'
|
||
# box-firewall follows the bridge, wherever BOX_SUBNET put it.
|
||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||
check "box-firewall: the gateway is read off the live bridge, not hardcoded" 0 "" \
|
||
grep -qF 'addr show dev "$NET"' "$ROOT/host/box-firewall.sh"
|
||
# The drill and migrate probes derive the prefix from the network — a
|
||
# BOX_SUBNET host must not fail its own rehearsals.
|
||
check "drill: derives the boxnet prefix from the network" 0 "" \
|
||
grep -qF 'network get boxnet ipv4.address' "$ROOT/drill/drill.sh"
|
||
check "multiuser: derives the boxnet prefix from the network" 0 "" \
|
||
grep -qF 'network get boxnet ipv4.address' "$ROOT/drill/multiuser.sh"
|
||
check "migrate-host: derives the boxnet prefix from the network" 0 "" \
|
||
grep -qF 'network get boxnet ipv4.address' "$ROOT/host/migrate-host.sh"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The doctor's #80 signature. gw_squat_signature is pure text → findings, so
|
||
# it is extracted and driven against synthetic route tables — including the
|
||
# EXACT poisoned state from the issue, the workaround state (bridge remapped:
|
||
# clean), and a healthy host running the stack (clean).
|
||
# ---------------------------------------------------------------------------
|
||
SIGFN="$(mktemp)"
|
||
awk '/^gw_squat_signature\(\) \{/,/^\}/' "$ROOT/drill/doctor.sh" > "$SIGFN"
|
||
check "gw_squat_signature: extracted from doctor.sh (guards the awk)" 0 "default" cat "$SIGFN"
|
||
check "gw_squat_signature: the extracted function is valid bash" 0 "" bash -n "$SIGFN"
|
||
sig() { bash -c ". '$SIGFN'; gw_squat_signature \"\$1\" \"\$2\"" _ "$1" "$2"; }
|
||
nosig() { [ -z "$(sig "$1" "$2")" ]; }
|
||
|
||
# The poisoned guest, verbatim from #80: gateway held locally AND duplicated
|
||
# connected routes for the uplink subnet.
|
||
R_POISON="$D_INBOX
|
||
10.88.0.0/24 dev boxnet proto kernel scope link src 10.88.0.1 linkdown
|
||
10.88.0.0/24 dev enp5s0 proto kernel scope link src 10.88.0.202 metric 1024
|
||
10.88.0.1 dev enp5s0 proto dhcp scope link src 10.88.0.202 metric 1024"
|
||
A_POISON="$A_GUEST
|
||
17: boxnet inet 10.88.0.1/24 scope global boxnet"
|
||
check "signature: poisoned guest — the gateway is held as a LOCAL address" \
|
||
0 "held as a LOCAL address" sig "$R_POISON" "$A_POISON"
|
||
check "signature: poisoned guest — duplicate connected routes for the uplink" \
|
||
0 "duplicate connected routes" sig "$R_POISON" "$A_POISON"
|
||
# The workaround state (#80's fix: bridge remapped off the uplink subnet) —
|
||
# both signature lines must be ABSENT.
|
||
R_REMAP="$D_INBOX
|
||
10.88.0.0/24 dev enp5s0 proto kernel scope link src 10.88.0.202 metric 1024
|
||
10.88.0.1 dev enp5s0 proto dhcp scope link src 10.88.0.202 metric 1024
|
||
10.89.0.0/24 dev boxnet proto kernel scope link src 10.89.0.1 linkdown"
|
||
A_REMAP="$A_GUEST
|
||
17: boxnet inet 10.89.0.1/24 scope global boxnet"
|
||
check "signature: the remapped-bridge workaround is CLEAN" 0 "" nosig "$R_REMAP" "$A_REMAP"
|
||
# A healthy HOST running the stack: boxnet legitimately owns its subnet, and
|
||
# the uplink is elsewhere — clean, or every host would cry wolf.
|
||
R_HOST="$D_LAN
|
||
192.168.1.0/24 dev eno1 proto kernel scope link src 192.168.1.50
|
||
10.88.0.0/24 dev boxnet proto kernel scope link src 10.88.0.1"
|
||
check "signature: a healthy host running the stack is CLEAN" 0 "" nosig "$R_HOST" "$A_HOSTSTACK"
|
||
check "signature: no default route → nothing to judge (clean)" 0 "" \
|
||
nosig "10.88.0.0/24 dev boxnet proto kernel scope link src 10.88.0.1" "$A_HOSTSTACK"
|
||
# Each line fires on its own: a captured gateway without duplicate routes...
|
||
R_GWONLY="$D_INBOX
|
||
10.88.0.0/24 dev enp5s0 proto kernel scope link src 10.88.0.202 metric 1024"
|
||
check "signature: a captured gateway alone still fires" \
|
||
0 "held as a LOCAL address" sig "$R_GWONLY" "$A_POISON"
|
||
# ...and duplicate routes without the gateway captured (nested bridge on .5).
|
||
A_DUPONLY="$A_GUEST
|
||
17: boxnet inet 10.88.0.5/24 scope global boxnet"
|
||
check "signature: duplicate routes alone still fire" \
|
||
0 "duplicate connected routes" sig "$R_POISON" "$A_DUPONLY"
|
||
rm -f "$SIGFN"
|
||
|
||
# The wiring: the signature is judged on THIS machine before any daemon call
|
||
# (the daemon answering could be the nested impostor), probed INSIDE boxes on
|
||
# both tiers, and the egress-broken-DNS-fine split names the fingerprint.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "doctor: this machine's signature precedes the daemon checks" 0 "" bash -c '
|
||
sig="$(grep -n "is a nested box stack squatting" "'"$ROOT"'/drill/doctor.sh" | head -1 | cut -d: -f1)"
|
||
daemon="$(grep -n "timeout 10 incus list" "'"$ROOT"'/drill/doctor.sh" | head -1 | cut -d: -f1)"
|
||
[ -n "$sig" ] && [ -n "$daemon" ] && [ "$sig" -lt "$daemon" ]'
|
||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||
check "doctor: the signature is probed inside boxes on BOTH tiers" 0 "" bash -c '
|
||
[ "$(grep -c "probe_sig \"\$probe\"" "'"$ROOT"'/drill/doctor.sh")" -eq 2 ]'
|
||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||
check "doctor: the egress-broken-DNS-fine fingerprint is named on both tiers" 0 "" bash -c '
|
||
[ "$(grep -c "fingerprint" "'"$ROOT"'/drill/doctor.sh")" -ge 2 ]'
|
||
check "doctor: the ACL carve-out is checked against the live gateway" 0 "" \
|
||
grep -qF "does NOT match boxnet's gateway" "$ROOT/drill/doctor.sh"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# box-firewall's UFW converge and the fail-closed boot window (#86 review,
|
||
# items 1–2). The whole script is DRIVEN under shims (the setup-host seam):
|
||
# a fake ufw serves canned `ufw status` tables and logs every mutation, fake
|
||
# nft/sysctl/iptables swallow the rest, and the shim ip answers the
|
||
# live-bridge read. Stale gateway allows must converge to the live gateway,
|
||
# a fresh UFW host must get exactly the rule set it always did, a no-UFW
|
||
# host must keep its nft path, and the no-bridge-address boot window must
|
||
# mutate NOTHING — the old GW=10.88.0.1 fallback built the carve-out for
|
||
# the wrong gateway on every BOX_SUBNET host that hit it.
|
||
# ---------------------------------------------------------------------------
|
||
FWSHIM="$(mktemp -d)"; UFWSHIM="$(mktemp -d)"; WFW="$(mktemp -d)"
|
||
cat > "$UFWSHIM/ufw" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake ufw: 'status' prints $FAKE_UFW_STATUS; every call is logged to
|
||
# $FAKE_UFW_LOG. Mutations mutate nothing, of course.
|
||
[ -n "${FAKE_UFW_LOG:-}" ] && printf 'ufw %s\n' "$*" >> "$FAKE_UFW_LOG"
|
||
case "${1:-}" in status) printf '%s\n' "${FAKE_UFW_STATUS:-Status: inactive}" ;; esac
|
||
exit 0
|
||
SHIM
|
||
cat > "$FWSHIM/nft" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake nft: logs to $FAKE_NFT_LOG. The bridge-table probe answers "absent"
|
||
# so the creation path runs (and is logged) instead of being skipped.
|
||
[ -n "${FAKE_NFT_LOG:-}" ] && printf 'nft %s\n' "$*" >> "$FAKE_NFT_LOG"
|
||
case "$*" in "list table bridge box") exit 1 ;; esac
|
||
exit 0
|
||
SHIM
|
||
cat > "$FWSHIM/sysctl" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
exit 0
|
||
SHIM
|
||
cat > "$FWSHIM/iptables" <<'SHIM'
|
||
#!/usr/bin/env bash
|
||
# Fake iptables: the DOCKER-USER probe answers "no such chain", so the
|
||
# docker block is deterministically skipped whether or not this runner
|
||
# happens to have docker.
|
||
exit 1
|
||
SHIM
|
||
chmod +x "$UFWSHIM/ufw" "$FWSHIM/nft" "$FWSHIM/sysctl" "$FWSHIM/iptables"
|
||
|
||
runfw() { # runfw <ufw|noufw> [VAR=val ...] — the real box-firewall, under shims
|
||
local mode="$1" p rc=0; shift
|
||
p="$FWSHIM:$SHIMDIR:$PATH"
|
||
[ "$mode" = ufw ] && p="$UFWSHIM:$p"
|
||
# Stderr is captured to a file AND re-emitted, rather than only passed
|
||
# through. The driving `check` swallows the output of a run that passes, so
|
||
# when a later grep over the log fails there is nothing left to read — which
|
||
# is precisely the hole #102 fell into. Keeping a copy on disk lets
|
||
# fwlog_ready below show what the run actually said. Overwritten per call by
|
||
# design: every fwlog_ready sits immediately after its own runfw, so "the
|
||
# last run" is always the run being diagnosed.
|
||
env PATH="$p" "$@" bash "$ROOT/host/box-firewall.sh" 2>"$WFW/last-run.err" || rc=$?
|
||
cat "$WFW/last-run.err" >&2
|
||
return "$rc"
|
||
}
|
||
|
||
# fwlog_ready <log> — the shimmed ufw actually logged mutations to <log>.
|
||
#
|
||
# Why this exists (#102): every grep in the blocks below reads a log written by
|
||
# the shimmed ufw during the driving `runfw` check. When something stops the
|
||
# UFW branch of box-firewall.sh from running at all, that log is missing — or,
|
||
# as it turned out, present but holding nothing except the `ufw status` probe.
|
||
# The greps then fail four-at-a-time with empty output: a signature that looks
|
||
# alarmingly specific and carries no information whatsoever. #102 was filed
|
||
# reading it as "the log is not written", which was a reasonable inference from
|
||
# four blank failures and was also wrong; the file was there, the mutations
|
||
# were not, and that distinction is the entire diagnosis. So assert the
|
||
# precondition explicitly, before the content greps, and on failure print what
|
||
# IS in $WFW, what the log itself holds, and what the run wrote to stderr. The
|
||
# fix below should mean this never fires — it is here for the next cause, not
|
||
# this one, and its whole job is to hand over the evidence instead of making
|
||
# the next person re-derive it from a re-run loop.
|
||
fwlog_ready() {
|
||
local log="$1" muts
|
||
if [ -f "$log" ]; then
|
||
muts="$(grep -vc "^ufw status" "$log")"
|
||
[ "$muts" -gt 0 ] && return 0
|
||
echo "DIAGNOSIS: $log exists but logs no ufw MUTATION (only 'ufw status')."
|
||
echo " => box-firewall.sh took its no-UFW branch; the UFW carve-out never ran."
|
||
else
|
||
echo "DIAGNOSIS: $log does not exist — the shimmed ufw was never invoked."
|
||
fi
|
||
echo " \$WFW ($WFW) holds:"
|
||
# shellcheck disable=SC2012 # a human-read diagnostic dump, not parsed: `ls -la`
|
||
# shows sizes and mtimes, which is the whole point here (a zero-byte log and a
|
||
# log that was never created are different failures). $WFW is our own mktemp -d.
|
||
ls -la "$WFW" 2>&1 | sed 's/^/ /'
|
||
echo " contents of $(basename "$log"):"
|
||
{ [ -f "$log" ] && cat "$log" || echo "(absent)"; } 2>&1 | sed 's/^/ /'
|
||
echo " stderr of the run that should have written it:"
|
||
{ [ -s "$WFW/last-run.err" ] && cat "$WFW/last-run.err" || echo "(empty)"; } 2>&1 | sed 's/^/ /'
|
||
return 1
|
||
}
|
||
|
||
# Canned `ufw status` tables, modeled on the real output shape.
|
||
U_HDR='Status: active
|
||
|
||
To Action From
|
||
-- ------ ----
|
||
22/tcp ALLOW Anywhere'
|
||
U_FRESH="$U_HDR"
|
||
U_OLDGW="$U_HDR
|
||
Anywhere on boxnet DENY Anywhere
|
||
10.88.0.1 53/tcp on boxnet ALLOW Anywhere
|
||
10.88.0.1 53/udp on boxnet ALLOW Anywhere
|
||
67/udp on boxnet ALLOW Anywhere
|
||
Anywhere on boxnet ALLOW FWD Anywhere"
|
||
U_LIVEGW="$U_HDR
|
||
Anywhere on boxnet DENY Anywhere
|
||
10.89.0.1 53/tcp on boxnet ALLOW Anywhere
|
||
10.89.0.1 53/udp on boxnet ALLOW Anywhere
|
||
67/udp on boxnet ALLOW Anywhere
|
||
Anywhere on boxnet ALLOW FWD Anywhere"
|
||
BX88='5: boxnet inet 10.88.0.1/24 scope global boxnet'
|
||
BX89='5: boxnet inet 10.89.0.1/24 scope global boxnet'
|
||
|
||
# The remapped host (#80's escape hatch): bridge on 10.89, UFW still carrying
|
||
# 10.88's carve-out — the stale allows go, the live gateway's land.
|
||
check "box-firewall: a remapped bridge CONVERGES the UFW carve-out" 0 "" \
|
||
runfw ufw FAKE_IP4_BOXNET="$BX89" FAKE_UFW_STATUS="$U_OLDGW" FAKE_UFW_LOG="$WFW/remap.log"
|
||
check "box-firewall: ...the run logged ufw mutations at all" 0 "" fwlog_ready "$WFW/remap.log"
|
||
check "box-firewall: ...the stale tcp allow is deleted" 0 "" \
|
||
grep -qF 'ufw delete allow in on boxnet to 10.88.0.1 port 53 proto tcp' "$WFW/remap.log"
|
||
check "box-firewall: ...and the stale udp allow" 0 "" \
|
||
grep -qF 'ufw delete allow in on boxnet to 10.88.0.1 port 53 proto udp' "$WFW/remap.log"
|
||
check "box-firewall: ...the live gateway gains its tcp allow" 0 "" \
|
||
grep -qF 'ufw insert 1 allow in on boxnet to 10.89.0.1 port 53 proto tcp' "$WFW/remap.log"
|
||
check "box-firewall: ...and its udp allow" 0 "" \
|
||
grep -qF 'ufw insert 1 allow in on boxnet to 10.89.0.1 port 53 proto udp' "$WFW/remap.log"
|
||
check "box-firewall: ...the live gateway's rules are never deleted" 1 "" \
|
||
grep -qF 'delete allow in on boxnet to 10.89.0.1' "$WFW/remap.log"
|
||
|
||
# The agreeing host: rules already match the live gateway — nothing deleted
|
||
# (ufw itself skips the re-adds as existing rules).
|
||
check "box-firewall: an agreeing UFW host deletes nothing" 0 "" \
|
||
runfw ufw FAKE_IP4_BOXNET="$BX89" FAKE_UFW_STATUS="$U_LIVEGW" FAKE_UFW_LOG="$WFW/agree.log"
|
||
# This one matters more than it looks: "no delete was issued" is an ASSERT-ABSENT
|
||
# check, so a run that issued nothing at all passes it for the wrong reason.
|
||
# fwlog_ready is what keeps the absence meaningful.
|
||
check "box-firewall: ...the run logged ufw mutations at all" 0 "" fwlog_ready "$WFW/agree.log"
|
||
check "box-firewall: ...no delete was issued" 1 "" grep -qF ' delete ' "$WFW/agree.log"
|
||
|
||
# The fresh host: no boxnet rules yet — exactly the five historical commands,
|
||
# aimed at the live gateway, and nothing else (unchanged behavior).
|
||
check "box-firewall: a fresh UFW host runs clean" 0 "" \
|
||
runfw ufw FAKE_IP4_BOXNET="$BX88" FAKE_UFW_STATUS="$U_FRESH" FAKE_UFW_LOG="$WFW/fresh.log"
|
||
check "box-firewall: ...the run logged ufw mutations at all" 0 "" fwlog_ready "$WFW/fresh.log"
|
||
check "box-firewall: ...the deny lands" 0 "" \
|
||
grep -qF 'ufw insert 1 deny in on boxnet' "$WFW/fresh.log"
|
||
check "box-firewall: ...the DNS allows aim at the live gateway" 0 "" \
|
||
grep -qF 'ufw insert 1 allow in on boxnet to 10.88.0.1 port 53 proto tcp' "$WFW/fresh.log"
|
||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||
check "box-firewall: ...DHCP and the route allow land too" 0 "" bash -c '
|
||
grep -qF "ufw insert 1 allow in on boxnet to any port 67 proto udp" "$1" &&
|
||
grep -qF "ufw route allow in on boxnet" "$1"' _ "$WFW/fresh.log"
|
||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||
check "box-firewall: ...exactly the five historical mutations, no more" 0 "" \
|
||
bash -c '[ "$(grep -vc "^ufw status" "$1")" -eq 5 ]' _ "$WFW/fresh.log"
|
||
|
||
# The boot window (#86 review item 2): bridge not yet addressed → NO guessed
|
||
# gateway, NO mutation at all — the persisted rules are left exactly as they
|
||
# are, and the skip says so. (The old fallback built 10.88.0.1 rules on a
|
||
# BOX_SUBNET host here — a latent DNS drop.)
|
||
check "box-firewall: an unaddressed bridge FAILS CLOSED on a UFW host" 0 "left as-is" \
|
||
runfw ufw FAKE_IP4_BOXNET= FAKE_UFW_STATUS="$U_OLDGW" FAKE_UFW_LOG="$WFW/boot.log"
|
||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||
check "box-firewall: ...not one ufw mutation was issued" 0 "" \
|
||
bash -c '[ "$(grep -vc "^ufw status" "$1")" -eq 0 ]' _ "$WFW/boot.log"
|
||
check "box-firewall: the hardcoded gateway fallback is GONE (comments aside)" 1 "" \
|
||
grep -qE '^[^#]*GW=10' "$ROOT/host/box-firewall.sh"
|
||
|
||
# The no-UFW host: untouched semantics — the nft input carve-out is
|
||
# interface-scoped, so it needs no gateway and applies even in the boot
|
||
# window where the UFW path now declines to guess.
|
||
check "box-firewall: a no-UFW host keeps its nft path" 0 "" \
|
||
runfw noufw FAKE_IP4_BOXNET="$BX89" FAKE_NFT_LOG="$WFW/nft.log"
|
||
check "box-firewall: ...the DNS/DHCP accept is interface-scoped" 0 "" \
|
||
grep -qF 'add rule inet box input iifname boxnet udp dport { 53, 67 } accept' "$WFW/nft.log"
|
||
check "box-firewall: ...and the input drop lands" 0 "" \
|
||
grep -qF 'add rule inet box input iifname boxnet drop' "$WFW/nft.log"
|
||
check "box-firewall: the nft path survives the boot window too" 0 "" \
|
||
runfw noufw FAKE_IP4_BOXNET= FAKE_NFT_LOG="$WFW/nftboot.log"
|
||
check "box-firewall: ...with the same interface-scoped carve-out" 0 "" \
|
||
grep -qF 'add rule inet box input iifname boxnet udp dport { 53, 67 } accept' "$WFW/nftboot.log"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The doctor's UFW blind spot (#86 review item 1, second half): the ACL
|
||
# check alone gave a remapped UFW host a clean bill while the stale UFW
|
||
# allow dropped box DNS. ufw_dns_findings is pure text → findings, the
|
||
# gw_squat_signature seam: extracted and driven against canned tables.
|
||
# ---------------------------------------------------------------------------
|
||
UFWFN="$(mktemp)"
|
||
awk '/^ufw_dns_findings\(\) \{/,/^\}/' "$ROOT/drill/doctor.sh" > "$UFWFN"
|
||
check "ufw_dns_findings: extracted from doctor.sh (guards the awk)" 0 "DNS allow" cat "$UFWFN"
|
||
check "ufw_dns_findings: the extracted function is valid bash" 0 "" bash -n "$UFWFN"
|
||
ufwsig() { bash -c ". '$UFWFN'; ufw_dns_findings \"\$1\" \"\$2\" \"\$3\"" _ "$1" "$2" "$3"; }
|
||
noufwsig() { [ -z "$(ufwsig "$1" "$2" "$3")" ]; }
|
||
|
||
check "ufw findings: agreement is SILENT" 0 "" noufwsig "$U_LIVEGW" boxnet 10.89.0.1
|
||
check "ufw findings: a stale carve-out is flagged as NOT the live gateway" \
|
||
0 "NOT boxnet's live gateway" ufwsig "$U_OLDGW" boxnet 10.89.0.1
|
||
check "ufw findings: ...naming the address it points at" \
|
||
0 "10.88.0.1" ufwsig "$U_OLDGW" boxnet 10.89.0.1
|
||
# Our deny with no DNS allow at all is a drop — say so.
|
||
U_DENYONLY="$U_HDR
|
||
Anywhere on boxnet DENY Anywhere"
|
||
check "ufw findings: a deny with NO DNS allow is a drop" \
|
||
0 "NO DNS allow" ufwsig "$U_DENYONLY" boxnet 10.89.0.1
|
||
# A UFW host box-firewall never touched has nothing to judge — clean.
|
||
check "ufw findings: an untouched UFW host is CLEAN" 0 "" noufwsig "$U_FRESH" boxnet 10.89.0.1
|
||
# A stale allow left BESIDE the live one still gets named (residue, not a drop).
|
||
U_BOTH="$U_LIVEGW
|
||
10.88.0.1 53/tcp on boxnet ALLOW Anywhere"
|
||
check "ufw findings: a stale allow beside the live one is named" \
|
||
0 "stale UFW DNS allow" ufwsig "$U_BOTH" boxnet 10.89.0.1
|
||
# Rules on OTHER interfaces are not boxnet's problem.
|
||
U_OTHERIF="$U_LIVEGW
|
||
10.88.0.1 53/tcp on eth0 ALLOW Anywhere"
|
||
check "ufw findings: another interface's DNS allow is ignored" 0 "" \
|
||
noufwsig "$U_OTHERIF" boxnet 10.89.0.1
|
||
|
||
# The wiring: doctor judges UFW's own table where UFW is active, and the fix
|
||
# points at the converging box-firewall.
|
||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||
check "doctor: reads UFW's table through ufw_dns_findings" 0 "" \
|
||
grep -qF 'ufw_dns_findings "$ufw_out"' "$ROOT/drill/doctor.sh"
|
||
check "doctor: the UFW fix names the converge" 0 "" \
|
||
grep -qF 'converges the UFW allows' "$ROOT/drill/doctor.sh"
|
||
rm -f "$UFWFN"; rm -rf "$FWSHIM" "$UFWSHIM" "$WFW"
|
||
|
||
# The docs keep the new promises.
|
||
check "help setup-host names BOX_SUBNET" 0 "BOX_SUBNET" "$BOX" help setup-host
|
||
check "help setup-host names the refusal" 0 "REFUSES" "$BOX" help setup-host
|
||
check "help doctor names the #80 signature" 0 "#80" "$BOX" help doctor
|
||
check "README documents BOX_SUBNET" 0 "" grep -qF 'BOX_SUBNET' "$ROOT/README.md"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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" "$SRC9/host"
|
||
cp "$ROOT/bin/box" "$SRC9/bin/box"; chmod +x "$SRC9/bin/box"
|
||
echo "9.9.9-drill" > "$SRC9/VERSION"
|
||
# A stub host/setup-host.sh that only announces itself: enough to prove WHETHER
|
||
# the installer ran host setup, and from WHICH version's tree, with no Incus and
|
||
# no root. The real script builds the isolation stack; this one echoes (#115).
|
||
cat > "$SRC9/host/setup-host.sh" <<'STUB'
|
||
#!/usr/bin/env bash
|
||
echo "SETUP-HOST-RAN-FROM 9.9.9-drill"
|
||
STUB
|
||
chmod +x "$SRC9/host/setup-host.sh"
|
||
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"
|
||
}
|
||
inst_setup() { # like inst, but WITHOUT BOX_SKIP_SETUP_HOST — host setup is the
|
||
# thing under test, so the switch that suppresses it has to come off. Safe
|
||
# offline: the only setup-host on these fabricated sources is the echo stub
|
||
# above, and BOX_YES=1 answers its prompt.
|
||
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_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
|
||
# 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
|
||
|
||
# #117: the migration is not silent about the entry it manufactured. The old
|
||
# tree is now a first-class 'box versions' row the operator never installed —
|
||
# so the output has to name the way back out (uninstall) and the reason to
|
||
# keep it (rollback), at the migration AND again in the closing summary, which
|
||
# is the half an operator scrolling ~250 lines of install output actually sees.
|
||
H3B="$WORK/h3b"; B3B="$WORK/b3b"; mkdir -p "$H3B/bin" "$B3B"
|
||
cp "$ROOT/bin/box" "$H3B/bin/box"; chmod +x "$H3B/bin/box"
|
||
cp "$ROOT/VERSION" "$H3B/VERSION"
|
||
ln -s "$H3B/bin/box" "$B3B/box"
|
||
mig_out="$WORK/mig-out.txt"
|
||
inst "$H3B" "$B3B" BOX_INSTALL_SOURCE="$SRC9" >"$mig_out" 2>&1 || true
|
||
check "migrate: the output points at the reap command (#117)" 0 "box uninstall $VER" \
|
||
cat "$mig_out"
|
||
check "migrate: ...and names keeping it as a rollback target (#117)" 0 "keep it to roll back" \
|
||
cat "$mig_out"
|
||
check "migrate: ...and the closing summary re-states it (#117)" 0 "was migrated to versions/$VER" \
|
||
cat "$mig_out"
|
||
# ...and the note is conditional: an install with nothing to migrate must not
|
||
# mention a migration at all. grep exits 1 when the string is absent, which is
|
||
# the pass here.
|
||
nomig_out="$WORK/nomig-out.txt"
|
||
H3C="$WORK/h3c"; B3C="$WORK/b3c"
|
||
inst "$H3C" "$B3C" >"$nomig_out" 2>&1 || true
|
||
check "migrate: a NON-migrating install stays silent about migration (#117)" 1 "" \
|
||
grep -qF "was migrated to versions/" "$nomig_out"
|
||
|
||
# ...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
|
||
|
||
# #115, end to end and fully offline: a flat pre-0.7.0 tree must still count as
|
||
# "no install yet" and RUN host setup. The migration converts the flat tree into
|
||
# versions/<v>, which is precisely what used to make had_install read 1 — the
|
||
# host then skipped setup-host while 'box --version' reported the new release,
|
||
# leaving every host-side artifact (box-firewall, #102) at the old one. The stub
|
||
# setup-host echoes a marker, so the marker IS the proof it ran.
|
||
H4B="$WORK/h4b"; B4B="$WORK/b4b"; mkdir -p "$H4B/bin" "$B4B"
|
||
cp "$ROOT/bin/box" "$H4B/bin/box"; chmod +x "$H4B/bin/box"
|
||
cp "$ROOT/VERSION" "$H4B/VERSION"
|
||
ln -s "$H4B/bin/box" "$B4B/box"
|
||
check "flat upgrade: host setup RUNS over a migrated flat tree (#115)" 0 "SETUP-HOST-RAN-FROM 9.9.9-drill" \
|
||
inst_setup "$H4B" "$B4B" BOX_INSTALL_SOURCE="$SRC9"
|
||
|
||
# The converse, so the gate is proven to still GATE: H4B is now a genuinely
|
||
# versioned tree, which HAS already made the host-setup decision — a re-run must
|
||
# not redo it. Without this, "fix" and "run setup-host unconditionally" would be
|
||
# indistinguishable.
|
||
vers_out="$WORK/versioned-upgrade-out.txt"
|
||
inst_setup "$H4B" "$B4B" BOX_INSTALL_SOURCE="$SRC8" >"$vers_out" 2>&1 || true
|
||
check "versioned upgrade: an existing versioned install still SKIPS host setup" 0 "already had a box install" \
|
||
cat "$vers_out"
|
||
check "versioned upgrade: ...and the stub did NOT run" 1 "" \
|
||
grep -qF "SETUP-HOST-RAN-FROM" "$vers_out"
|
||
|
||
# 'current' does not always flip: the #66 guard holds the default under existing
|
||
# boxes. Host setup must still come from the version just installed, or the
|
||
# upgrade converges the host with the OLD release's host scripts — reinstating
|
||
# the very staleness #115 is about. The flat fixture carries no host/ dir at all,
|
||
# so going through 'current' could not even find a script to run.
|
||
H10="$WORK/h10"; B10="$WORK/b10"; mkdir -p "$H10/bin" "$B10"
|
||
cp "$ROOT/bin/box" "$H10/bin/box"; chmod +x "$H10/bin/box"
|
||
cp "$ROOT/VERSION" "$H10/VERSION"
|
||
ln -s "$H10/bin/box" "$B10/box"
|
||
check "flat upgrade under boxes: setup-host runs the NEW version's script" 0 "SETUP-HOST-RAN-FROM 9.9.9-drill" \
|
||
inst_setup "$H10" "$B10" BOX_INSTALL_SOURCE="$SRC9" FAKE_BOXES=work
|
||
check "flat upgrade under boxes: ...while the default correctly stayed put (#66)" 0 "box $VER" \
|
||
ibox "$B10/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"
|
||
# 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"
|
||
|
||
# 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"
|
||
# ...and the other side of that contract (#113): consent NOT given and no
|
||
# terminal to ask on is a usage error, not a mute 'aborted'. Driven for real —
|
||
# the gate sits above the first 'incus' call, so a daemon-free run reaches it.
|
||
check "teardown-host: refuses without a TTY and names the override (#113)" 2 \
|
||
"--yes (or BOX_YES=1) means yes" env -u BOX_YES bash "$ROOT/host/teardown-host.sh" </dev/null
|
||
|
||
# #102's race, pinned as a CLASS rather than at the one site that had it
|
||
# (#107). A daemon-free run cannot exercise a UFW teardown, so the shape is
|
||
# pinned instead: nowhere under host/ or drill/ may a known multi-line writer
|
||
# be piped into a line reader. `Status: active` is ufw's FIRST line, so the
|
||
# reader matches, closes the pipe, ufw takes SIGPIPE, and the pipeline
|
||
# yields 141 — under pipefail the branch silently reads false and the whole
|
||
# firewall block is skipped on a host the operator was told is clean.
|
||
#
|
||
# Swept, not per-file, because absence of pipefail is what made drill/wipe.sh
|
||
# survive the same shape: a file is only ever one `set -o pipefail` — the kind
|
||
# of robustness tweak that sails through review — from being #102 again. The
|
||
# sweep closes the class, so a new host/ or drill/ script inherits the pin for
|
||
# free instead of being one more site someone has to remember.
|
||
# Comment lines are stripped before matching: each fix's own commentary quotes
|
||
# the racing shape to explain it, and a pin that cannot tell prose from code
|
||
# would fail on the very comment documenting why it exists.
|
||
#
|
||
# BOTH halves of the matcher are alternations, and both were widened in #124:
|
||
#
|
||
# · READERS. Pinning `| grep` guarded the instance spelling, not the class.
|
||
# `head -n1`, `sed -n '1p;q'` and `awk '/x/ {print; exit}'` all close the
|
||
# pipe early and produce the identical wrong answer under pipefail. The
|
||
# alternation is deliberately NOT restricted to the early-exit spellings
|
||
# (`grep -q` but not `grep -c`, `sed …q` but not `sed s///`): telling
|
||
# those apart by regex is exactly the kind of precision that rots, and
|
||
# the house idiom is to capture first anyway — all six `ufw status` sites
|
||
# in the tree already do. Banning the pipe outright costs nothing real
|
||
# and cannot be defeated by a spelling nobody enumerated.
|
||
#
|
||
# · WRITERS. Enumerated, not generalised. `incus config trust list` joins
|
||
# `ufw status` because host/revoke-user.sh used it as a leftover-detection
|
||
# condition under `set -euo pipefail` (#124). A generic "no multi-line
|
||
# writer feeds a reader" matcher is unwritable here: ~150 legitimate
|
||
# `| grep` sites exist across host/ and drill/, nearly all reading an
|
||
# already-captured string back out of `printf '%s\n' "$var"`. So the
|
||
# sweep claims exactly what it can check — THESE writers are never piped
|
||
# — and grows one named writer at a time.
|
||
# shellcheck disable=SC2016 # "$1" is the subshell's positional, passed below
|
||
check "no multi-line writer is piped into a line reader under host/ or drill/" 0 "" \
|
||
bash -c 'bad=""
|
||
for f in "$1"/host/*.sh "$1"/drill/*.sh; do
|
||
grep -vE "^[[:space:]]*#" "$f" \
|
||
| grep -qE "(ufw status|incus config trust list)[^|]*\| *(grep|head|sed|awk|read)" \
|
||
&& bad="$bad ${f#"$1"/}"
|
||
done
|
||
[ -z "$bad" ] || { printf "racing reads in:%s\n" "$bad"; exit 1; }' \
|
||
_ "$ROOT"
|
||
|
||
# The other direction, per file that removes UFW rules: the capture present and
|
||
# the delete loop breaking on absence, so the sweep above cannot be satisfied by
|
||
# deleting the block instead of fixing it.
|
||
for f in host/teardown-host.sh drill/wipe.sh; do
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target files
|
||
check "$f: the UFW branch reads a captured snapshot" 0 "" \
|
||
grep -qF 'if [[ "$ufw_status" == *"Status: active"* ]]; then' "$ROOT/$f"
|
||
# shellcheck disable=SC2016 # ditto
|
||
check "$f: the numbered-delete loop breaks on absence, not on a pipe" 0 "" \
|
||
grep -qF '[ -n "$line" ] || break' "$ROOT/$f"
|
||
done
|
||
|
||
# Same other-direction pin for the non-ufw writer the sweep now names: the
|
||
# --purge leftover assert must match a captured trust store, so the sweep
|
||
# cannot be satisfied by deleting the assert instead of fixing it. That assert
|
||
# is the last thing standing between "purge INCOMPLETE" and a silent claim of
|
||
# success on a host that still trusts the revoked user's certificate.
|
||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||
check "revoke-user: the purge leftover assert reads a captured trust store" 0 "" \
|
||
grep -qF 'trust_csv="$(incus config trust list' "$ROOT/host/revoke-user.sh"
|
||
# shellcheck disable=SC2016 # ditto
|
||
check "revoke-user: the cert leftover check matches the capture, not a pipe" 0 "" \
|
||
grep -qF '"$trust_csv" == *$' "$ROOT/host/revoke-user.sh"
|
||
check "drill: reads the installed tree through current/" 0 "" \
|
||
grep -qF '.local/share/box/current/VERSION' "$ROOT/drill/drill.sh"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Drill record emission (#152). Feed the pure renderer facts captured by the
|
||
# destructive harness and inspect the Markdown it writes; no fake Incus run is
|
||
# mistaken for evidence from real hardware.
|
||
# ---------------------------------------------------------------------------
|
||
DRILL_RECORD_DIR="$(mktemp -d)"
|
||
DRILL_RECORD="$DRILL_RECORD_DIR/0.9.1.md"
|
||
|
||
drill_record_fixture() {
|
||
bash -c '
|
||
set -u
|
||
. "$1/drill/record.sh"
|
||
findings=("FAIL: sibling isolation regressed" $'"'"'NOTE: \033[33mcontainer fallback\033[0m'"'"')
|
||
drill_write_record "$2" \
|
||
"0.9.1" "drill-0.9.1-20260818-01" "builder / Debian 13, Incus 6.0.2" \
|
||
"2026-08-18" "heavy-duty/box" "release/0.9.1" \
|
||
"0123456789abcdef0123456789abcdef01234567" \
|
||
"heavy-duty/rig" "release/0.4.0" \
|
||
"89abcdef0123456789abcdef0123456789abcdef" \
|
||
"bash drill/drill.sh --ref release/0.9.1 --run-id drill-0.9.1-20260818-01" \
|
||
84 1 2460 findings
|
||
' _ "$ROOT" "$DRILL_RECORD"
|
||
}
|
||
|
||
check "drill record: renderer writes a Markdown draft" 0 "" drill_record_fixture
|
||
check "drill record: names the release version" 0 "# Release drill — 0.9.1" cat "$DRILL_RECORD"
|
||
# Backticks are literal Markdown delimiters.
|
||
# shellcheck disable=SC2016
|
||
check "drill record: carries the shared run ID" 0 '**Run ID:** `drill-0.9.1-20260818-01`' cat "$DRILL_RECORD"
|
||
# shellcheck disable=SC2016
|
||
check "drill record: pins the box ref and full SHA" 0 'box `release/0.9.1` @ `0123456789abcdef0123456789abcdef01234567`' cat "$DRILL_RECORD"
|
||
# shellcheck disable=SC2016
|
||
check "drill record: pins the rig ref and full SHA" 0 'rig `release/0.4.0` @ `89abcdef0123456789abcdef0123456789abcdef`' cat "$DRILL_RECORD"
|
||
# shellcheck disable=SC2016
|
||
check "drill record: records the invocation" 0 '`bash drill/drill.sh --ref release/0.9.1 --run-id drill-0.9.1-20260818-01`' cat "$DRILL_RECORD"
|
||
check "drill record: renders pass/fail counts and wall clock" 0 '**84/85 passed, 1 failed.** 41 minutes wall clock.' cat "$DRILL_RECORD"
|
||
check "drill record: carries plain findings" 0 '- FAIL: sibling isolation regressed' cat "$DRILL_RECORD"
|
||
check "drill record: strips terminal colour" 1 "" grep -q $'\033' "$DRILL_RECORD"
|
||
|
||
printf 'operator evidence\n' > "$DRILL_RECORD_DIR/existing.md"
|
||
# Expansion belongs to the bash -c fixture.
|
||
# shellcheck disable=SC2016
|
||
check "drill record: refuses to overwrite evidence" 1 "already exists" \
|
||
bash -c '. "$1/drill/record.sh"; findings=(); drill_write_record "$2" v id host date repo ref sha rig rref rsha invocation 0 0 0 findings' \
|
||
_ "$ROOT" "$DRILL_RECORD_DIR/existing.md"
|
||
check "drill record: an overwrite refusal preserves the file" 0 "operator evidence" cat "$DRILL_RECORD_DIR/existing.md"
|
||
|
||
# shellcheck disable=SC2016
|
||
check "drill record: default run ID joins version and UTC date" 0 "drill-0.9.1-20260818-01" \
|
||
bash -c '. "$1/drill/record.sh"; drill_default_run_id 0.9.1 2026-08-18' _ "$ROOT"
|
||
# shellcheck disable=SC2016
|
||
check "drill record: an already exact ref stays exact" 0 "0123456789abcdef0123456789abcdef01234567" \
|
||
bash -c '. "$1/drill/record.sh"; drill_resolve_ref_sha repo 0123456789abcdef0123456789abcdef01234567' _ "$ROOT"
|
||
|
||
REF_FIXTURE="$DRILL_RECORD_DIR/ref-fixture"
|
||
git init -q "$REF_FIXTURE"
|
||
git -C "$REF_FIXTURE" config user.name fixture
|
||
git -C "$REF_FIXTURE" config user.email fixture@example.invalid
|
||
printf 'branch\n' > "$REF_FIXTURE/value"
|
||
git -C "$REF_FIXTURE" add value
|
||
git -C "$REF_FIXTURE" commit -qm branch
|
||
git -C "$REF_FIXTURE" branch candidate
|
||
printf 'tag\n' > "$REF_FIXTURE/value"
|
||
git -C "$REF_FIXTURE" commit -qam tag
|
||
git -C "$REF_FIXTURE" tag candidate
|
||
TAG_SHA="$(git -C "$REF_FIXTURE" rev-parse 'refs/tags/candidate^{commit}')"
|
||
# The installer tries a tag before a branch when both share a name, so the
|
||
# record resolver must pin the same object rather than merely finding a ref.
|
||
# shellcheck disable=SC2016
|
||
check "drill record: tag resolution matches the installer's tag-first channel" 0 "$TAG_SHA" \
|
||
bash -c '. "$1/drill/record.sh"; drill_resolve_ref_sha "$2" candidate' _ "$ROOT" "$REF_FIXTURE"
|
||
|
||
EMPTY_REMOTE="$DRILL_RECORD_DIR/empty.git"
|
||
git init -q --bare "$EMPTY_REMOTE"
|
||
EARLY_RECORD="$DRILL_RECORD_DIR/early-failure.md"
|
||
check "drill record: an unresolved source fails before consent or mutation" 1 "cannot resolve exact box SHA" \
|
||
bash "$ROOT/drill/drill.sh" --repo "$EMPTY_REMOTE" --ref absent --emit-record "$EARLY_RECORD"
|
||
check "drill record: an early preflight failure still emits evidence" 0 "drill exited early" \
|
||
cat "$EARLY_RECORD"
|
||
check "drill: help names --emit-record" 0 "--emit-record <path>" bash "$ROOT/drill/drill.sh" --help
|
||
check "drill: help names --run-id" 0 "--run-id <id>" bash "$ROOT/drill/drill.sh" --help
|
||
check "drill: --emit-record needs a value before any mutation" 2 "--emit-record needs a value" \
|
||
bash "$ROOT/drill/drill.sh" --emit-record
|
||
check "drill: --run-id needs a value before any mutation" 2 "--run-id needs a value" \
|
||
bash "$ROOT/drill/drill.sh" --run-id
|
||
|
||
rm -rf "$DRILL_RECORD_DIR"
|
||
|
||
echo "---"
|
||
echo "$PASS passed, $FAIL failed"
|
||
rm -rf "$SHIMDIR" "$WORK"
|
||
[ "$FAIL" -eq 0 ]
|