diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e4c8e..7e34a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,16 @@ on the way to cutting its first release, and this file starts there. the socket, and rig falls back to removing the group itself, as it also does where box is not installed. Every fallback path carries the session warning, because the silence was the bug. +- **`rig bootstrap` refuses a users file that names no users** (#57) — an + empty, comments-only or whitespace-only file is not a parse error, so it + passed pre-flight, converged nothing, and left the box root-only: the exact + outcome `--no-users` exists to make explicit, reached by the flag added to + guarantee the opposite. Bootstrap's pre-flight now catches the zero-user + parse — before `apt`, the hostname change, or a spent pre-auth key — and + refuses, naming `--no-users` as the way to ask for a root-only box out loud. + Scoped to `rig bootstrap`'s contract only: a standalone `rig users apply` + against an emptied file is a real de-provisioning operation and is + unchanged. ## 0.1.0 — 2026-07-19 diff --git a/README.md b/README.md index b5e5b41..737967d 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,13 @@ error too — rig will not silently pick a winner. A bad users file is caught **up front**, in the same breath as a bad `--class`: bootstrap pre-flights it with the same parser apply uses, before `apt`, before the hostname change, and before a single-use pre-auth key is -spent. And on `host=yes` with `RIG_SKIP_BOX_INSTALL=1`, a box-role user with +spent. A file that names **no users** is refused there too — empty, +comments-only and whitespace-only files all parse fine, but bootstrapping +with one converges the same root-only box `--no-users` asks for, via the flag +that exists to guarantee the opposite. The refusal names `--no-users`, because +that box is reachable; it just has to be asked for out loud. This is +bootstrap's contract only: `rig users apply` against an emptied file is a +genuine de-provisioning operation and stays available. And on `host=yes` with `RIG_SKIP_BOX_INSTALL=1`, a box-role user with no `incus` group refuses immediately rather than a hundred lines later — that is the one case where the outcome is already certain, since the run has been told it will not install box. rig still **never** installs Incus or runs diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index e0b31dd..2374dfe 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -227,6 +227,37 @@ if [ -n "$USERS_FILE" ]; then if ! USERS_PARSED="$(parse_users_file "$USERS_FILE")"; then die "invalid users file: $USERS_FILE — every error is listed above; nothing was changed" 2 fi + # A file that parses to ZERO users is not a parse error — empty, comments-only + # and whitespace-only files are all perfectly valid input, and the parser is + # right to accept them. But they walk straight through the requirement #51 + # built: `--users ./empty-file` and `--no-users` converge the identical + # root-only box, and only one of them says so. The ambiguity the required flag + # exists to kill would survive in a narrower form — an operator who pointed at + # the wrong path and one who meant root-only would again produce indis- + # tinguishable boxes, which is precisely the state the flag was made to end. + # + # Refuse, and name --no-users: the outcome is reachable, it just has to be + # said out loud. That is the whole shape of #51's contract — both answers are + # available, neither is a side effect of what you did not type. + # + # The sharper reason this belongs at pre-flight rather than nowhere: against a + # box that ALREADY has operators, a truncated file does not converge nothing, + # it revokes every one of them. That is apply's correct and documented + # drop-semantics and it warns per user, so it is loud rather than silent — but + # a stray '>' is all it takes to produce that file, and every other failure + # mode on this command was deliberately made to fail before apt, the hostname + # change, or a spent pre-auth key. This one should not be the exception that + # fails after them. + # + # Deliberately scoped to BOOTSTRAP, not to the parser and not to apply. The + # lib stays a parser — "zero users is not allowed here" is bootstrap's policy, + # not a property of the file format — and a standalone `rig users apply` + # against an emptied file remains a real de-provisioning operation that must + # keep working. Bootstrap is where the claim "this box's people are these" is + # being made, so bootstrap is where an empty answer is a contradiction. + if [ -z "$USERS_PARSED" ]; then + die "users file names no users: $USERS_FILE parsed to zero operators (it is empty, or only comments and blank lines). Bootstrapping with it would converge a box only root can enter — the same outcome as --no-users, reached by the flag that exists to guarantee the opposite. Check the path, or pass --no-users to leave this box root-only deliberately" 2 + fi # Does anyone in the file carry role box? That single fact decides whether the # incus precondition below applies at all — a users file naming only admins # converges perfectly well on a host that has never seen Incus, and refusing diff --git a/test/cli.sh b/test/cli.sh index 9390f09..70e2e8e 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -213,6 +213,46 @@ check "bootstrap: the invalid-file refusal carries the parser's own line error" # ('--no-users' then apply by hand) named. check "bootstrap: --users - is refused, naming the pre-auth key prompt" 2 "pre-auth key prompt" \ "$ROOT/commands/bootstrap.sh" workload --users - +# A file that parses to ZERO users (#57). Not a parse error — the parser is +# right to accept empty, comments-only and whitespace-only files — but it walks +# straight through #51's requirement: `--users ./empty` and `--no-users` +# converge the identical root-only box, and only one of them says so. All three +# shapes are tested separately because they take different paths through the +# parser's skip rules, and an implementation that checked, say, file size alone +# would pass one and fail the others. +: > "$BOOT_USERS/empty" +cat > "$BOOT_USERS/comments" <<'USERS' +# the operators for this box +#dan admin ssh-ed25519 AAAAC3fixture dan@laptop +USERS +printf ' \n\t\n\n' > "$BOOT_USERS/blank" +check "bootstrap: an empty users file exits 2" 2 "names no users" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/empty" +check "bootstrap: a comments-only users file exits 2" 2 "names no users" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/comments" +check "bootstrap: a whitespace-only users file exits 2" 2 "names no users" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/blank" +# The refusal must name --no-users, for the same reason the missing-flag one +# does: the root-only box IS reachable, it just has to be said out loud. An +# error that only reported "no users" would leave the operator who genuinely +# wants root-only with no named way to ask for it. +check "bootstrap: the zero-user refusal names --no-users as the way to say it" 2 "pass --no-users to leave this box root-only" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/empty" +# It must NOT over-refuse: a file that names even one operator passes pre-flight +# untouched. Reaching the root check (exit 1) is the proof — same idiom as the +# incus precondition's negative cases below. +if [ "$(id -u)" -ne 0 ]; then + check "bootstrap: a users file naming operators still passes pre-flight" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok" +fi +# Scope guard (#57): the refusal is BOOTSTRAP's contract, not the parser's and +# not apply's. A standalone `rig users apply` against an emptied file is a real +# de-provisioning operation and must stay possible — greps that find nothing +# (exit 1) are the pass, the repo's negative-law idiom. +check "users apply: an empty file is still a legal de-provisioning input" 1 "" \ + grep -nE 'names no users' "$ROOT/commands/users-apply.sh" +check "users-config: zero users stays bootstrap policy, not a parser error" 1 "" \ + grep -nE 'names no users' "$ROOT/commands/lib/users-config.sh" # The host=yes box-role precondition, surfaced EARLY — but only where the # outcome is already proven: RIG_SKIP_BOX_INSTALL=1 means this run will not # install box, so a missing incus group can no longer be rescued by the install