From 4dbf890e820c2243eaf966fd418121df7a24b385 Mon Sep 17 00:00:00 2001
From: "XXV.CC"
Date: Sat, 1 Aug 2026 01:09:07 +0800
Subject: [PATCH 1/2] release: prepare v2.9.0 security hardening
---
.github/dependabot.yml | 5 +-
.github/workflows/mirror-release.yml | 2 +-
.github/workflows/stage-release.yml | 33 +-
.gitignore | 6 +-
CHANGELOG.md | 276 ++-
README.en.md | 12 +-
README.md | 12 +-
docs/installing.en.md | 2 +-
docs/installing.md | 2 +-
docs/operator-guide.en.md | 40 +-
docs/operator-guide.md | 40 +-
docs/releasing.md | 216 +-
docs/security-model.en.md | 42 +-
docs/security-model.md | 42 +-
internal/audit/audit.go | 118 +-
internal/audit/audit_test.go | 265 +++
internal/cli/cli.go | 174 +-
internal/cli/cli_e2e_test.go | 67 +-
internal/cli/cli_test.go | 827 ++++++-
internal/cli/commands.go | 183 +-
internal/cli/doctor_identity_test.go | 229 ++
internal/cli/invite.go | 508 ++--
internal/cli/invite_reuse_root_test.go | 117 +-
internal/cli/manage_root_test.go | 403 +++-
internal/cli/revoke.go | 441 +++-
internal/cli/revoke_process.go | 179 ++
internal/cli/revoke_process_test.go | 114 +
internal/cli/revoke_test.go | 867 +++++++
internal/cli/uninstall.go | 270 ++-
internal/cli/uninstall_root_test.go | 69 +-
internal/cli/uninstall_test.go | 290 +++
internal/config/config.go | 4 +-
internal/executil/executil.go | 7 +-
internal/expiry/expiry.go | 35 +-
internal/expiry/expiry_test.go | 110 +-
internal/fsutil/fsutil.go | 111 +-
internal/fsutil/fsutil_test.go | 255 ++-
internal/lifecycle/lock.go | 34 +-
internal/lifecycle/lock_test.go | 59 +
internal/mountinfo/mountinfo.go | 123 +
internal/mountinfo/mountinfo_test.go | 55 +
internal/netdetect/netdetect.go | 19 +-
internal/registry/record.go | 63 +-
internal/registry/record_test.go | 118 +-
internal/registry/store.go | 319 ++-
.../registry/store_durability_root_test.go | 53 +
internal/registry/store_root_test.go | 288 ++-
internal/registry/store_test.go | 197 +-
internal/schedule/orphans.go | 118 +-
internal/schedule/orphans_test.go | 118 +-
internal/schedule/schedule.go | 196 +-
internal/schedule/schedule_root_test.go | 4 +-
internal/schedule/schedule_test.go | 320 ++-
internal/schedule/system.go | 496 +++-
internal/schedule/system_test.go | 330 ++-
internal/schedule/valid.go | 12 +-
internal/schedule/valid_test.go | 47 +-
internal/selfmanage/release_pipeline_test.go | 920 +++++++-
internal/selfmanage/selfmanage.go | 7 +-
internal/selfmanage/selfmanage_test.go | 34 +
internal/sshdconf/sshdconf.go | 138 +-
internal/sshdconf/sshdconf_root_test.go | 16 +-
internal/sshdconf/sshdconf_test.go | 73 +
internal/sshkey/sshkey_test.go | 3 +-
internal/sudoers/sudoers.go | 121 +-
internal/sudoers/sudoers_test.go | 98 +-
internal/sysinfo/sshd.go | 260 ++-
internal/sysinfo/sshd_test.go | 116 +
internal/sysinfo/sysinfo.go | 46 +-
internal/sysinfo/sysinfo_test.go | 59 +-
internal/user/user.go | 1296 +++++++++--
internal/user/user_root_test.go | 2 +-
internal/user/user_test.go | 2035 +++++++++++++++--
internal/userjobs/jobs.go | 532 +++++
internal/userjobs/jobs_test.go | 682 ++++++
internal/validate/validate.go | 8 +
internal/validate/validate_test.go | 23 +-
internal/version/version.go | 17 +-
internal/version/version_test.go | 2 +
scripts/publish-release.sh | 429 +++-
80 files changed, 14628 insertions(+), 1631 deletions(-)
create mode 100644 internal/cli/doctor_identity_test.go
create mode 100644 internal/cli/revoke_process.go
create mode 100644 internal/cli/revoke_process_test.go
create mode 100644 internal/cli/revoke_test.go
create mode 100644 internal/cli/uninstall_test.go
create mode 100644 internal/mountinfo/mountinfo.go
create mode 100644 internal/mountinfo/mountinfo_test.go
create mode 100644 internal/userjobs/jobs.go
create mode 100644 internal/userjobs/jobs_test.go
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b65d304..db546cb 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,7 +1,8 @@
# Keep the supply chain patched. GitHub Actions are pinned to commit SHAs in the
# workflows; dependabot bumps those SHAs (and the version comment) when a new
-# release lands, and opens PRs for Go module updates. Security fixes to the Go
-# standard library ride in via the toolchain (setup-go check-latest), not here.
+# release lands, and opens PRs for Go module updates. Dependabot does not update
+# the fixed release toolchain: Go security releases require a coordinated change
+# to go.mod, release.yml, prepare-release.sh, and the release documentation.
version: 2
updates:
- package-ecosystem: github-actions
diff --git a/.github/workflows/mirror-release.yml b/.github/workflows/mirror-release.yml
index 09bb0d1..3529745 100644
--- a/.github/workflows/mirror-release.yml
+++ b/.github/workflows/mirror-release.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: Immutable release tag to mirror, for example v2.8.3
+ description: Immutable release tag to mirror, for example vX.Y.Z
required: true
type: string
diff --git a/.github/workflows/stage-release.yml b/.github/workflows/stage-release.yml
index 27af314..627eb5b 100644
--- a/.github/workflows/stage-release.yml
+++ b/.github/workflows/stage-release.yml
@@ -123,24 +123,27 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
- lookup="$(mktemp)"
- set +e
- timeout -k 5 60 gh api --include "repos/${GH_REPO}/releases/tags/${TAG}" >"$lookup" 2>&1
- lookup_status=$?
- set -e
- if [[ "$lookup_status" -eq 0 ]]; then
- echo "release or draft $TAG already exists; refusing to refresh any remote asset" >&2
- exit 1
+ # The tag-specific REST endpoint exposes only published releases. The
+ # authenticated list also exposes drafts to this write-capable token,
+ # so enumerate every page and refuse any existing use of the tag.
+ release_records="$(timeout -k 5 60 gh api --paginate \
+ "repos/${GH_REPO}/releases?per_page=100" \
+ --jq '.[] | [.tag_name, (.id|tostring)] | @tsv')" || {
+ echo "could not prove release $TAG is absent" >&2
+ exit 1
+ }
+ match_count=0
+ if [[ -n "$release_records" ]]; then
+ while IFS=$'\t' read -r actual_tag release_id extra; do
+ [[ -z "$extra" && -n "$actual_tag" && "$release_id" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "release enumeration returned malformed identity data" >&2; exit 1; }
+ [[ "$actual_tag" != "$TAG" ]] || match_count=$((match_count + 1))
+ done <<<"$release_records"
fi
- [[ "$lookup_status" -eq 1 ]] \
- || { echo "release lookup failed with unexpected status $lookup_status" >&2; exit 1; }
- [[ "$(grep -Ec '^HTTP/[0-9.]+ [0-9]{3}([[:space:]]|$)' "$lookup")" -eq 1 \
- && "$(grep -Ec '^HTTP/[0-9.]+ 404([[:space:]]|$)' "$lookup")" -eq 1 ]] || {
- cat "$lookup" >&2
- echo "could not prove release $TAG is absent" >&2
+ (( match_count == 0 )) || {
+ echo "release or draft $TAG already exists; refusing to refresh any remote asset" >&2
exit 1
}
- rm -f -- "$lookup"
# Re-resolve the protected tag immediately before the first write;
# neither queueing nor an environment delay can make it stale.
diff --git a/.gitignore b/.gitignore
index 3c50da7..15787ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,9 +10,13 @@
*.out
coverage.txt
-# Release staging (produced by scripts/release.sh)
+# Release build and staging output
/dist/
+# Python test caches
+__pycache__/
+*.py[cod]
+
# Editor / OS junk
.DS_Store
*.swp
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 083c5ef..1ceb257 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,171 @@
All notable changes to this project are documented here.
+## v2.9.0 - 2026-08-01
+
+- Repair an incomplete audit-log tail before the next append so a crash cannot
+ leave subsequent JSON records permanently joined to a partial line. Document
+ that audit writes are best-effort and an operation may be missing after a size
+ limit or write failure, and refuse a hard-linked log inode before metadata or
+ content repair could mutate its alias.
+- Clarify that SSH verification is an effective-configuration verdict, not an
+ end-to-end login test covering the network, PAM, SELinux, or running daemon.
+ Replace unconditional expiry-deletion wording with scheduled revocation, and
+ document the `UNVERIFIED` no-running-daemon path plus rollback cleanup failure.
+ Make sshd grant validation fail closed when any syntax, effective-config, or
+ reload probe is not configured, including a nil effective-config result, and
+ reject an overflowing `/proc` process-start timestamp before using a pid file
+ to select the sshd reload target.
+- When no known blocker already decides the credential, defer future-account
+ `Match Group` decisions until the credential-less account exists, then re-check
+ its real groups before installing a password or key; keep password fallback
+ behind an explicit default-No risk confirmation.
+- Treat a public IP returned by plaintext cloud metadata or found on a local
+ interface only as an interactive Host default that the operator must confirm
+ or replace, rather than silently directing an invite and its password to it.
+- Re-check the complete account identity around revoke and failed-create
+ rollback, refusing an observable same-name replacement before a helper or
+ fallback can continue. System account helpers remain name-scoped and are not
+ an atomic compare-and-swap. Bind interactive revoke and uninstall approval to
+ the complete registry record and passwd snapshot visible at confirmation;
+ any change before the locked mutation phase requires a new confirmation.
+- Reserve an absent deterministic `/home/` before creation. Create the
+ account entry with `useradd -M` already past-date expired and password-locked,
+ never copy `/etc/skel`, and keep Home absent while inherited cron/at work and
+ daemon-cached jobs are drained. Create an empty `0700` Home through a pinned
+ parent descriptor only after the selected UID is proved idle and that cleanup
+ is rechecked, so an old generation cannot plant credentials or startup files
+ for the new account to inherit. Require the created real
+ directory to belong to the new non-root UID/GID, reject nested
+ mounts, and remove that Home plus any UID-matched conventional mail spool under
+ the still-bound account identity before invoking account deletion helpers. Sweep
+ mail again after the helper confirms account absence. Invoke only `userdel --`
+ without `-r/-f`: a distro `deluser` can re-enable recursive Home or
+ whole-filesystem UID cleanup, while shadow's `-f` can remove a same-name group
+ still used as another account's primary group. Do not accept arbitrary BusyBox
+ account applets because their compiled shadow/group semantics cannot be proven
+ from the applet name.
+ Traverse Home through directory descriptors without following symlinks, and
+ bound cleanup to 100,000 entries, 128 levels, and a cooperative two-minute
+ deadline checked between filesystem calls; a single blocked call cannot be
+ interrupted by that deadline. Exceeding any budget retains the disabled account
+ and registry witness for a later retry.
+- Retain a credential-less, past-date-expired, password-locked pending account
+ without a Home to keep its UID occupied when a residual process already carries
+ that UID or `/proc` cannot be scanned reliably, rather than freeing the number
+ for immediate reuse. Inspect every
+ live thread so a zombie thread-group leader cannot hide an executable worker,
+ and re-check the group after binding its leader with pidfd. Re-scan the UID
+ after every SIGKILL sweep so a parent that forks and exits between snapshots
+ cannot hide its new child. Require two consecutive stable empty snapshots
+ before declaring the UID free; a disappearing TGID/TID resets that confirmation.
+- Before issuing credentials or releasing an account identity, remove and verify
+ its personal crontab and every queued `at`/`batch` job carrying the UID. Wait
+ 65 seconds for daemon-cached due work, then repeat job and process cleanup;
+ partial tooling, malformed inventories, surviving known-spool evidence, or jobs
+ and processes that survive retain the disabled account. Start the requested
+ invite lifetime after this drain, and document that process/job checks are
+ repeated snapshots rather than a kernel-atomic freeze. Detect a still-running
+ cron/at daemon through `/proc` even if its executable has disappeared, and wait
+ conservatively when that inventory is unreliable. Re-read each `at` job before
+ `atrm` so ID reuse cannot redirect deletion, and recognize a managed auto-revoke
+ job only when its generated owner header binds it to root. Probe the owner under
+ a 64 KiB limit so an oversized non-root body cannot block inventory, while root
+ jobs remain subject to the bounded full-body check and the external interface's
+ remaining non-atomic interval. Keep the account expired until its
+ credential, grants, registry state, and revoke task are complete, then explicitly
+ clear the temporary safety expiry for a permanent invite so `--no-auto-revoke`
+ does not leave it unable to log in. Check openSUSE's actual
+ `/var/spool/cron/tabs` layout rather than the unrelated `/var/spool/tabs` path.
+- Serialize same-name creation and revocation with an account reader/writer
+ barrier in front of the global lifecycle lock. A legacy `revoke --yes` without
+ UID/generation binding that collides with same-name creation now deletes
+ nothing and exits successfully so an old systemd job cannot retry against the
+ new generation. The same safe skip applies to a manual non-interactive command;
+ its warning and the bilingual guides require `doctor` plus a fresh revoke after
+ the concurrent operation. Accounts migrated with an unverified fixed identity
+ marker now require interactive full-name confirmation: the historical
+ `--yes --force --confirm-force` timer command can no longer be mistaken for
+ direct operator authorization. Report and sweep that now-unauthorized task as
+ stale instead of retaining a systemd service that can only retry forever.
+- Upgrade the registry to schema v4 and persist a deletion-started witness after
+ the final identity/job checks but before `userdel`. This lets an interrupted
+ post-deletion owner-checked mail sweep retry without allowing an ordinary stale
+ row to remove UID-owned data; recovery for an absent account never recursively
+ removes its old Home path. Exact generations remain bound; legacy, unregistered,
+ and pending rollback paths retain only a UID witness and require interactive
+ `--force` recovery while the account is live; their old unattended tasks are
+ cancelled as stale. Ordinary upsert, removal, compaction, and same-name invite
+ cannot discard or overwrite the witness.
+- Reconcile visible-but-not-yet-durable file deletions and directory creation on
+ retry in inode-before-parent order, including an absent unlink target, missing
+ Home/mail artifact, an intermediate directory whose inode or parent sync failed,
+ and an already-absent state/audit tree after recursive-removal parent sync failed.
+ Refuse recursive uninstall through a symlinked ancestor before checking mounts,
+ and fail closed on an empty or malformed mount inventory.
+- Require `visudo` as the pre-commit parser for `--sudo` grants, keep both
+ `sudo` and `visudo` optional for non-sudo invites, keep numerically equivalent
+ suffix spellings distinct in natural version ordering, and correct documentation
+ for host-detection helpers. Refuse to inventory sudoers through a symlinked
+ directory or silently ignore a malformed artifact in the managed namespace.
+ Fail the complete effective-policy proof when a root/ALL RunAs user is combined
+ with any negation, so exclusions such as `(ALL, !root)`
+ cannot be mistaken for root NOPASSWD access or skipped in favor of an earlier
+ apparently valid line.
+ Honor sudoers' last-match ordering when checking `sudo -l` output, so a later
+ `PASSWD: ALL`, restricted PASSWD command, or unparsed command list cannot be
+ hidden by an earlier `NOPASSWD: ALL` match; only a later exact NOPASSWD grant
+ restores the full-policy proof.
+- Require `chpasswd` during dependency planning only for password-login invites,
+ so a missing password helper is reported or installed before account creation;
+ keep it optional for key-only invites and the base `doctor` verdict.
+- Treat every non-empty `atq` line as inventory evidence, reject malformed or
+ duplicate job IDs, and roll back an exact matching revoke job when `at` reports
+ an ambiguous submission result. Treat a lone `batch` command as a partial at
+ installation that must fail closed during inventory, and bind the last-resort
+ `pgrep` atd probe to a process started by real UID 0 so an unprivileged process
+ cannot spoof only the daemon name. Inventory loaded systemd manager units as
+ well as unit files, reload the manager after cancellation even when files
+ already disappeared, and explicitly stop and confirm a still-loaded timer when
+ `systemctl disable --now` reports that its unit file is missing.
+- Compute one minute-ceiled absolute revoke deadline for display, `chage`,
+ systemd, and `at`. Submit `at` jobs by absolute UTC time instead of `now + N
+ hours`, which could run almost a minute early and, across daylight-saving
+ changes, up to an hour early or late. Tighten systemd's timer accuracy window
+ and fail closed if account setup consumes the whole requested lifetime before
+ scheduling.
+- Clarify that permanent accounts have no automatic revoke task and that an
+ incomplete uninstall inventory can leave accounts, grants, or now-unrunnable
+ tasks behind. A failed invite rollback reports nonzero and may retain a disabled
+ account plus its registry witness when cleanup cannot be confirmed. Clarify
+ that the uninstall marker blocks current binaries that check it under the
+ lifecycle lock, but cannot constrain every already-loaded historical binary.
+- Preflight deletion authority for every live account in an uninstall plan before
+ revoking the first one, so a later marker-only, pending, legacy, or mismatched
+ identity cannot leave an earlier valid account already deleted. Complete the
+ mirror-receiver maintenance example with a root-owned same-directory temporary
+ file, metadata checks, sync, and atomic rename.
+- Prevent a cancelled upgrade retry from blocking on the pre-Go-1.23 timer-drain
+ pattern, and require the online publisher to observe an immutable GitHub
+ Release before it reports publication complete.
+- Bind every publication mutation to an explicitly verified numeric GitHub
+ Release ID: pin the candidate before the first write, and bind any failure
+ restoration target before changing Latest. Never use a tag-addressed mutation;
+ tag reads select or cross-check numeric identities. Return an unexpectedly
+ public mutable candidate to draft by its pinned ID, and require repository
+ immutable Releases to be enabled before staging. Replace draft assets one at a
+ time after first filling missing entries, allowing an interrupted attempt with
+ at most one of the five final assets absent to resume without ever deleting the
+ whole draft asset set at once.
+- Discover both staged drafts and published Releases through the authenticated,
+ paginated Release list. GitHub's tag-specific REST endpoint hides drafts, so it
+ is no longer used either to bind the publisher's target or to prove that the CI
+ staging job may create a new draft.
+- Make the manual Latest incident procedure re-enumerate all stable Releases after
+ its mutation and verify that the same immutable numeric Release is still the
+ highest fallback, including the no-fallback case, before accepting the final
+ Latest route.
+
## v2.8.4 - 2026-07-27
- Run mirror synchronization only from an explicit protected-`main` dispatch.
@@ -154,8 +319,8 @@ All notable changes to this project are documented here.
pathname. Account creation also checks NSS before touching stale grants or
creating a local identity, preventing LDAP/SSSD username shadowing.
- Refuse and fully roll back an auto-delete invite when neither systemd nor `at`
- can schedule the exact deadline. Document `chage` accurately as a later,
- day-granularity backstop rather than the exact expiry mechanism.
+ can schedule the requested revoke target. Document `chage` accurately as a
+ later, day-granularity backstop rather than the scheduled revoke mechanism.
- Parse sshd `Match` criterion/value positions and treat `LocalAddress`,
`LocalPort`, routing-domain, and unknown connection criteria as unverifiable.
sshd grant rollback now reports removal and restore-reload failures, and the
@@ -398,10 +563,11 @@ the unprivileged-invitee surface all held.
`schedule` gained the `Orphans`/`UnitUsers` sweep that `sudoers` and `sshdconf`
always had, and it globs both prefixes.
-- **The audit log survives an uninstall by default.** It records who opened and
- closed root-capable accounts; erasing it on the way out is what covering your
- tracks looks like. `--purge-audit` removes it, and the teardown's own record is
- written *before* the purge, so "purge" cannot mean "leave exactly one line".
+- **The audit log survives an uninstall by default.** It preserves a best-effort
+ trail of attempts to open and close root-capable accounts; erasing it on the way
+ out is what covering your tracks looks like. `--purge-audit` removes it, and
+ teardown attempts to write its own record *before* the purge, so a successful
+ purge does not intentionally leave exactly one line.
- **`uninstall` refuses when run from the account it would delete.** A temp admin
has sudo and can run it; deleting its own account mid-teardown reaps the sudo
@@ -418,9 +584,11 @@ the unprivileged-invitee surface all held.
printed "removed an orphaned sudo grant" whichever way the removal went.
- **A recorded UID now decides in both directions** (this was to be v2.5.1; it
- ships here). The registry pins a `(name, uid)` pair at creation and the code calls
- it the tool's only immutable proof, precisely because the GECOS marker beside it
- can be rewritten by the account itself. A matching UID was honoured; a
+ ships here). The registry pins a `(name, uid)` pair at creation and that release
+ treated it as a stronger creation-time witness than the account-writable GECOS
+ marker. The pair detects a contradiction but cannot prove identity across
+ deletion and recreation because Linux may reuse a UID; current releases also
+ bind a random per-creation generation. Previously, a matching UID was honoured; a
contradicting one fell through and asked the marker instead, so an account
carrying a UID this tool never issued was deleted anyway, on the say-so of the
weaker witness. A contradiction is not a missing witness but a disproof.
@@ -624,9 +792,11 @@ held up. Everything it did find was in the revoke path, and this release fixes i
them.
The registry now records each account's UID at creation — fixed before the
- invitee ever had access, and unlike GECOS it cannot be rewritten retroactively —
- and that (name, uid) pair is what proves an account is the tool's. It stays
- reuse-proof, because a recreated account under the same name draws a fresh UID.
+ invitee ever had access, and unlike GECOS it cannot be rewritten retroactively.
+ That (name, uid) pair detects a changed UID but is not reuse-proof because Linux
+ may later allocate the same UID again. Current releases additionally bind a
+ per-creation generation in GECOS and the registry and compare a complete passwd
+ snapshot around name-scoped helpers.
A registry row written before this field still parses (the field is appended;
the parser's minimum stays at nine), so accounts already on deployed hosts remain
revocable, and a row written now still parses under an older build. The privilege
@@ -660,14 +830,15 @@ held up. Everything it did find was in the revoke path, and this release fixes i
semantics (only `*` and `?` are special — Go's `path.Match` honours `[...]`
classes that sshd treats literally, which could print "verified" for a login sshd
refuses); the silent metadata probe no longer queries a DNS-named endpoint, which
- broke the "never leaves this host or its link" promise and let a DNS spoofer seed
- the invite's Host; and the interactive menu no longer spins on a non-TTY stream of
- invalid input.
+ exposed the lookup to resolver traffic and let a DNS spoofer seed the invite's
+ Host; and the interactive menu no longer spins on a non-TTY stream of invalid
+ input.
## v2.2.5 - The invite stops promising a login it never checked
-- **`invite` now verifies that the account can actually log in — before it creates
- anything.** The tool wrote the public key to `~/.ssh/authorized_keys` and printed
+- **`invite` now checks whether the effective sshd configuration admits the
+ planned credential — before it creates anything.** The tool wrote the public
+ key to `~/.ssh/authorized_keys` and printed
`Login: SSH key only` as a hardcoded literal, without ever asking sshd whether it
would accept that key. On a host with `PubkeyAuthentication no`, an
`AuthorizedKeysFile` pointing somewhere else, an `AllowUsers`/`AllowGroups`
@@ -688,7 +859,8 @@ held up. Everything it did find was in the revoke path, and this release fixes i
sshd's global configuration is never edited, so every other account keeps the
operator's baseline byte for byte — and "restoring" is deleting our own file, so
there is no backup to go stale and clobber a later change. The grant is
- syntax-checked with `sshd -t`, *proved* effective with `sshd -T -C user=`, and
+ syntax-checked with `sshd -t`, confirmed in the effective configuration with
+ `sshd -T -C user=`, and
only then reloaded (`reload`, never `restart`: live sessions survive). Any failure
removes the file and refuses the invite. `revoke` — including the auto-revoke
timer — deletes the drop-in and reloads sshd. An interactive run asks first; a
@@ -697,7 +869,8 @@ held up. Everything it did find was in the revoke path, and this release fixes i
`DenyUsers`/`DenyGroups` rule is never bypassed: not being on an allow list is a
default nobody spoke about, an explicit deny is a decision.
- **`--password-login` is the opt-in fallback for hosts you would rather not
- touch.** It verifies that sshd really accepts passwords (refusing otherwise),
+ touch.** It checks that the effective sshd configuration permits password
+ authentication (refusing otherwise),
issues a 24-character password from `crypto/rand` shown once, and hands it to
`chpasswd` on stdin so it never appears in the process table. The invite says
`Login: password` truthfully and warns that this is the weakest grant the tool
@@ -714,9 +887,10 @@ held up. Everything it did find was in the revoke path, and this release fixes i
command should never reach a question at all.
- **The interactive flow no longer dead-ends a menu-driven operator on a
- locked-down host.** When a key login cannot be made to work — an unfixable deny, or
- the operator declines the per-account sshd exception — and sshd would accept a
- password, the interactive run now offers one (defaulting to No, behind the same
+ locked-down host.** When the effective-config check reports a key-credential
+ blocker — with an unfixable deny, or after the operator declines the per-account
+ sshd exception — but conclusively admits a password credential, the interactive
+ run now offers one (defaulting to No, behind the same
"weakest grant this tool issues" warning). Previously the only route to a working
invite there was `--password-login`, a flag the menu cannot reach, so a menu-only
operator was stranded.
@@ -746,9 +920,10 @@ held up. Everything it did find was in the revoke path, and this release fixes i
Previously the operator typed YES and was only then asked whether sshd could be
modified — agreeing to the account before seeing what it would cost the host. And
- when the post-creation re-check against the account's real groups finds that sshd
- accepts the login as it is, the promised exception is not written and the invite
- says so, rather than quietly skipping a file the summary had named.
+ when the post-creation re-check against the account's real groups finds that the
+ effective config already admits the credential, the promised exception is not
+ written and the invite says so, rather than quietly skipping a file the summary
+ had named.
- **No path in a root-run tool may panic, and none may hang.** The sshd probe is
reached through a guard that reports an unwired collaborator instead of
@@ -776,10 +951,10 @@ held up. Everything it did find was in the revoke path, and this release fixes i
under `.../local-ipv6s`, each needing a per-provider two-hop lookup that buys
nothing here).
-- **`doctor` reports whether sshd would accept a key login** for a freshly created
- temporary account, so the answer is available before an invite is needed, and
- names any sshd exception that outlived its account. `cleanup-expired --compact`
- removes those orphans.
+- **`doctor` reports the effective sshd configuration verdict for a key
+ credential** on a freshly created temporary account, so the result is available
+ before an invite is needed, and names any sshd exception that outlived its
+ account. `cleanup-expired --compact` removes those orphans.
Safety properties worth stating, because they are what make the sshd write
defensible at all:
@@ -807,7 +982,8 @@ held up. Everything it did find was in the revoke path, and this release fixes i
in 8.5).
- **An address-qualified `AllowUsers user@host` rule yields no verdict, not a pass.**
The tool cannot know which IP the invitee will connect from, so it reports the
- login as UNVERIFIED instead of claiming a proof — and does not "fix" it either,
+ login as UNVERIFIED instead of claiming a conclusive result — and does not
+ "fix" it either,
since writing `AllowUsers ` would quietly cancel the operator's network
restriction. Deny rules fail closed for the same reason, in the other direction.
- **The login is re-checked against the account's real groups** once it exists.
@@ -825,9 +1001,9 @@ held up. Everything it did find was in the revoke path, and this release fixes i
cannot be evaluated without knowing the invitee's address, so it is treated as a
reason to print UNVERIFIED — never as a blocker to "repair" (which would silently
cancel the network restriction) and never as something that fails the drop-in's
- proof-of-effect (the drop-in still makes the key work; that is what the proof
- checks). A bare `AllowUsers ` alongside it still counts as an
- unconditional pass.
+ effective-config check (the drop-in still removes the known key blocker; that is
+ what this check confirms). A bare `AllowUsers ` alongside it still
+ counts as an unconditional configuration pass.
- **An address- or host-scoped `Match` block downgrades the invite to UNVERIFIED.**
`sshd -T -C user=X` cannot evaluate `Match Address`/`Match Host` — the invitee's
source address is unknown — so a host that, say, denies the account from the
@@ -930,13 +1106,14 @@ held up. Everything it did find was in the revoke path, and this release fixes i
table so a reordered entry can no longer run the wrong command.
- **Host detection no longer interrogates before it looks.** `invite` without
`--host` used to ask "detect public IP? [y/N]" before doing anything, so the
- common case cost an extra keystroke and defaulted to No. Cloud metadata and
- local interfaces — neither of which leaves the host or its link — are now
- probed silently, and what they find prefills the host prompt (Enter accepts,
- or type over it). The external echo services (`api.ipify.org` and friends)
- still require an explicit yes, because that step discloses the server to a
- third party. `--yes` mode is unchanged: it never reaches out and still
- requires `--host`.
+ common case cost an extra keystroke and defaulted to No. Local interfaces and
+ fixed-address cloud metadata are now probed silently, and what they find
+ prefills the host prompt (Enter accepts, or type over it). Interface inspection
+ sends no traffic; metadata avoids public DNS and echo services but may traverse
+ the local or provider network. External echo services (`api.ipify.org` and
+ friends) still require an explicit yes because they disclose the server to a
+ third party. `--yes` mode is unchanged: it never reaches out and still requires
+ `--host`.
- Restored the `TerminateProcesses` uid guard test that was lost with the v1
unit-test suite: `kill` is now indirected so a test can prove a non-positive
uid signals nothing, without signalling every root process when the guard
@@ -954,9 +1131,9 @@ held up. Everything it did find was in the revoke path, and this release fixes i
## v2.1.0 - Operation audit log; v1 deprecation
-- **Operation audit log (new).** Every privileged mutating operation — account
- create/delete, and install / uninstall / upgrade — is appended as a JSON line to
- a root-owned, append-only `/var/log/linux-temp-admin/audit.log` (0600), recording
+- **Operation audit log (new).** Each privileged mutating operation — account
+ create/delete, and install / uninstall / upgrade — is submitted to a best-effort
+ JSONL logger at `/var/log/linux-temp-admin/audit.log` (root-owned, 0600), recording
the timestamp, actor (the invoking user under sudo, plus the effective uid),
action, target, result, and key parameters. Writes are best-effort and never
block or fail the operation itself. (An on-host log is tamperable by root;
@@ -988,12 +1165,13 @@ Follow-up hardening from a multi-pass security audit of the v2 rewrite. No new
features and no command/flag changes; existing behavior is unchanged except for the
`revoke` protection fix noted below.
-- **revoke: never delete a real account via a stale registry entry.** A UID>=1000
- account is now protected unless it carries the tool's managed GECOS marker — a
- per-account, reuse-proof signal — instead of trusting a name-keyed registry entry
- that can outlive a deleted temp account and be inherited by a later real user of
- the same name. The managed check is now an exact GECOS-field match, not a
- substring, matching its documented guarantee.
+- **revoke: stop trusting a stale registry name alone.** A UID>=1000 account in
+ that release was protected unless it carried the tool's exact managed GECOS
+ marker, instead of trusting a name-keyed registry entry that can outlive a
+ deleted temp account and be inherited by a later real user of the same name.
+ That marker was an additional per-account check, not reuse-proof identity: it
+ can be rewritten and copied. Current releases also require the registry UID and
+ a random per-creation generation.
- **invite: a failed sudo grant can no longer leave a NOPASSWD drop-in behind.** The
drop-in is removed on any grant/verification failure, and a removal failure is
reported rather than silently swallowed.
diff --git a/README.en.md b/README.en.md
index be20add..474aa4b 100644
--- a/README.en.md
+++ b/README.en.md
@@ -6,9 +6,9 @@
-> One command creates a time-limited SSH administrator account for a trusted collaborator and removes it automatically when it expires.
+> One command creates a time-limited SSH administrator account for a trusted collaborator and schedules automatic revocation at expiry.
-**linux-temp-admin** avoids sharing the root password and never stores the invite's private key on the server. It creates a temporary account, prints a bundle you can forward privately, and later removes the account, SSH key, and sudo grant.
+**linux-temp-admin** avoids sharing the root password and never stores the invite's private key on the server. It creates a temporary account, prints a bundle you can forward privately, and schedules revocation of the managed account, SSH key, and sudo grant.
The program is one static binary for amd64 and arm64 Linux, on both glibc and musl. Account, SSH, and scheduler operations still use the host's standard administration tools.
@@ -28,7 +28,7 @@ The tool then:
1. creates a temporary account with a random name;
2. generates a one-time SSH key and prints an invite bundle;
-3. grants passwordless sudo by default and removes the account after 24 hours;
+3. grants passwordless sudo by default and schedules automatic revocation after 24 hours;
4. checks the effective sshd configuration before creation, refusing a definite blocker and reporting incomplete knowledge as `UNVERIFIED`.
The quick start obtains the installer from the official mirror and sends it to a root shell. `set -o pipefail` propagates curl failures, so a failed install does not continue to `invite`; it **does not authenticate the script or stop an already received partial script from beginning execution**. Once the installer is running, the downloaded binary is still verified with SHA-256 and an ed25519 signature. Use the [high-assurance first-install procedure](docs/installing.en.md#high-assurance-first-install) when the script must be authenticated before execution.
@@ -70,7 +70,7 @@ The real private key is shown only once. Never put an invite bundle in a group c
## Inspect and revoke
```bash
-# Show all temporary accounts
+# Show all registered temporary accounts
/usr/bin/sudo /usr/local/sbin/linux-temp-admin status
# Choose an account from a list and revoke it
@@ -80,7 +80,7 @@ The real private key is shown only once. Never put an invite bundle in a group c
/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5
```
-By default, the account, home directory, SSH key, sudo grant, and any tool-created sshd exception are removed after 24 hours. Revoke access immediately when work is finished even when automatic removal is enabled.
+By default, automatic revocation is scheduled after 24 hours. When the complete account identity can still be checked, a successful revoke removes the personal crontab, UID-matched `at`/`batch` jobs, account, home directory, SSH key, sudo grant, and any tool-created sshd exception. If the account disappeared outside the tool, revoke cleans only the registry, name-scoped grants, and tasks it can still identify safely; it does not guess at Home or mail cleanup after losing the identity witness. If a safety check or cleanup fails, the command returns nonzero, retains the account when it still exists and the registry witness, and attempts to disable any surviving account for a systemd retry or manual recovery. Revoke access immediately when work is finished even when automatic revocation is enabled.
## Everyday commands
@@ -121,7 +121,7 @@ When public-key login is disabled, create an account-scoped sshd exception:
/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd
```
-This does not modify the global sshd policy, and the exception is removed with the account. See the [operator guide](docs/operator-guide.en.md) for automation, password login, permanent accounts, and complete troubleshooting.
+This does not modify the global sshd policy, and a successful account revoke removes the exception. See the [operator guide](docs/operator-guide.en.md) for automation, password login, permanent accounts, and complete troubleshooting.
## Security essentials
diff --git a/README.md b/README.md
index 721cc75..98f369f 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@
-> 一条命令,为可信协作者创建一个有时限、用完自动删除的临时 SSH 管理员账号。
+> 一条命令,为可信协作者创建一个有时限、安排到期自动撤销的临时 SSH 管理员账号。
-**linux-temp-admin** 不需要分享 root 密码,也不会在服务器保存邀请私钥。它会创建临时账号、输出可私聊转发的邀请包,并在到期时自动撤销账号、SSH key 和 sudo 授权。
+**linux-temp-admin** 不需要分享 root 密码,也不会在服务器保存邀请私钥。它会创建临时账号、输出可私聊转发的邀请包,并安排在到期时撤销受管账号、SSH key 和 sudo 授权。
程序是一个支持 glibc 和 musl 的静态二进制,适用于 amd64 和 arm64 Linux。账号、SSH 和定时任务操作仍会调用系统已有的标准管理工具。
@@ -28,7 +28,7 @@ curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh
1. 创建一个随机命名的临时账号;
2. 生成一次性 SSH key,并在终端显示邀请包;
-3. 默认授予免密 sudo,并在 24 小时后自动删除账号;
+3. 默认授予免密 sudo,并安排在 24 小时后自动撤销;
4. 在创建前检查当前 sshd 配置:明确阻止登录就拒绝,无法完整判断则如实标记 `UNVERIFIED`。
快速入口从官方镜像取得安装脚本并交给 root shell。`set -o pipefail` 会传播 curl 失败,因此安装失败后不会继续创建邀请;它**不认证脚本本身,也不能阻止已经收到的部分脚本开始执行**。安装器启动后,下载的二进制仍会经过 SHA-256 和 ed25519 签名验证。需要在执行前认证安装脚本时,请使用[高保证首次安装流程](docs/installing.md#高保证首次安装)。
@@ -70,7 +70,7 @@ ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10
## 查看和撤销
```bash
-# 查看全部临时账号
+# 查看全部已登记临时账号
/usr/bin/sudo /usr/local/sbin/linux-temp-admin status
# 从列表选择并撤销
@@ -80,7 +80,7 @@ ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10
/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5
```
-默认会在 24 小时后自动删除账号、家目录、SSH key、sudo 授权和本工具创建的 sshd 例外。即使启用了自动删除,用完后也应立即手动撤销。
+默认会安排在 24 小时后自动撤销。对于仍可用完整身份核对的账号,撤销成功会删除个人 crontab、UID 匹配的 `at`/`batch` 任务、账号、家目录、SSH key、sudo 授权和本工具创建的 sshd 例外;若账号已在程序外消失,只清理可安全识别的登记、按用户名授权和任务,不会猜测删除失去身份见证的 Home/mail。若安全检查或清理失败,程序会返回非零,保留账号(若仍存在)和登记,并尽力禁用仍存在的账号,供 systemd 重试或人工处理。即使启用了自动撤销,用完后也应立即手动撤销。
## 常用命令
@@ -121,7 +121,7 @@ ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10
/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd
```
-该操作不会修改 sshd 全局策略,并会在撤销账号时删除对应例外。自动化调用、密码登录、永久账号和完整故障处理见[管理员指南](docs/operator-guide.md)。
+该操作不会修改 sshd 全局策略;成功撤销账号时会删除对应例外。自动化调用、密码登录、永久账号和完整故障处理见[管理员指南](docs/operator-guide.md)。
## 安全要点
diff --git a/docs/installing.en.md b/docs/installing.en.md
index 921769d..33ac250 100644
--- a/docs/installing.en.md
+++ b/docs/installing.en.md
@@ -13,7 +13,7 @@ This guide is for administrators who install and maintain `linux-temp-admin`. Se
- root access plus curl, OpenSSL 3, sha256sum, and timeout for installation;
- `getent` or `nslookup` for GitHub CDN fallback, so every redirect target can be validated and pinned to a public address.
-The binary has no dynamic-library or language-runtime dependency. Account lifecycle operations still use the system's `id`, `useradd`/`adduser`, `userdel`/`deluser`, `usermod`, and `chage`; granting sudo also requires `sudo`. Missing tools can be installed through apt, dnf, yum, or apk after interactive confirmation.
+The binary has no dynamic-library or language-runtime dependency. Account lifecycle operations still use the system's `id`, `useradd`, `userdel`, `usermod`, and `chage`; password login additionally requires `chpasswd`, while granting sudo requires `sudo` and `visudo` for pre-commit policy validation. The tool does not fall back to a distro `adduser`/`deluser` or an arbitrary BusyBox account applet: command names alone cannot prove equivalent arguments, configuration, or compile-time shadow/group semantics. Missing tools can be installed through apt, dnf, yum, or apk after interactive confirmation.
Arch Linux has no safe partial-upgrade mode, while `pacman -Syu` upgrades the whole system. The tool therefore never runs pacman automatically while creating an account. Complete the prompted upgrade and dependency installation deliberately first.
diff --git a/docs/installing.md b/docs/installing.md
index ba2a20a..05f9905 100644
--- a/docs/installing.md
+++ b/docs/installing.md
@@ -13,7 +13,7 @@
- 安装需要 root 权限、curl、OpenSSL 3、sha256sum 和 timeout;
- GitHub CDN 回退还需要 `getent` 或 `nslookup`,用于验证并固定每个重定向目标的公网地址。
-二进制本身不依赖动态库或语言运行时。账号生命周期仍会使用系统的 `id`、`useradd`/`adduser`、`userdel`/`deluser`、`usermod` 和 `chage`;授予 sudo 时还需要 `sudo`。缺失依赖可在交互确认后通过 apt、dnf、yum 或 apk 安装。
+二进制本身不依赖动态库或语言运行时。账号生命周期仍会使用系统的 `id`、`useradd`、`userdel`、`usermod` 和 `chage`;密码登录还需要 `chpasswd`,授予 sudo 时还需要 `sudo` 和用于写入前策略校验的 `visudo`。程序不回退到发行版 `adduser`/`deluser` 或任意 BusyBox 账号 applet:这些实现的参数、配置及编译期 shadow/group 语义不能仅凭命令名证明与 shadow 工具链等价。缺失依赖可在交互确认后通过 apt、dnf、yum 或 apk 安装。
Arch Linux 不允许安全的部分升级,而 `pacman -Syu` 会升级整个系统,因此本工具不会在创建账号时自动运行 pacman。请根据提示由管理员先完成完整升级和依赖安装。
diff --git a/docs/operator-guide.en.md b/docs/operator-guide.en.md
index 054d91e..9d4940b 100644
--- a/docs/operator-guide.en.md
+++ b/docs/operator-guide.en.md
@@ -35,13 +35,13 @@ The interactive flow:
3. grants sudo by default, with an option for a regular account;
4. asks whether to auto-delete and then asks the lifetime only when enabled;
5. shows the complete summary for confirmation;
-6. creates the account, grants, and revoke task before printing the invite credential.
+6. creates the account and grants, creates a task when automatic revocation is enabled, and only then prints the invite credential.
-Before creating anything, the tool evaluates the effective sshd configuration to rehearse whether the new account can log in. A definite blocker refuses creation; incomplete knowledge is reported as `UNVERIFIED` rather than presented as a verified result.
+Before creating anything, the tool checks whether the planned credential is compatible with the effective sshd configuration. An unresolved blocker reported by the check refuses creation, and incomplete knowledge is reported as `UNVERIFIED`. "Verified against the effective sshd config" means only that this configuration check completed without a known blocker or unevaluated rule; it is not end-to-end proof of the network, firewall, PAM, SELinux, or running sshd state. Test the invite through the intended connection path before delivery.
### Host detection
-Without `--host`, interactive mode first checks cloud metadata and local interfaces; these checks do not leave the host or local link. Only when no public address is found does it ask permission to query a public IP service, which exposes the server's egress address to that third party.
+Without `--host`, interactive mode first queries fixed-address cloud metadata endpoints and inspects local interfaces. Interface inspection sends no traffic; metadata uses plaintext HTTP and avoids DNS, redirects, and environment proxies, but it may traverse the local or cloud-provider network and its response is unauthenticated. The detected value is only a default that the operator must confirm or replace. Especially for password login, verify the Host first through the cloud console, DNS, or another independent channel so the invitee is not directed to submit the password to the wrong SSH server. Only when no public address is found does the tool ask permission to query a public IP service, which exposes the server's egress address to that third party; that result also requires confirmation.
`--yes` mode never queries a public IP service and requires an explicit `--host`. The host accepts a plain domain, IPv4, or IPv6 value; pass the port separately with `--port`.
@@ -65,6 +65,8 @@ Without `--host`, interactive mode first checks cloud metadata and local interfa
With automatic removal disabled, only `revoke` deletes the account and `--hours` is ignored.
+The account-database entry is past-date expired and password-locked from `useradd`, and `/etc/skel` is not copied. After the tool verifies the complete identity and finds no residual process for the new UID, the Home remains absent while it clears the same-name crontab, `at`/`batch` jobs for the reused UID, and any due job a daemon may already have read. When a cron/at command or running daemon is detected, this credential-less, still-expired pending account remains allocated for a 65-second drain; an unreliable process inventory also takes the conservative wait. An empty mode-`0700` Home is created only after cleanup and repeated checks pass, and the account is activated only after its password/key, grants, registry state, and automatic revoke task are complete. The requested lifetime starts after this cleanup, so the wait does not shorten the access requested by `--hours`.
+
## Deliver the invite
The bundle contains Host, Port, User, expiry, sudo state, login verdict, and a command that saves the one-time private key. Only the public key is stored on the server; the private key is printed once after successful creation.
@@ -99,7 +101,7 @@ Unattended mode never installs dependencies or changes sshd implicitly; pass `--
/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5
```
-Status reports account identity, UID, expiry, auto-delete task, and registry anomalies. `doctor` also reports orphaned sudoers files, sshd exceptions, revoke tasks, and missing schedulers.
+Status reports account identity, UID, expiry, auto-delete task, and registry anomalies. `doctor` also reports orphaned sudoers files and sshd exceptions, plus orphaned, missing, or invalid registered revoke tasks; with no account awaiting a schedule, it does not independently prove that the systemd or `at` backend is available.
## Revoke an account
@@ -111,9 +113,17 @@ Status reports account identity, UID, expiry, auto-delete task, and registry ano
/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5
```
-Revoke removes the account, home directory, public key, sudoers grant, account-scoped sshd exception, and automatic task. If a name-scoped grant cannot be removed safely, the tool retains and disables the account and returns nonzero so username reuse cannot reactivate a leftover grant.
+When the complete identity can still be checked, revoke first disables login; removes and verifies the personal crontab and target-UID `at`/`batch` jobs; waits for a 65-second daemon drain; repeats job/process cleanup; and then removes the account, deterministic `/home/` directory, any UID-matched conventional mail spool, public key, sudoers grant, account-scoped sshd exception, and automatic task. If the account disappeared outside the tool, it cleans only the registry, name-scoped grants, and tasks that remain safely identifiable. Recursive Home cleanup proceeds only for a real directory owned by the registered account's UID/GID with no mount boundary underneath; a mail spool must likewise be a non-symlink regular file in an accepted system mail directory and is swept again after account absence is confirmed. Home cleanup uses directory descriptors and rejects a symlink at the Home root; an internal symlink is unlinked without following its target. Traversal checks cooperative budgets of 100,000 entries, 128 levels, and two minutes between filesystem calls, so the deadline cannot interrupt one blocked filesystem call. Cron/at and process results are repeated snapshots, not an atomic freeze. If a safety condition, resource limit, job/process inventory, or name-scoped grant cannot be confirmed, revoke attempts to disable the account, retains any surviving account and the registry witness, and returns nonzero so username reuse cannot inherit old data, deferred work, or privilege.
+
+Before deleting an `at` job, the tool rereads its body and rechecks the UID or exact revoke command so a reused job ID cannot authorize deletion of an unrelated task. `at` has no atomic compare-and-delete interface, so a very short local-root trust-boundary interval remains between that read and `atrm`.
+
+For compatibility with old automatic tasks, a `revoke --yes` command without UID/generation arguments cannot prove that its old deletion intent still names the same account if it collides with a concurrent same-name `invite`. The command warns explicitly, deletes no account, and exits successfully so systemd cannot retry the old task against the new generation; a manually issued non-interactive command of the same shape follows the same rule. After the concurrent operation finishes, run `doctor` and invoke `revoke` again against the current account.
+
+An account reported by `doctor` as `legacy-unverified` carries an old fixed identity marker, so same-name/same-UID reuse cannot be excluded. After manual inspection, it can be recovered only by running `revoke --user --force` in an interactive terminal and typing the complete username. The historical timer's `--yes --force --confirm-force` arguments and every other non-interactive invocation are denied deletion authority for this account class. `doctor` reports any surviving old task as orphaned, and `cleanup-expired --compact` cancels that task while retaining the live account and registry row for manual handling.
+
+After every pre-deletion check passes, the tool persists a deletion-recovery witness before invoking `userdel`. If account deletion, the post-deletion mail-spool sweep, or task cleanup is interrupted, `status` and `doctor` show the recovery state, a same-name `invite` refuses to overwrite the witness, and `cleanup-expired --compact` does not discard the witness. When the account is absent or still exactly matches the recorded generation, run `revoke --user ` to resume. An identifiable automatic task is retained in either state, but only a systemd job retries automatically under its restart policy; `at` and legacy one-shot jobs require a manual retry. Legacy, unregistered, and pending-rollback paths retain only a UID witness. If that account is still live, inspect it and run `revoke --user --force` in an interactive terminal, then type the complete username; every non-interactive invocation is refused. The old automatic task for such a live account is treated as orphaned and cancelled, while the registry witness remains for manual recovery.
-Deleting an unregistered account requires explicit `--force` and an additional username confirmation. Root, UID 0, low-UID system accounts, and real accounts without the tool's exact marker are never treated as managed accounts.
+Deleting an unregistered account requires explicit `--force` and an additional username confirmation; it does not override protection for reserved names, UID 0, or unregistered/legacy-identity low-UID accounts. If a system assigns a new tool-created account a low UID, it remains normally revocable only while the current registry UID, random generation, and exact GECOS marker are fully bound. A real account without the tool's exact marker is never treated as managed.
## Clean anomalous state
@@ -125,7 +135,7 @@ Deleting an unregistered account requires explicit `--force` and an additional u
## Public-key login is disabled
-If sshd disables public-key login, changes the `authorized_keys` path, or uses an AllowUsers list, the tool detects the problem before creation and refuses the invite.
+If sshd disables public-key login, changes the `authorized_keys` path, or uses an AllowUsers list, the tool reports it before creation; an unresolved blocker refuses the invite.
The preferred repair is an exception scoped only to the new account:
@@ -133,7 +143,7 @@ The preferred repair is an exception scoped only to the new account:
/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd
```
-This option writes an account-scoped sshd drop-in without changing global policy. It validates the file with `sshd -t` and `sshd -T -C user=...`, then reloads rather than restarts sshd. Any failure removes the file and aborts; `revoke` removes the exception and reloads again. Explicit `DenyUsers` and `DenyGroups` rules are never bypassed.
+This option writes an account-scoped sshd drop-in without changing global policy. It checks syntax with `sshd -t`, then checks the effective configuration with `sshd -T -C user=...`. When a running sshd can be reached, it requests a reload and never a restart. If no running daemon can be notified, the file remains for socket activation or the next start, but the invite says `UNVERIFIED`. Other grant failures attempt to remove the file and abort; a failed removal or restorative reload returns nonzero and retains recovery evidence. A successful `revoke` removes the exception and requests another reload. Explicit `DenyUsers` and `DenyGroups` rules are never bypassed.
When public keys cannot be used, password login can be selected explicitly:
@@ -141,18 +151,18 @@ When public keys cannot be used, password login can be selected explicitly:
/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login
```
-The tool first verifies that sshd accepts passwords, then creates a random password shown once. This is the weaker grant because the password can be attacked over the network throughout its lifetime; prefer public keys.
+The tool creates a random password shown once only after the effective sshd configuration check finds neither a password-credential blocker nor an unevaluated rule. This is still not an end-to-end login test. Passwords are the weaker grant because they can be attacked over the network throughout their lifetime; prefer public keys.
-## Expiry and automatic removal
+## Expiry and automatic revocation
-The default lifetime is 24 hours with automatic removal enabled. A persistent systemd timer is preferred; an existing `at`/`atd` service is the fallback when systemd is unavailable. `at` is never installed automatically. If neither backend can schedule removal, the entire invite rolls back.
+The default lifetime is 24 hours with automatic revocation scheduled. A persistent systemd timer is preferred; an existing `at`/`atd` service is used only when systemd is unavailable or its failed scheduling attempt was safely rolled back. `at` is never installed automatically. If neither backend can schedule revocation successfully, the invite enters fail-closed rollback. If account, grant, or task cleanup cannot be confirmed, the command returns nonzero and, when necessary, retains a disabled account and registry witness for manual recovery instead of reporting an incomplete cleanup as success.
-`chage -E` provides only a day-granularity lock fallback and can be later than the displayed expiry; the revoke task enforces the exact deadline. The task binds the original UID, random generation token, and registry record, refusing to delete an account that has been removed and recreated or no longer matches.
+The lifetime is computed once after deferred-job cleanup for the new UID and the 65-second daemon drain complete, then rounded upward to a whole minute: the safety wait does not shorten the requested duration and rounding adds less than one minute. Display, systemd, and `at` share that absolute target; `at` uses an absolute UTC minute so daylight-saving changes cannot make it run early. `chage -E` provides only a possibly later, day-granularity lock fallback. Scheduler load, host downtime, and retries can delay actual removal; revoke access manually as soon as it is no longer needed. The task binds the original UID, random generation token, and registry record, refusing to delete an account that has been removed and recreated or no longer matches.
## Uninstall
```bash
-# Interactive: show the complete inventory, then type YES
+# Interactive: scan and show the uninstall inventory, then type YES
/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall
# Non-interactive; managed accounts require explicit removal authorization
@@ -162,9 +172,9 @@ The default lifetime is 24 hours with automatic removal enabled. A persistent sy
/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users --purge-audit
```
-Uninstall removes managed accounts and their grants, exceptions, and tasks before deleting state and the program. A failure to delete any account aborts the uninstall instead of leaving a sudo-capable account without its management command. Running uninstall from the temporary account's own session is refused.
+Uninstall first applies the same identity checks and cleanup as a normal `revoke` to each account in the inventory. It deletes state and the program only after confirming that every account, grant, exception, and task is gone. Any item that cannot be confirmed during account cleanup aborts the uninstall and keeps the management command and state. Running uninstall from the temporary account's own session is refused.
-The audit log remains at `/var/log/linux-temp-admin/audit.log` by default. The lifecycle lock and uninstall marker also remain to prevent already queued old processes from recreating state; an explicit reinstall handles the marker.
+The audit log remains at `/var/log/linux-temp-admin/audit.log` by default. The lifecycle lock and uninstall marker also remain; current binaries check the marker after taking the lock and refuse to recreate state. A previously loaded binary from before this protocol may not check it, so the marker does not guarantee control over every historically queued process. An explicit reinstall handles the marker.
## Written paths
diff --git a/docs/operator-guide.md b/docs/operator-guide.md
index e56df2d..43739ed 100644
--- a/docs/operator-guide.md
+++ b/docs/operator-guide.md
@@ -35,13 +35,13 @@
3. 默认授予 sudo,也可以选择普通账号;
4. 询问是否自动删除,启用时再询问有效期;
5. 显示完整摘要并确认;
-6. 创建账号、授权和撤销任务,最后才输出邀请私钥。
+6. 创建账号和授权;启用自动撤销时创建任务,最后才输出邀请凭据。
-创建任何内容前,工具会用 sshd 的有效配置预演新账号能否登录。明确阻止登录时会直接拒绝,无法完整判断时会在邀请中标记 `UNVERIFIED`,不会伪造已验证结论。
+创建任何内容前,工具会用 sshd 的有效配置检查计划凭据是否兼容。未解决的配置检查阻碍会拒绝创建,无法完整判断时会在邀请中标记 `UNVERIFIED`。显示“已对照 sshd 有效配置验证”只代表这项配置检查完整通过,不是对网络、防火墙、PAM、SELinux 或运行中 sshd 状态的端到端登录证明;交付前仍应沿实际连接路径测试邀请。
### Host 探测
-不传 `--host` 时,交互模式先读取云 metadata 和本地网卡,这些探测不会离开本机或本链路。只有找不到公网地址时,才会询问是否访问公网 IP 服务;这会向第三方暴露服务器出口地址,必须显式同意。
+不传 `--host` 时,交互模式先请求固定数字地址的云 metadata 端点并检查本地网卡。网卡检查不会发送流量;metadata 使用明文 HTTP,请求不使用 DNS、重定向或环境代理,但可能经过本地或云厂商网络,其返回值未经认证。程序只把探测值作为默认值,操作员必须确认或改写;尤其在密码登录模式下,应先通过云控制台、DNS 或其他独立渠道核对 Host,避免受邀者把密码提交给错误的 SSH 主机。只有找不到公网地址时,才会询问是否访问公网 IP 服务;这会向第三方暴露服务器出口地址,必须显式同意,返回值同样需要确认。
`--yes` 模式永远不会主动访问公网 IP 服务,必须显式提供 `--host`。Host 只接受普通域名、IPv4 或 IPv6;端口使用单独的 `--port`。
@@ -65,6 +65,8 @@
关闭自动删除后,账号只能通过 `revoke` 手动删除,`--hours` 会被忽略。
+账号数据库项自 `useradd` 起就使用过去日期过期并锁定密码,不会复制 `/etc/skel`。程序核对完整身份并确认新 UID 没有残留进程后,仍会让 Home 保持不存在,再清理同名 crontab、复用 UID 的 `at`/`batch` 任务和 daemon 可能已读取的到期任务。检测到 cron/at 命令或仍运行的 daemon 时,这个无凭据、保持过期的 pending 账号会继续占用身份并等待 65 秒;进程清单无法可靠读取时也会保守等待。只有清场和复查通过后才创建权限 `0700` 的空 Home;密码/公钥、授权、登记和自动撤销任务全部完成后账号才被激活。有效期从清场完成后开始,因此等待不会缩短 `--hours` 请求的访问时长。
+
## 交付邀请
邀请包包含 Host、Port、User、截止时间、sudo 状态、登录验证结果和一次性私钥保存命令。服务器只保存公钥;私钥只在成功创建后显示一次。
@@ -99,7 +101,7 @@ ssh -i ./USER.key -p PORT USER@HOST
/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5
```
-状态会显示账号身份、UID、有效期、自动删除任务和异常登记。`doctor` 还会报告孤儿 sudoers、sshd 例外、撤销任务和缺失调度器。
+状态会显示账号身份、UID、有效期、自动删除任务和异常登记。`doctor` 还会报告孤儿 sudoers、sshd 例外,以及孤儿、缺失或无效的已登记撤销任务;它不会在没有待调度账号时单独证明 systemd 或 `at` 后端可用。
## 撤销账号
@@ -111,9 +113,17 @@ ssh -i ./USER.key -p PORT USER@HOST
/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5
```
-撤销会删除账号、家目录、公钥、sudoers、账号专属 sshd 例外和自动删除任务。任一按用户名授权无法安全删除时,工具会保留并禁用账号、返回非零,避免用户名被复用后重新取得残留权限。
+对于仍可用完整身份核对的账号,撤销会先禁用登录,删除并复核个人 crontab 和目标 UID 的 `at`/`batch` 任务,等待 65 秒的 daemon drain 后重复任务/进程清理,再删除账号、确定的 `/home/<用户名>` 家目录、UID 匹配的常规 mail spool、公钥、sudoers、账号专属 sshd 例外和自动删除任务;账号若已在程序外消失,只清理仍可安全识别的登记、按用户名授权和任务。只有 Home 是真实目录、属于登记账号的 UID/GID 且不包含挂载边界时才会递归清理;mail spool 也必须是受信系统邮件目录中的非链接普通文件,并在账号确认消失后复扫一次。Home 清理使用目录描述符,不接受链接形式的 Home 根;内部链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,因此单次阻塞的文件系统调用不能被该期限中断。cron/at 和进程结果是重复快照,不是原子冻结。任一安全条件、资源上限、任务/进程盘点或按用户名授权无法确认时,都会尝试禁用账号,保留仍存在的账号和登记并返回非零,避免用户名复用后继承旧数据、任务或权限。
+
+删除 `at` 作业前会重新读取作业正文并再次核对 UID 或精确撤销命令,避免已复用的作业 ID 指向无关任务;`at` 没有原子的比较删除接口,因此重新读取到 `atrm` 之间仍存在本机 root 信任边界内的极短窗口。
+
+兼容旧版自动任务时,不带 UID/世代参数的 `revoke --yes` 若恰好与同名 `invite` 并发,无法证明旧删除意图在创建结束后仍指向同一账号。此时命令会明确警告、本次不删除任何账号并以成功状态跳过,避免 systemd 把旧任务重试到新世代;人工执行的同形非交互命令也遵循这一规则。并发操作完成后必须运行 `doctor`,并针对当前账号重新执行 `revoke`。
+
+`doctor` 报告为 `legacy-unverified` 的账号来自旧版固定身份标记,无法排除同名/同 UID 重用。人工核查后,只能在交互终端运行 `revoke --user <名> --force` 并输入完整用户名确认。旧版 timer 使用的 `--yes --force --confirm-force` 以及其他非交互调用都不会获得这类账号的删除授权;`doctor` 会把仍存在的旧任务报告为孤儿任务,`cleanup-expired --compact` 会取消任务但保留活账号及登记供人工处理。
+
+通过全部删除前检查后,程序会在调用 `userdel` 前持久化删除恢复见证。若账号删除、删除后的 mail spool 复扫或任务清理中断,`status` 和 `doctor` 会显示删除恢复状态,同名 `invite` 会拒绝覆盖该见证,`cleanup-expired --compact` 也不会删除见证。账号已经不存在或仍精确匹配登记世代时,运行 `revoke --user <名>` 可继续恢复;这两种状态下仍保留可识别的自动任务,但只有 systemd 任务会按重启策略自动重试,`at` 和旧的一次性任务需要人工重试。旧版、未登记或 pending 回滚只保留 UID 见证,若账号仍存在,必须人工核查后在交互终端运行 `revoke --user <名> --force` 并输入完整用户名,任何非交互调用都会被拒绝;这类活账号的旧自动任务会被当作孤儿任务取消,登记见证则保留供人工恢复。
-删除未登记账号需要显式 `--force`,并有额外用户名确认。root、UID 0、低 UID 系统账号及没有本工具精确标记的真实账号始终不会被当作受管账号删除。
+删除未登记账号需要显式 `--force`,并有额外用户名确认;它不会绕过保留名称、UID 0 或未登记/旧身份低 UID 账号的保护。若某些系统把本工具新建的账号分配到低 UID,只有当前登记 UID、随机世代和精确 GECOS 标记完整绑定时才能正常撤销。没有本工具精确标记的真实账号始终不会被当作受管账号删除。
## 清理异常状态
@@ -125,7 +135,7 @@ ssh -i ./USER.key -p PORT USER@HOST
## 公钥登录被禁用
-如果 sshd 关闭公钥登录、改变 `authorized_keys` 路径或使用 AllowUsers 白名单,工具会在创建前发现并拒绝。
+如果 sshd 关闭公钥登录、改变 `authorized_keys` 路径或使用 AllowUsers 白名单,工具会在创建前报告;未解决的阻碍会拒绝创建。
推荐只为新账号创建独立例外:
@@ -133,7 +143,7 @@ ssh -i ./USER.key -p PORT USER@HOST
/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd
```
-该选项只写账号作用域的 sshd drop-in,不修改全局配置。文件会经过 `sshd -t` 和 `sshd -T -C user=...` 验证,然后只 reload、不 restart。任一步失败都会删除文件并中止;`revoke` 会删除例外并再次 reload。显式 `DenyUsers` 或 `DenyGroups` 永远不会被绕过。
+该选项只写账号作用域的 sshd drop-in,不修改全局配置。文件会经过 `sshd -t` 语法检查,再用 `sshd -T -C user=...` 检查有效配置;能找到运行中的 sshd 时只请求 reload、不 restart。若没有可通知的运行中 daemon,文件会保留供 socket 激活或下次启动读取,但邀请显示 `UNVERIFIED`。其他授权失败会尝试删除文件并中止;删除或恢复 reload 失败会返回非零并保留恢复见证。成功 `revoke` 会删除例外并再次请求 reload。显式 `DenyUsers` 或 `DenyGroups` 永远不会被绕过。
无法使用公钥时也可明确选择密码:
@@ -141,18 +151,18 @@ ssh -i ./USER.key -p PORT USER@HOST
/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login
```
-工具会先验证 sshd 接受密码,再生成只显示一次的随机密码。这是较弱的授权方式,密码在有效期内可以被网络暴力尝试,应优先使用公钥。
+工具只会在 sshd 有效配置检查未发现密码凭据阻碍或无法判断的规则后,才生成只显示一次的随机密码。这仍不是端到端登录测试。这是较弱的授权方式,密码在有效期内可以被网络暴力尝试,应优先使用公钥。
-## 到期与自动删除
+## 到期与自动撤销
-默认有效期为 24 小时并启用自动删除。优先使用持久化 systemd timer,systemd 不可用时使用已有的 `at`/`atd`;`at` 不会被自动安装。两个后端都无法创建任务时,整个邀请回滚。
+默认有效期为 24 小时并安排自动撤销。优先使用持久化 systemd timer;systemd 不可用,或排程失败且相关 timer 已安全回滚时,才使用已有的 `at`/`atd`,`at` 不会被自动安装。任一后端都无法成功创建任务时,邀请会进入失败关闭回滚;若账号、授权或任务清理无法确认,工具会返回非零,并在必要时保留已禁用账号和登记见证供人工恢复,而不会把不完整清理报告为成功。
-`chage -E` 仅提供按天粒度的兜底锁定,可能晚于邀请显示时间;精确截止由撤销任务实现。撤销任务绑定创建时的 UID、随机世代标识和登记记录,账号被删除重建或身份不匹配时会拒绝误删。
+有效期在新 UID 的延迟任务清场和 65 秒 daemon drain 完成后只计算一次,并向上取整到整分钟:安全等待不会缩短请求时长,取整最多多不到一分钟。显示、systemd 和 `at` 共用这一绝对目标,其中 `at` 按 UTC 绝对分钟排程,不会因夏令时变化提前执行。`chage -E` 仅提供可能更晚的按天粒度兜底锁定。调度器忙碌、主机停机和重试都可能让实际删除延后;不再需要时应立即手动撤销。撤销任务绑定创建时的 UID、随机世代标识和登记记录,账号被删除重建或身份不匹配时会拒绝误删。
## 卸载
```bash
-# 交互式:先显示完整清单,再输入 YES
+# 交互式:先扫描并显示卸载清单,再输入 YES
/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall
# 非交互式;存在受管账号时必须明确允许删除
@@ -162,9 +172,9 @@ ssh -i ./USER.key -p PORT USER@HOST
/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users --purge-audit
```
-卸载先删除受管账号及其授权、例外和任务,再删除状态与程序。任何账号删不掉时都会中止,不会留下带 sudo 的账号却删除管理命令。从临时账号自己的会话运行卸载会被拒绝。
+卸载先对清单中的账号执行与普通 `revoke` 相同的身份核验和清理;只有确认账号、授权、例外和任务全部消失后才删除状态和程序。账号清理阶段的任一项无法确认都会中止卸载并保留管理命令与状态。从临时账号自己的会话运行卸载会被拒绝。
-审计日志默认保留在 `/var/log/linux-temp-admin/audit.log`。生命周期锁和卸载标记也会保留,用于阻止已经排队的旧进程在卸载后重建状态;显式重新安装会处理卸载标记。
+审计日志默认保留在 `/var/log/linux-temp-admin/audit.log`。生命周期锁和卸载标记也会保留;当前版本的进程在取得锁后会检查该标记并拒绝重建状态。已经载入且早于这项协议的旧版二进制可能不会检查它,因此该标记不保证约束每个历史排队进程;显式重新安装会处理卸载标记。
## 写入位置
diff --git a/docs/releasing.md b/docs/releasing.md
index 09fa134..65a0777 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -341,11 +341,16 @@ repository:
disable administrator bypass, and restrict deployments to the protected
`main` branch. A `workflow_run` receiver executes from the default branch even
though it validates and stages the triggering `v*` tag.
-4. Only after verifying those controls, set the repository Actions variable
+4. Enable immutable Releases for the repository before staging any draft. GitHub
+ applies this setting only to releases created after it is enabled, so enabling
+ it after CI has staged the candidate is too late. Re-check the setting before
+ every release ceremony; the publisher deliberately fails closed if the bound
+ release becomes public without becoming immutable.
+5. Only after verifying those controls, set the repository Actions variable
`LTA_RELEASE_ENVIRONMENT_CONFIGURED` to the exact value `true`. Missing or
different values fail closed before the write-capable job can run. Remove the
variable immediately if the environment or rulesets are weakened.
-5. Keep the repository's default Actions token permission read-only and do not
+6. Keep the repository's default Actions token permission read-only and do not
allow Actions to approve pull requests.
The staging workflow uses one fixed repository-wide concurrency group, so only
@@ -354,7 +359,11 @@ not provide an atomic compare-and-set operation spanning release enumeration and
the Latest pointer. The release coordinator must therefore also hold one
organization-wide publication lock for the entire preparation, offline signing,
and publication ceremony. Do not prepare or publish two tags concurrently from
-different workstations or workflow runs.
+different workstations or workflow runs. This repository does not provision,
+acquire, or verify that cross-workstation lock: the release organization must
+supply a durable exclusive lock that records its owner and tag and has an
+explicit stale-lock recovery policy. Workflow concurrency and a workstation-local
+`flock` do not satisfy this requirement.
The OpenPGP tag-signing key is separate from the offline ed25519 release key.
Before the first release under this process, generate or import a dedicated
@@ -422,10 +431,10 @@ git -c user.name='XXV.CC' \
-c user.signingkey="${TAG_SIGNING_FPR}!" \
-c gpg.format=openpgp \
-c gpg.program=/usr/bin/gpg \
- tag -s v2.8.4 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.8.4'
+ tag -s v2.9.0 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.9.0'
git -c gpg.format=openpgp -c gpg.program=/usr/bin/gpg \
- verify-tag --raw v2.8.4
-git push origin v2.8.4
+ verify-tag --raw v2.9.0
+git push origin v2.9.0
```
Before pushing, the `VALIDSIG` record from `verify-tag --raw` must identify the
@@ -448,7 +457,10 @@ uses its narrowly scoped write token to create a new unsigned draft. Candidate
tag workflows never receive a write token. The stage job requires GitHub to
recognize the annotated tag's OpenPGP signature; the online trusted phases still
pin and verify the exact signer fingerprint independently. Both workflows
-enforce the clients' 64 MiB binary limit before staging. CI refuses to refresh any existing draft;
+enforce the clients' 64 MiB binary limit before staging. The authenticated staging
+job enumerates every Release page because GitHub's tag-specific REST endpoint does
+not expose drafts; CI refuses to create or refresh any tag already used by a draft
+or published Release;
investigate and deliberately remove a bad draft before rerunning instead of
overwriting bytes that an offline ceremony may already have signed.
@@ -489,7 +501,7 @@ printf '\n' >/dev/tty
|| fail "GH_TOKEN must be one non-empty token without whitespace"
export GH_TOKEN
exec /opt/lta-release-tools/prepare-release.sh \
- v2.8.4 /srv/linux-temp-admin /srv/release-transfer/v2.8.4-prepared
+ v2.9.0 /srv/linux-temp-admin /srv/release-transfer/v2.9.0-prepared
LTA_PREPARE_RELEASE
```
@@ -509,7 +521,7 @@ the candidate or transfer media:
LTA_SIGN_KEY=/offline/keys/release-v1.key
LTA_TRUSTED_SIGNER=/opt/lta-release-tools/lta-release
LTA_TRUSTED_SIGNER_SHA256=''
-LTA_EXPECTED_TAG=v2.8.4
+LTA_EXPECTED_TAG=v2.9.0
LTA_EXPECTED_COMMIT=''
LTA_EXPECTED_PREPARED_MANIFEST_SHA256=''
LTA_EXPECTED_RELEASE_SIGNER_PUBKEY=''
@@ -521,7 +533,7 @@ LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='/dev/tty
|| fail "GH_TOKEN must be one non-empty token without whitespace"
export GH_TOKEN
exec /opt/lta-release-tools/publish-release.sh \
- /srv/release-transfer/v2.8.4-signed /srv/linux-temp-admin
+ /srv/release-transfer/v2.9.0-signed /srv/linux-temp-admin
LTA_PUBLISH_RELEASE
```
@@ -581,16 +593,39 @@ under a hard copy timeout and binds that private copy to the independently
recorded signed-bundle manifest hash, then verifies the manifest, pinned tag
signer, self-contained source tree, GitHub `main` ancestry, successful CI run,
keyring, checksums, and both
-signatures. While the
-release is still a draft it rejects any missing or extra assets, replaces all
-expected assets with the exact signed bytes, checks the complete asset list
+signatures. While the release is still a draft it accepts the exact three
+unsigned CI assets, the complete five-asset signed set left by an ambiguous
+response, or a prior replacement interrupted with exactly one of those five
+assets absent; duplicate, unexpected, and other partial states fail before any
+write. It uploads every missing signed asset first, then replaces each old
+asset immediately before re-uploading its exact signed bytes through asset IDs
+belonging to the bound Release ID. Once replacement starts, an interruption can
+therefore leave at most one of the five assets absent, and the identical command
+can safely resume. It checks the
+complete asset list
again, downloads the draft, compares every byte, and checks GitHub's SHA-256
digest for every asset immediately before publication. Stable versions must be
strictly newer than every published stable release, and the current Latest must
already equal that maximum and remain unchanged during preparation; prereleases
-never become Latest. It publishes every release initially with `--latest=false`,
-downloads and verifies the public versioned assets with bounded retries and hard
-file limits, and only then promotes a stable tag to Latest. Before the first
+never become Latest. It publishes every release initially with
+`make_latest=false`. Before any remote mutation, it enumerates GitHub's
+authenticated Release list, which includes drafts for a caller with push access,
+and requires exactly one visible Release whose tag equals `TAG`; only then does
+it pin that Release's numeric ID. It deliberately does not use the
+published-only `/releases/tags/{tag}` REST route to discover a draft. That
+initial state must be either the expected mutable draft or the expected immutable
+published release. Every later asset read, asset replacement, publish operation,
+Latest update, draft rollback, and state gate concerning that Release is
+addressed only by the pinned numeric ID; the publisher never re-resolves the
+candidate tag and therefore cannot follow a replacement mapping to another
+Release. Separate tag-addressed reads may bind other immutable stable Releases
+considered for Latest restoration, but tag-addressed routes are never mutation
+endpoints.
+After publishing, it also
+requires GitHub to report the exact tag, expected prerelease flag, and a non-draft
+immutable state. It downloads and verifies the public versioned assets with
+bounded retries and hard file limits, and only then promotes a stable tag to
+Latest. Before the first
remote mutation it preflights every command needed by the remaining publication
and verification path, including `curl` and `timeout`. A final enumeration
detects a concurrently published higher stable tag and restores Latest to the
@@ -599,17 +634,29 @@ stable release, it clears Latest and confirms the REST Latest route is exactly a
404; authentication or transport errors never count as that empty state. The
Latest route is then independently compared, checksummed, and
signature-verified for both architectures, followed by another highest-version
-check. An EXIT trap performs the same exact restoration after
-any error or signal following a possibly applied promotion. These checks narrow
-the race window but cannot make the GitHub API transactional; the mandatory
-global publication lock above remains the control that prevents two authorized
-publishers from overlapping.
+check and a final immutable-Release check. An EXIT trap performs the same exact
+restoration after any error or catchable terminating signal following a possibly
+applied promotion.
+If a publish request fails or returns an ambiguous response while the same bound
+Release is public but still mutable, the publisher returns that exact numeric ID
+to draft and verifies the rollback. It never follows a replaced tag to another
+Release; inability to classify or secure the bound ID is a fail-closed incident
+that must remain unannounced.
+These checks narrow the race window but cannot make the GitHub API transactional;
+the mandatory global publication lock above remains the control that prevents
+two authorized publishers from overlapping.
The publication command is deliberately resumable after the release has become
-public. It accepts that state only when the tag, draft/prerelease flags, complete
-asset-name set, sizes, GitHub SHA-256 digests, signed-bundle bytes, versioned
-public downloads, checksums, and ed25519 signatures all still match exactly. It
-does not clobber assets on a published release. This covers interruption after
+public. It accepts that state only when the tag and draft/prerelease/immutable
+flags are exact, the asset-name set is complete, and sizes, GitHub SHA-256
+digests, signed-bundle bytes, versioned public downloads, checksums, and ed25519
+signatures all still match. Within that invocation, the pinned numeric Release
+ID must also remain unchanged. A new process necessarily establishes a fresh ID
+pin from the then-current immutable Release; it does not prove that an earlier
+interrupted process observed the same numeric ID. The retained signed bundle,
+immutable state, exact asset checks, and the rule below forbidding deletion or
+replacement are therefore still required across retries. The publisher does not
+clobber assets on a published release. This covers interruption after
publication, after Latest promotion, or during final CDN verification without
weakening the signed-bundle binding. If the release is already Latest when a
read-only resume begins, a later verification or transport failure leaves that
@@ -633,11 +680,11 @@ release unannounced and retain the organization-wide publication lock. Restore
network/API access, rerun the same publisher first, and inspect its exact result.
For manual incident recovery only, use the following fail-closed procedure. It
uses decimal-string comparison rather than machine-sized arithmetic, rejects a
-noncanonical published stable tag, excludes the failed `TAG`, and verifies the
-exact resulting Latest state:
+noncanonical stable tag or mutable recovery target, excludes the failed `TAG`,
+and binds both the mutation and resulting Latest state to one numeric Release ID:
```bash
-TAG=v2.8.4 # the failed release; verify this value before running
+TAG=v2.9.0 # the failed release; verify this value before running
/usr/bin/sudo /usr/bin/env -i \
HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \
TAG="$TAG" /bin/bash -p <<'LTA_LATEST_RECOVERY'
@@ -694,26 +741,79 @@ stable_gt() {
return 1
}
-release_tags="$(gh_with_timeout api --paginate "repos/${REPO}/releases?per_page=100" \
- --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name')"
-fallback=
-while IFS= read -r candidate; do
- [[ -n "$candidate" ]] || continue
- [[ "$candidate" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \
- || { echo "noncanonical stable tag: $candidate" >&2; exit 1; }
- [[ "$candidate" != "$TAG" ]] || continue
- if [[ -z "$fallback" ]] || stable_gt "$candidate" "$fallback"; then
- fallback=$candidate
- fi
-done <<<"$release_tags"
+enumerate_recovery_target() {
+ local release_records candidate candidate_id candidate_immutable extra
+ local -A seen_release_tags=() seen_release_ids=()
+ fallback=
+ fallback_id=
+ fallback_immutable=
+ failed_id=
+ failed_immutable=
+ release_records="$(gh_with_timeout api --paginate "repos/${REPO}/releases?per_page=100" \
+ --jq '.[] | select(.draft == false and .prerelease == false) | [.tag_name, (.id|tostring), (.immutable|tostring)] | @tsv')"
+ while IFS=$'\t' read -r candidate candidate_id candidate_immutable extra; do
+ [[ -n "$candidate" ]] || continue
+ [[ -z "$extra" && "$candidate_id" =~ ^[1-9][0-9]*$ \
+ && ( "$candidate_immutable" == true || "$candidate_immutable" == false ) ]] \
+ || fail "stable release has no unambiguous identity or immutable state"
+ [[ "$candidate" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \
+ || fail "noncanonical stable tag: $candidate"
+ [[ -z "${seen_release_tags[$candidate]+present}" \
+ && -z "${seen_release_ids[$candidate_id]+present}" ]] \
+ || fail "duplicate stable Release tag or identity"
+ seen_release_tags[$candidate]=1
+ seen_release_ids[$candidate_id]=1
+ if [[ "$candidate" == "$TAG" ]]; then
+ failed_id=$candidate_id
+ failed_immutable=$candidate_immutable
+ continue
+ fi
+ if [[ -z "$fallback" ]] || stable_gt "$candidate" "$fallback"; then
+ fallback=$candidate
+ fallback_id=$candidate_id
+ fallback_immutable=$candidate_immutable
+ fi
+ done <<<"$release_records"
+}
+
+enumerate_recovery_target
+[[ "$failed_id" =~ ^[1-9][0-9]*$ && "$failed_immutable" == true ]] \
+ || fail "failed tag has no bound immutable published Release ID"
+expected_fallback=$fallback
+expected_fallback_id=$fallback_id
+expected_fallback_immutable=$fallback_immutable
+expected_failed_id=$failed_id
+expected_failed_immutable=$failed_immutable
if [[ -n "$fallback" ]]; then
- gh_with_timeout release edit "$fallback" --repo "$REPO" --latest
- actual="$(gh_with_timeout release view --repo "$REPO" --json tagName --jq '.tagName')"
- [[ "$actual" == "$fallback" ]] \
- || { echo "Latest is $actual, expected $fallback" >&2; exit 1; }
+ [[ "$fallback_immutable" == true ]] \
+ || { echo "highest stable fallback is mutable: $fallback ($fallback_id)" >&2; exit 1; }
+ state="$(gh_with_timeout api --method PATCH "repos/${REPO}/releases/${fallback_id}" \
+ -f make_latest=true \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')"
+ [[ "$state" == "$fallback false false true $fallback_id" ]] \
+ || { echo "unexpected fallback Release state: $state" >&2; exit 1; }
+else
+ state="$(gh_with_timeout api --method PATCH "repos/${REPO}/releases/${failed_id}" \
+ -f make_latest=false \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')"
+ [[ "$state" == "$TAG false false true $failed_id" ]] \
+ || { echo "unexpected failed Release state: $state" >&2; exit 1; }
+fi
+
+enumerate_recovery_target
+[[ "$failed_id" == "$expected_failed_id" && "$failed_immutable" == "$expected_failed_immutable" ]] \
+ || fail "failed Release identity or immutable state changed during Latest recovery"
+[[ "$fallback" == "$expected_fallback" && "$fallback_id" == "$expected_fallback_id" \
+ && "$fallback_immutable" == "$expected_fallback_immutable" ]] \
+ || fail "highest stable fallback changed during Latest recovery"
+
+if [[ -n "$expected_fallback" ]]; then
+ actual="$(gh_with_timeout api "repos/${REPO}/releases/latest" \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')"
+ [[ "$actual" == "$expected_fallback false false true $expected_fallback_id" ]] \
+ || fail "Latest is $actual, expected immutable Release $expected_fallback ($expected_fallback_id)"
else
- gh_with_timeout release edit "$TAG" --repo "$REPO" --latest=false
response="$work/latest-response"
set +e
gh_with_timeout api --include "repos/${REPO}/releases/latest" >"$response" 2>&1
@@ -749,7 +849,7 @@ announcement:
```bash
gh workflow run mirror-release.yml --repo xxvcc/linux-temp-admin \
- --ref main -f tag=v2.8.4
+ --ref main -f tag=v2.9.0
gh run list --repo xxvcc/linux-temp-admin \
--workflow mirror-release.yml --event workflow_dispatch --limit 1
```
@@ -849,8 +949,28 @@ SSH session may continue using the old inode, so wait for it to finish before
declaring the rollout complete:
```bash
+(
+set -Eeuo pipefail
python3 -B -m unittest -v scripts/mirror_receiver_test.py
+receiver_dir=/usr/local/libexec
+receiver_path="$receiver_dir/linux-temp-admin-mirror-receiver"
+[[ "$(sudo stat -Lc '%F %U %G %a' -- "$receiver_dir")" == 'directory root root 755' ]]
+receiver_tmp="$(sudo mktemp "$receiver_dir/.linux-temp-admin-mirror-receiver.XXXXXXXXXX")"
+cleanup_receiver_tmp() {
+ if [[ -n "$receiver_tmp" ]] && sudo test -e "$receiver_tmp"; then
+ sudo unlink -- "$receiver_tmp"
+ fi
+}
+trap cleanup_receiver_tmp EXIT
+sudo install -o root -g root -m 0755 -- scripts/mirror-receiver.py "$receiver_tmp"
+[[ "$(sudo stat -Lc '%F %U %G %a' -- "$receiver_tmp")" == 'regular file root root 755' ]]
+sudo sync -f "$receiver_tmp"
+sudo mv -fT -- "$receiver_tmp" "$receiver_path"
+receiver_tmp=
+sudo sync -d "$receiver_dir"
cmp scripts/mirror-receiver.py /usr/local/libexec/linux-temp-admin-mirror-receiver
+trap - EXIT
+)
```
The final `cmp` is a post-install drift check and must succeed. For an Nginx
@@ -960,7 +1080,7 @@ the release audit/signing record and a separate authenticated channel, then run:
```bash
INSTALLER_COMMIT='replace-with-the-audited-40-hex-commit'
INSTALLER_SHA256='replace-with-the-independent-64-hex-script-hash'
-LTA_RELEASE_TAG='v2.8.4'
+LTA_RELEASE_TAG='v2.9.0'
/usr/bin/sudo /usr/bin/env -i \
HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \
INSTALLER_COMMIT="$INSTALLER_COMMIT" INSTALLER_SHA256="$INSTALLER_SHA256" \
diff --git a/docs/security-model.en.md b/docs/security-model.en.md
index b90edda..9d5e6d4 100644
--- a/docs/security-model.en.md
+++ b/docs/security-model.en.md
@@ -27,15 +27,19 @@ An attacker who already has root can modify the program, kernel, account databas
## What sudo means
-`--sudo` writes an account-specific NOPASSWD sudoers grant and is effectively full root access. A trusted collaborator with root can create cron jobs, systemd units, SUID files, new accounts, or other persistence. Revoke removes only the account, grants, and tasks created and registered by this tool; it cannot infer and remove unrelated objects that collaborator created as root.
+`--sudo` writes an account-specific NOPASSWD sudoers grant and is effectively full root access. A trusted collaborator with root can create cron jobs, systemd units, SUID files, new accounts, or other persistence. Revoke removes the account, grants, and auto-revoke task managed by this tool; to prevent deferred work from crossing a username/UID reuse, it also removes the account's personal crontab and every `at`/`batch` job owned by that UID. It cannot infer and remove system-wide cron jobs, systemd units, SUID files, new accounts, or other unrelated objects that the collaborator created as root.
A "temporary account" limits the lifetime of the managed entry point. It is not a sandbox for root behavior. Never issue a sudo invite to an untrusted person.
## SSH login verdict
-Before creation, the tool evaluates the effective configuration equivalent to `sshd -T -C user=`, including Include, Match, and distribution crypto policy. The invite claims a verified key login only when that can be proved; incomplete knowledge is reported as `UNVERIFIED`, and a definite blocker refuses creation.
+Before creation, the tool checks compatibility against the effective configuration equivalent to `sshd -T -C user=`, including Include, Match, and distribution crypto policy. The invite says "verified against the effective sshd config" only when the check finds neither a known blocker nor an unevaluated rule. Incomplete knowledge is reported as `UNVERIFIED`, and an unresolved blocker reported by the check refuses creation.
-`--fix-sshd` writes only an account-scoped drop-in and restores later configuration scope with `Match all`. It validates with `sshd -t` and an effective-configuration check, then reloads rather than restarts sshd. Any failure removes the file and rolls back. Explicit `DenyUsers` and `DenyGroups` rules are never bypassed.
+This verdict means only that the planned credential is compatible with the effective sshd configuration inspected by the tool. It is not an end-to-end SSH login test and does not prove the complete state of the network, firewall, PAM, SELinux, or the running sshd process. Test the invite through the intended connection path before delivery.
+
+A future account has no NSS group membership before creation, so OpenSSH cannot reliably evaluate `Match Group` yet. Only when the check has no other known blocker and the future groups are its sole uncertainty does the tool create a pending account with no password or key credential, rerun the effective-configuration check against its real groups, and install a credential after that check passes. A known blocker still follows the normal refusal or explicitly authorized repair path; deferral cannot hide it. A `Match` that depends on connection attributes such as source address or destination port remains unevaluable: key invitations are explicitly marked `UNVERIFIED`, while password invitations fail closed.
+
+`--fix-sshd` writes only an account-scoped drop-in and restores later configuration scope with `Match all`. It validates with `sshd -t` and an effective-configuration check. When a running sshd can be reached, it requests a reload and never a restart. If no running daemon can be notified, the file remains for socket activation or the next start, but the invite is marked `UNVERIFIED`. Other grant failures attempt to remove the file and roll back; a failed removal or restorative reload returns nonzero and retains recovery evidence under the incomplete-rollback rules below. Explicit `DenyUsers` and `DenyGroups` rules are never bypassed.
## Account identity and deletion safety
@@ -48,19 +52,35 @@ Every new invite binds:
Automatic revoke and ordinary `revoke` require these identity values to agree. If the account is deleted and recreated, the UID is reused, the marker changes, or the registry is corrupt, unattended deletion is refused rather than guessing that the same name is the same object.
-Accounts migrated from the old fixed-marker registry are shown as `legacy-unverified` and are never automatically deleted by timers, bulk cleanup, or uninstall. They require manual inspection and a fully confirmed `revoke --force`.
+The account-database entry is created with `useradd -M -e 1970-01-01 -p '!'`, so it is expired to a past date and password-locked from the moment it appears, and it does not copy `/etc/skel`, which may contain host-local authentication material. After the complete passwd snapshot, non-root UID/GID, and residual-UID process scan pass, the Home remains absent throughout same-name/UID cron/at cleanup, the 65-second daemon drain, and the repeated checks. Only after another complete identity check passes does the tool create an empty mode-`0700` Home through a pinned `/home` directory descriptor and assign its owner. The account stays past-date expired throughout preparation. A final `chage` writes the requested expiry or never-expire value and activates login only after the password or key, sshd/sudo policy, registry state, and automatic revoke task are complete.
+
+The tool repeatedly rechecks the complete passwd snapshot at critical transaction stages; the GECOS identity-finalization and account-deletion paths also verify it after their name-scoped helpers return, detecting observable same-name replacement. These checks do not turn a helper into an atomic compare-and-swap; local root that can concurrently rewrite the account database remains inside the trust boundary. Account deletion invokes only `userdel --` without `-r/-f`, never a distro `deluser` that can read `/etc/deluser.conf` and re-enable recursive cleanup or an arbitrary BusyBox applet whose compile-time account-database semantics are unknown. Shadow-utils `-f` is also refused because it can delete a same-name group that another account still uses as its primary group.
+
+Accounts migrated from the old fixed-marker registry are shown as `legacy-unverified` and are never automatically deleted by timers, bulk cleanup, or uninstall. The historical timer's `--yes --force --confirm-force` arguments do not authorize deleting such an account. A surviving legacy task is reported as orphaned and may be cancelled by `cleanup-expired --compact` without deleting the live account or registry row. After manual inspection, an operator must run `revoke --force` in an interactive terminal and type the complete username; non-interactive deletion is always refused.
+
+Root, UID 0, and reserved names are never deleted. A low-UID account is revocable as tool-created only when its current registry UID, random generation, and exact GECOS marker are fully bound; an unregistered or legacy-identity low-UID account remains protected even with `--force`. A real account without the tool's exact marker is likewise never deleted as managed.
-Even with `--force`, root, UID 0, low-UID system accounts, and real accounts without the tool's exact marker are not deleted as managed accounts.
+## Deferred jobs, processes, and identity reuse
-## Processes and PID reuse
+A personal crontab and `at`/`batch` jobs do not reliably disappear when login is disabled, current processes are killed, or plain `userdel --` runs. Before a new account receives a password, public key, or sudo grant, and before an old account releases its username/UID, the tool removes and verifies the same-name personal crontab, inventories every job through `atq` and the generated `atrun uid=` header from `at -c`, and removes jobs for the target UID. Immediately before each `atrm`, it reads the same ID again and rebinds it to either the expected UID or the tool's exact revoke command; after a removal error it also distinguishes a surviving target from a disappeared target or reused ID. The tool recognizes its own automatic revoke job only when the `atrun` header says that root owns it. It probes that owner header within a 64 KiB limit, so an oversized job already identified as non-root does not block automatic-task inventory; only a root job is retained and read in full under the larger bounded limit. The external `at` interface has no atomic compare-and-delete operation, so a very short interval remains between that fresh read and `atrm`; local root able to replace a job in that interval is inside the trust boundary. A partial at-tool installation, corrupt or oversized queue output, an unparseable owner, or a surviving artifact fails closed.
-Before revocation, processes belonging to the target UID are inspected and Linux pidfds bind signals to those exact process instances, avoiding a signal to an unrelated process after PID reuse. Linux 5.3 plus usable `pidfd_open` and `pidfd_send_signal` are required for safe revocation. `doctor` probes them, and `invite` refuses creation when they are unavailable.
+Direct spool verification explicitly supports the cron directories `/var/spool/cron/crontabs`, `/var/spool/cron`, and `/var/spool/cron/tabs`, and the at directories `/var/spool/cron/atjobs`, `/var/spool/at`, and `/var/spool/atjobs`. Implementations using other layouts are outside this file-level verification. When a cron/at command footprint is present, or `/proc/*/comm` still shows a running `cron`, `crond`, or `atd`, the account keeps its identity allocated and disabled for 65 seconds so a daemon can finish a due job it read before cleanup; jobs and UID processes are then cleared again. An unreliable process inventory conservatively takes the wait instead of skipping it.
+
+Before revocation, every live thread in each thread group carrying the target UID is inspected; a group whose leader is already a zombie but whose worker still runs is not treated as empty. Linux pidfds bind signals to the inspected thread-group instance, avoiding a signal to an unrelated process after PID reuse, and thread credentials are checked again after the pidfd is opened. The UID is scanned again after every SIGKILL sweep, and account deletion proceeds only after two consecutive stable per-thread scans observe no live process. A TGID/TID disappearing before inspection resets that confirmation; an unreliable scan or exhausted bounded retries fails closed. Linux 5.3 plus usable `pidfd_open` and `pidfd_send_signal` are required for safe revocation. `doctor` probes them, and `invite` refuses creation when they are unavailable.
+
+The `/proc`, pidfd, cron, and at checks are repeated bounded snapshots under the same lifecycle lock, not a kernel-atomic freeze. They substantially narrow the race window and retain the disabled account when observation is unreliable, but cannot exclude local root that changes account databases or schedulers outside that lock; local root remains inside the trust boundary. System-wide cron entries, systemd units, or other root persistence separately created by a sudo-enabled collaborator are also outside personal-job cleanup.
## Transactions, locking, and rollback
Managed-state commits for invite, revoke, cleanup, install, upgrade, and uninstall share a root lifecycle lock so account, grant, registry, task, and binary changes do not interleave. Human confirmation, dependency installation, download, and signature verification are kept outside the lock where possible; state is revalidated after acquiring it.
-Any invite failure attempts to roll back the task, sudoers file, sshd exception, registry row, and account. A rollback failure is reported explicitly with a nonzero status and is never presented as success.
+A same-name `invite` also owns the exclusive side of an account barrier, while current revokes use its shared side. If a compatibility `revoke --yes` command without UID/generation arguments finds that a same-name creation already owns the exclusive barrier, it deletes nothing and skips successfully so an old systemd job cannot retry against the new generation; a manually issued non-interactive command of the same shape is skipped too and must be followed by `doctor` and a fresh revoke after the concurrent operation. This is a safety-first migration boundary, not proof that the account was deleted. An old binary that was already loaded and began waiting on the global lock before the new barrier took effect cannot be fully reconstructed by the new process locks; invite also scans for the exact root-owned legacy revoke process and refuses username reuse, but system helpers and `/proc` observation still are not an atomic compare-and-swap, and local root remains inside the trust boundary.
+
+Registry schema v4 writes `DeletionStarted` after all pre-deletion checks pass and before `userdel`. An exact-generation account keeps its UID/generation binding; legacy, unregistered, and pending-rollback paths keep only a UID witness. This preserves authority for a previously approved post-deletion mail-spool sweep without turning incomplete identity evidence into unattended live-account deletion authority. Post-deletion recovery is limited to an owner-checked conventional mail spool and never recursively removes an absent account's old Home path. Ordinary registry updates, removal, and compaction cannot overwrite a recovery row, and same-name creation must wait for recovery to finish. A live UID-only or generation-mismatched account permits only interactive `--force` recovery; its old automatic task is cancelled as stale so an unattended command with no recovery authority does not keep retrying.
+
+An invite failure runs its rollback stack, cleaning the task, sudoers file, sshd exception, registry row, and any new account that can still be matched to the complete creation-time identity. If the half-created account identity, grant cleanup, or recursive Home cleanup cannot be confirmed, the tool retains the account and registry witness for manual recovery instead of guessing by username. Every incomplete rollback is reported explicitly with a nonzero status and is never presented as success.
+
+If the UID selected for a new account already has residual processes, or a `/proc` scan cannot reach a reliable verdict, the tool retains a credential-less, past-date-expired, password-locked pending account without a Home to keep that UID occupied and preserves the registry witness for manual recovery. It does not delete the account and immediately expose the same UID to another allocation.
If revoke cannot completely remove a name-scoped grant, it retains and attempts to disable the account so username reuse cannot reactivate the leftover privilege. Treat every rollback or revoke error as an unresolved security incident.
@@ -69,14 +89,14 @@ If revoke cannot completely remove a name-scoped grant, it retains and attempts
- registry, preferences, and audit directories require root ownership and strict permissions;
- the registry validates schema, fields, UID, generation, and size and fails closed when corrupt or unreadable;
- installation, upgrades, and state writes use same-directory temporary files, metadata checks, atomic replacement, and required fsync operations;
-- an SSH home must belong to the target UID, and recursive removal refuses root/UID 0 homes and live mount boundaries;
+- a new account uses only the deterministic `/home/` path when it did not already exist; `/home` must be root-managed and the created real directory must belong to the target non-root UID/GID. Revoke removes it and any UID-matched conventional mail spool while the complete account identity is still available, then sweeps mail again after the account helper confirms absence to catch recreation during Home cleanup. Recursive Home removal uses directory descriptors. A symlink at the Home root, an owner mismatch, or a live mount boundary is refused; an internal symlink is unlinked without following its target. Traversal checks cooperative budgets of 100,000 entries, 128 levels, and two minutes between filesystem calls, so the deadline cannot interrupt one blocked filesystem call. A mail spool must be a non-symlink regular file in an accepted system mail directory;
- sudoers files, sshd exceptions, and automatic tasks use restricted project names and are removed only as verified managed objects.
Do not edit `/var/lib/linux-temp-admin/v2/registry.tsv` manually. An unreadable registry is never treated as an empty one.
## Expiry revocation
-The exact deadline is enforced by a systemd timer or an existing `at` backend; `chage -E` is only a day-granularity lock fallback. Invite creation rolls back when neither scheduling backend is available.
+The requested lifetime starts after deferred-job cleanup for the new UID and the 65-second daemon drain complete. It is then converted once into an absolute deadline rounded upward to a whole minute, so the safety wait does not shorten nominal access and rounding adds less than one minute. Invite display, the `chage -E` backstop date, the systemd timer, and `at` are all derived from that target; `at` receives an absolute UTC minute so a daylight-saving transition cannot revoke access early. `chage -E` remains only a later, day-granularity lock fallback. `at` is attempted only when systemd is unavailable or its failed scheduling attempt was safely rolled back. Invite creation rolls back when neither backend can schedule successfully or the deadline has already arrived before scheduling. Scheduler load, host downtime, and revoke retries can delay actual removal, so access that is no longer needed should be revoked manually.
The revoke task rechecks UID, generation token, GECOS marker, and registry row. An identity mismatch, missing registry, or recreated account is skipped safely for operator inspection. Failed systemd revokes use bounded retries; one-shot backend failures require `doctor` and manual action.
@@ -92,7 +112,7 @@ A valid signature alone does not provide absolute rollback protection for a firs
## Audit log
-Privileged operations append JSON lines to `/var/log/linux-temp-admin/audit.log`, recording time, caller, action, target, and result. The root-owned file and directory have per-record and total limits; at 64 MiB operations continue with a warning to archive or rotate the log.
+Privileged operations make a best-effort attempt to append JSON lines to `/var/log/linux-temp-admin/audit.log`, recording time, caller, action, target, and result. The root-owned file and directory have per-record and total limits. At 64 MiB, or after another write failure, the privileged operation continues with a warning and may have no audit record; the operator must archive, rotate, or repair the log. If a crash leaves an incomplete final line, the next writer truncates it back to the last complete JSON line before appending.
This is a local trace, not a remote immutable log resistant to root. Uninstall retains it by default and removes it only with explicit `--purge-audit`.
diff --git a/docs/security-model.md b/docs/security-model.md
index 6bc0b41..0ddd055 100644
--- a/docs/security-model.md
+++ b/docs/security-model.md
@@ -27,15 +27,19 @@
## sudo 的实际含义
-`--sudo` 写入账号专属 NOPASSWD sudoers,基本等同完整 root 权限。可信协作者取得 root 后可以创建 cron、systemd unit、SUID 文件、新账号或其他持久化。本工具撤销时只删除自己创建和登记的账号、授权及任务,不会猜测或清理对方以 root 创建的外部对象。
+`--sudo` 写入账号专属 NOPASSWD sudoers,基本等同完整 root 权限。可信协作者取得 root 后可以创建 cron、systemd unit、SUID 文件、新账号或其他持久化。撤销会删除本工具管理的账号、授权和自动撤销任务;为防止用户名/UID 复用继承延迟工作,还会删除目标账号的个人 crontab 以及该 UID 的全部 `at`/`batch` 任务。它不会猜测或清理对方以 root 创建的系统级 cron、systemd unit、SUID 文件、新账号或其他外部对象。
因此“临时账号”限制的是本工具管理的入口寿命,不是 root 行为的沙箱。不要把 sudo 邀请发给不可信对象。
## SSH 登录判定
-创建前会运行等价于 `sshd -T -C user=<新账号>` 的有效配置检查,展开 Include、Match 和发行版加密策略。只有能够证明公钥登录可用时,邀请才会把 `Login` 标记为已验证;无法完整判断时显示 `UNVERIFIED`,明确阻碍则拒绝创建。
+创建前会运行等价于 `sshd -T -C user=<新账号>` 的有效配置兼容性检查,展开 Include、Match 和发行版加密策略。只有检查未发现已知阻碍或无法判断的规则时,邀请才会显示“已对照 sshd 有效配置验证”;无法完整判断时显示 `UNVERIFIED`,未解决的配置检查阻碍则会拒绝创建。
-`--fix-sshd` 只写账号作用域 drop-in,并以 `Match all` 恢复后续配置作用域。写入前后分别通过 `sshd -t` 和有效配置检查,只 reload、不 restart。任一步失败都会删除文件并回滚。显式 `DenyUsers` 和 `DenyGroups` 不会被绕过。
+这个结论只说明计划凭据与检查到的 sshd 有效配置兼容,不是端到端 SSH 登录测试,也不能证明网络、防火墙、PAM、SELinux 或运行中 sshd 进程的全部状态。交付邀请前仍应按实际连接路径测试登录。
+
+未来账号在创建前尚无 NSS 用户组,OpenSSH 因此无法可靠求值 `Match Group`。若检查未发现其他已知阻碍、唯一的不确定项是未来用户组,工具才会先创建没有密码或公钥凭据的待定账号,再按真实用户组重新运行有效配置检查;只有后置检查通过后才写入凭据。已知阻碍仍按正常拒绝或显式修复流程处理,不会被延后判断掩盖。依赖来源地址、目标端口等连接属性的 `Match` 仍无法由用户组消除:公钥邀请会明确显示 `UNVERIFIED`,密码邀请则失败关闭。
+
+`--fix-sshd` 只写账号作用域 drop-in,并以 `Match all` 恢复后续配置作用域。写入前后分别通过 `sshd -t` 和有效配置检查;能找到运行中的 sshd 时只请求 reload、不会 restart。若没有可通知的运行中 daemon,配置文件会保留供 socket 激活或下次启动读取,但邀请标记为 `UNVERIFIED`。其他授权失败会尝试删除文件并回滚;删除或恢复 reload 本身失败时会返回非零,并按下文的不完整回滚规则保留恢复见证。显式 `DenyUsers` 和 `DenyGroups` 不会被绕过。
## 账号身份与防误删
@@ -48,19 +52,35 @@
自动撤销和普通 `revoke` 要求这些身份信息一致。账号被删除重建、UID 被复用、标记改变或登记损坏时会拒绝自动删除,而不是猜测同名账号仍是原对象。
-从旧登记格式迁移的固定标记账号显示为 `legacy-unverified`,不会被定时、批量清理或卸载自动删除。人工核对后才能使用带完整确认的 `revoke --force`。
+账号数据库项使用 `useradd -M -e 1970-01-01 -p '!'` 创建,因此从出现起就处于过去日期过期且密码锁定的状态,并且不会复制可能含有本机认证材料的 `/etc/skel`。完整 passwd 快照、非 root UID/GID 和 UID 残留进程扫描通过后,Home 在同名/UID 遗留 cron/at 清场、65 秒 daemon drain 和复查全部完成前仍保持不存在;只有清场后再次核对完整身份通过,程序才通过固定 `/home` 目录描述符创建权限为 `0700` 的空 Home 并设置其属主。准备期间账号一直保持过去日期过期;密码或公钥、sshd/sudo 策略、登记和自动撤销任务均完成后,最后一次 `chage` 才写入请求的到期日或永久值并激活登录。
+
+程序会在关键事务阶段重复核对完整 passwd 快照;完成 GECOS 身份标记和账号删除的路径还会在按用户名操作的 helper 返回后复核,以发现可观察到的同名替换。这些复核不能把 helper 变成原子的比较并交换。能够并发改写账号数据库的本机 root 仍属于信任边界。账号删除只调用不带 `-r/-f` 的 `userdel --`,不执行会读取 `/etc/deluser.conf` 并可能重新递归清理的发行版 `deluser`,也不接受编译期账号数据库语义不明的任意 BusyBox applet;shadow-utils 的 `-f` 还可能删除仍被其他账号用作主组的同名组,因此也被拒绝。
+
+从旧登记格式迁移的固定标记账号显示为 `legacy-unverified`,不会被定时、批量清理或卸载自动删除。旧版 timer 使用的 `--yes --force --confirm-force` 不能获得这类账号的删除授权;遗留任务会被报告为孤儿任务,并可由 `cleanup-expired --compact` 取消而不删除活账号或登记。人工核对后,必须在交互终端运行 `revoke --force` 并输入完整用户名确认。非交互调用始终拒绝删除这类账号。
+
+root、UID 0 和保留名称始终不会删除。低 UID 账号只有在当前登记 UID、随机世代与精确 GECOS 标记完整绑定时,才能作为本工具创建的账号撤销;未登记或旧身份格式的低 UID 账号即使使用 `--force` 也受保护。没有本工具精确标记的真实账号同样不会作为受管账号删除。
-即使使用 `--force`,root、UID 0、低 UID 系统账号和没有本工具精确标记的真实账号也不会作为受管账号删除。
+## 延迟任务、进程与身份复用
-## 进程与 PID 复用
+个人 crontab 和 `at`/`batch` 任务不会因禁用登录、杀掉当前进程或普通 `userdel --` 而可靠消失。新账号在获取密码、公钥或 sudo 授权前,以及旧账号释放用户名/UID 前,都会删除并复核同名个人 crontab,通过 `atq` 和 `at -c` 生成的 `atrun uid=` 头盘点全部任务,并删除目标 UID 的任务。每次调用 `atrm` 前都会重新读取同一 ID,并再次绑定预期 UID 或本工具的精确撤销命令;删除失败后也会复核目标是否仍存在或 ID 是否已被复用。本工具自己的自动撤销作业只在 `atrun` 头表明其属于 root 时才被识别;程序先在 64 KiB 上限内读取 owner 头,因此已确定属于非 root 的超大作业不会阻断自动任务清单,而 root 作业才会在更大的有界范围内读取完整正文。外部 `at` 接口没有原子的比较删除操作,所以重新读取到 `atrm` 之间仍有极短窗口;能在该窗口替换作业的本机 root 属于信任边界。部分安装的 at 工具、损坏/过大的队列输出、无法解析的所有者或存活文件都会 fail closed。
-撤销账号前会检查该 UID 的进程,并使用 Linux pidfd 将信号绑定到已经检查过的进程实例,避免 PID 复用后误杀无关进程。Linux 5.3、`pidfd_open` 和 `pidfd_send_signal` 是安全撤销所需能力;`doctor` 会实测,`invite` 在能力不可用时拒绝创建账号。
+直接 spool 复核明确支持的 cron 目录是 `/var/spool/cron/crontabs`、`/var/spool/cron` 和 `/var/spool/cron/tabs`;at 目录是 `/var/spool/cron/atjobs`、`/var/spool/at` 和 `/var/spool/atjobs`。未使用这些常规布局的实现不在这项文件级复核范围内。检测到 cron/at 命令足迹,或在 `/proc/*/comm` 中看到仍运行的 `cron`、`crond`、`atd` 时,账号会继续占用身份并保持禁用 65 秒,让 daemon 完成清理前已读取的到期任务,然后再次清理任务和 UID 进程;若进程清单无法可靠扫描,也会保守等待而不是跳过窗口。
+
+撤销账号前会逐个检查该 UID 线程组中的所有存活线程;主线程已经是 zombie 但工作线程仍在运行的线程组不会被当成空进程。Linux pidfd 把信号绑定到检查过的线程组实例,避免 PID 复用后误杀无关进程,且打开 pidfd 后还会再次核对线程凭据。每轮 SIGKILL 后都会重新扫描该 UID,只有连续两次稳定的逐线程复扫都观察不到存活进程才会继续删除账号;任一 TGID/TID 在读取前消失会重置确认,扫描不可靠或达到有界重试次数都会 fail closed。Linux 5.3、`pidfd_open` 和 `pidfd_send_signal` 是安全撤销所需能力;`doctor` 会实测,`invite` 在能力不可用时拒绝创建账号。
+
+`/proc`、pidfd、cron 和 at 检查是使用同一生命周期锁的重复有界快照,不是内核级原子冻结。它们会大幅缩小竞态窗口并在观察不可靠时保留禁用账号,但无法屏蔽不经该锁操作账号数据库或调度器的本机 root;后者仍在信任边界内。具有 sudo 的协作者另行创建的系统级 cron、systemd unit 或其他 root 持久化也不属于个人任务清理范围。
## 事务、锁与回滚
invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提交共用 root 生命周期锁,账号、授权、登记、任务和二进制变更不会互相穿插。需要人工等待的确认、依赖安装、下载和验签尽量在锁外完成;取得锁后会重新核验即将提交的状态。
-创建中任一步失败都会尝试回滚任务、sudoers、sshd 例外、登记和新账号。回滚失败会明确报告并返回非零,不会把部分成功显示为成功。
+同名 `invite` 另持独占账号屏障,当前撤销持共享侧屏障。兼容旧任务的不带 UID/世代 `revoke --yes` 若发现独占屏障已被同名创建占用,会不作删除并以成功状态跳过,防止旧 systemd 任务在创建结束后重试并命中新世代;人工发出的同形非交互命令也会被跳过,必须在并发操作完成后运行 `doctor` 并重新撤销。这是安全优先的迁移边界,而不是已经删除账号的证明。已经载入、且在新版屏障生效前开始等待全局锁的旧二进制无法由新版进程锁完全追溯;创建流程还会扫描精确的 root-owned 旧撤销进程并拒绝用户名复用,但系统 helper 和 `/proc` 观察仍不是原子 compare-and-swap,本机 root 保持在信任边界内。
+
+登记格式 v4 在所有删除前检查完成后、`userdel` 之前写入 `DeletionStarted`。精确世代账号保留 UID 与世代绑定;旧版、未登记及 pending 回滚只保留 UID 见证,避免一次已经授权的删除在账号消失后失去 mail spool 清扫依据,同时不把不完整身份变成无人值守删除权限。删除后的恢复只允许核对 UID 所有者的常规 mail spool 清扫,不会按缺失账号的旧 Home 路径递归删除。恢复行不能由普通登记更新、删除或 compact 覆盖,同名创建也必须等待恢复完成。活着的 UID-only 或世代不匹配账号只允许交互式 `--force` 人工恢复;旧自动任务会作为失效任务解除,避免无权完成恢复的无人值守命令持续重试。
+
+创建失败会运行回滚栈,清理任务、sudoers、sshd 例外、登记和能够以创建时完整身份确认的新账号。若半创建账号的身份、授权清理或递归 Home 清理无法确认,工具会保留账号及登记供人工恢复,而不会按用户名猜测删除。任何回滚不完整都会明确报告并返回非零,不会把部分成功显示为成功。
+
+新账号取得的 UID 若已有残留进程,或 `/proc` 扫描无法给出可靠结论,工具会保留没有密码或公钥凭据、已经过期且密码锁定、尚未创建 Home 的 pending 账号来占住该 UID,并保留登记供人工恢复;此时不会删除账号后立即把同一 UID 暴露给下一次分配。
撤销时如果用户名授权无法完全移除,会保留并尝试禁用账号,避免残留授权在用户名复用后重新生效。操作者应把任何回滚或撤销错误视为未解决的安全事件。
@@ -69,14 +89,14 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提
- 登记表、偏好和审计目录要求 root 所有及严格权限;
- 登记表严格验证 schema、字段、UID、世代和大小,损坏或不可读时 fail closed;
- 安装、升级和状态写入使用同目录临时文件、元数据验证、原子替换和必要的 fsync;
-- SSH 家目录必须属于目标 UID,递归删除拒绝 root/UID 0 家目录及活跃挂载边界;
+- 新账号只能使用创建前不存在的确定路径 `/home/<用户名>`;`/home` 必须由 root 管理,创建后的真实目录必须属于目标非 root UID/GID。撤销会在完整账号身份仍可核对时清理该目录及 UID 匹配的常规 mail spool,并在账号 helper 确认账号消失后再次清扫 mail,以处理 Home 清理期间的重建。Home 递归删除使用目录描述符;Home 根若为符号链接、属主不符或跨越活跃挂载边界会被拒绝,内部符号链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,单次阻塞的文件系统调用不能由该期限中断;mail spool 只接受受信目录中的非链接普通文件;
- sudoers、sshd 例外和自动任务使用受限、可预测的项目命名,只删除经过身份验证的受管对象。
不要手工编辑 `/var/lib/linux-temp-admin/v2/registry.tsv`。读不到登记状态不会被当作“没有账号”。
## 到期撤销
-精确截止由 systemd timer 或已有的 `at` 后端执行;`chage -E` 只是按天粒度的兜底锁定。两个调度后端都不可用时,邀请创建会回滚。
+请求的有效期在新 UID 的延迟任务清场和 65 秒 daemon drain 完成后开始,再只转换一次为向上取整到整分钟的绝对截止时间;因此安全等待不会缩短名义访问时长,取整最多多不到一分钟。邀请显示、`chage -E` 兜底日期、systemd timer 和 `at` 都由这一目标生成;`at` 使用绝对 UTC 分钟,不会因夏令时跳变而提前撤销。`chage -E` 仍只是更晚、按天粒度的锁定兜底。systemd 不可用,或其排程失败且任务已安全回滚时,才尝试 `at`;任一后端都无法成功排程,或者排程前截止时间已到时,邀请创建会回滚。调度器忙碌、主机停机和撤销重试都可能让实际删除延后,因此不再需要的访问应手动立即撤销。
撤销任务再次验证 UID、世代标识、GECOS 和登记行。身份不匹配、登记丢失或账号已重建时会安全跳过,交由管理员检查。systemd 撤销失败会限速重试;一次性后端失败需要 `doctor` 和人工处理。
@@ -92,7 +112,7 @@ README 的便利入口把官方镜像返回的安装脚本直接交给 root shel
## 审计日志
-特权操作以 JSON 行追加到 `/var/log/linux-temp-admin/audit.log`,记录时间、调用者、动作、目标和结果。文件和目录为 root 所有,单条记录和总大小都有上限;达到 64 MiB 后操作继续但会警告管理员归档或轮转。
+特权操作会尽力以 JSON 行追加到 `/var/log/linux-temp-admin/audit.log`,记录时间、调用者、动作、目标和结果。文件和目录为 root 所有,单条记录和总大小都有上限。达到 64 MiB 或发生其他写入失败时,特权操作仍会继续并发出警告,因此该次操作可能没有审计记录;管理员必须归档、轮转或修复写入问题。若崩溃留下未完成的末行,下一次写入会先回退到最后一条完整 JSON 行。
审计日志用于本机追踪,不是防 root 篡改的远程不可变日志。卸载默认保留它,只有显式 `--purge-audit` 才删除。
diff --git a/internal/audit/audit.go b/internal/audit/audit.go
index a3eb9d5..90e96f0 100644
--- a/internal/audit/audit.go
+++ b/internal/audit/audit.go
@@ -1,8 +1,8 @@
-// Package audit appends a root-owned, append-only record of every privileged
+// Package audit attempts to append a root-owned record of each privileged
// mutating operation (account create/delete, sudo grant, install/uninstall/
-// upgrade) to a log file. Each entry is one JSON object per line and records
-// when, who (the invoking user under sudo, plus the effective uid), what, the
-// target, and the result — giving an operator-attributable trail.
+// upgrade) to a log file. Each completed entry is one JSON object per line and
+// records when, who (the invoking user under sudo, plus the effective uid), what,
+// the target, and the result — giving an operator-attributable trail.
//
// The log lives in a root-owned 0700 directory and is written 0600 with
// O_NOFOLLOW, so an unprivileged local user can neither read nor redirect it.
@@ -11,6 +11,7 @@
package audit
import (
+ "bytes"
"encoding/json"
"errors"
"fmt"
@@ -52,8 +53,9 @@ type record struct {
Fields map[string]string `json:"fields,omitempty"`
}
-// Logger appends events to a file. Fields are injectable so tests can point at a
-// temporary path and supply a fixed clock/actor.
+// Logger appends events to File, which must be a direct child of the absolute
+// Dir. Fields are injectable so tests can point the complete layout at a
+// temporary directory and supply a fixed clock/actor.
type Logger struct {
Dir string
File string
@@ -84,14 +86,18 @@ func realActor() (string, int) {
// Log appends one event. It is best-effort from the caller's perspective (it
// returns any error so the caller can warn). New writers serialize with flock;
// a failed write is truncated back to its locked starting size and the completed
-// line is synced before success. This sharply limits partial tails, but an on-host
-// log cannot promise atomicity across a kernel/filesystem crash or a concurrent
-// writer from an older build that does not honor the lock. A nil/empty-path Logger
-// is a no-op, which disables auditing (e.g. in tests).
+// line is synced before success. After a crash, the next writer truncates an
+// incomplete tail back to the last newline before appending, preserving JSONL
+// framing. A concurrent writer from an older build that does not honor the lock
+// can still violate this protocol. A nil/empty-path Logger is a no-op, which
+// disables auditing (e.g. in tests).
func (l *Logger) Log(ev Event) error {
if l == nil || l.Dir == "" || l.File == "" {
return nil
}
+ if err := l.validateLayout(); err != nil {
+ return err
+ }
if err := fsutil.EnsureDir(l.Dir, 0o700, 0, 0); err != nil {
return fmt.Errorf("audit dir: %w", err)
}
@@ -128,10 +134,10 @@ func (l *Logger) Log(ev Event) error {
if len(line) > maxAuditRecordBytes {
return fmt.Errorf("audit record exceeds %d bytes", maxAuditRecordBytes)
}
- // Append-only, refusing to follow a symlink planted at the path. Existing logs
- // are repaired to the required metadata through the descriptor and then
- // re-checked before any event is written.
- f, created, err := openAuditFile(l.File)
+ // Open the append-oriented log without following a symlink planted at the path.
+ // Existing logs are repaired to the required metadata through the descriptor
+ // and then re-checked before any event is written.
+ f, _, err := openAuditFile(l.File)
if err != nil {
return fmt.Errorf("open audit log: %w", err)
}
@@ -145,7 +151,13 @@ func (l *Logger) Log(ev Event) error {
if err != nil {
return fmt.Errorf("stat locked audit log: %w", err)
}
- start := fi.Size()
+ if fi.Size() > maxAuditLogBytes {
+ return fmt.Errorf("audit log reached its %d-byte limit; archive or rotate it before retrying", maxAuditLogBytes)
+ }
+ start, err := l.repairIncompleteTail(f, fi.Size())
+ if err != nil {
+ return fmt.Errorf("repair incomplete audit record: %w", err)
+ }
if start < 0 || start > maxAuditLogBytes-int64(len(line)) {
return fmt.Errorf("audit log reached its %d-byte limit; archive or rotate it before retrying", maxAuditLogBytes)
}
@@ -166,14 +178,70 @@ func (l *Logger) Log(ev Event) error {
// durable either, and a complete possibly-durable record is the safer state.
return fmt.Errorf("sync audit record: %w", err)
}
- if created {
- if err := syncAuditDirectory(filepath.Dir(l.File)); err != nil {
- return fmt.Errorf("sync new audit log directory entry: %w", err)
- }
+ // Sync the parent even for an existing path. It may be the visible result of a
+ // previous append whose new-file directory sync failed; retrying only the file
+ // sync would otherwise report success without finishing that durability step.
+ if err := syncAuditDirectory(filepath.Dir(l.File)); err != nil {
+ return fmt.Errorf("sync audit log directory: %w", err)
}
return nil
}
+func (l *Logger) validateLayout() error {
+ dir := filepath.Clean(l.Dir)
+ file := filepath.Clean(l.File)
+ if !filepath.IsAbs(l.Dir) || dir != l.Dir || dir == string(filepath.Separator) {
+ return fmt.Errorf("unsafe audit directory %q", l.Dir)
+ }
+ if !filepath.IsAbs(l.File) || file != l.File || filepath.Dir(file) != dir || file == dir {
+ return fmt.Errorf("audit file %q must be a direct child of %s", l.File, dir)
+ }
+ return nil
+}
+
+func (l *Logger) repairIncompleteTail(f *os.File, size int64) (int64, error) {
+ if size <= 0 {
+ return size, nil
+ }
+ var last [1]byte
+ if _, err := f.ReadAt(last[:], size-1); err != nil {
+ return 0, err
+ }
+ if last[0] == '\n' {
+ return size, nil
+ }
+
+ const blockSize = 64 << 10
+ buf := make([]byte, blockSize)
+ newSize := int64(0)
+ for end := size; end > 0; {
+ start := end - int64(len(buf))
+ if start < 0 {
+ start = 0
+ }
+ want := int(end - start)
+ n, err := f.ReadAt(buf[:want], start)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return 0, err
+ }
+ if n != want {
+ return 0, io.ErrUnexpectedEOF
+ }
+ if i := bytes.LastIndexByte(buf[:n], '\n'); i >= 0 {
+ newSize = start + int64(i) + 1
+ break
+ }
+ end = start
+ }
+ if err := f.Truncate(newSize); err != nil {
+ return 0, err
+ }
+ if err := l.syncFile(f); err != nil {
+ return 0, err
+ }
+ return newSize, nil
+}
+
func (l *Logger) syncFile(f *os.File) error {
if l.sync != nil {
return l.sync(f)
@@ -205,7 +273,7 @@ func wrapIfErr(prefix string, err error) error {
return fmt.Errorf("%s: %w", prefix, err)
}
-func syncAuditDirectory(path string) error {
+var syncAuditDirectory = func(path string) error {
dir, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0)
if err != nil {
return err
@@ -224,13 +292,16 @@ func openAuditFile(path string) (*os.File, bool, error) {
if !ok {
return nil, false, fmt.Errorf("cannot determine inode of %s", path)
}
+ if st.Nlink != 1 {
+ return nil, false, fmt.Errorf("%s has %d hard links; refusing to mutate a shared inode", path, st.Nlink)
+ }
copy := *st
before = ©
} else if !os.IsNotExist(err) {
return nil, false, err
}
created := before == nil
- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0o600)
+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0o600)
if err != nil {
return nil, false, err
}
@@ -249,6 +320,9 @@ func openAuditFile(path string) (*os.File, bool, error) {
if !ok {
return fail(fmt.Errorf("cannot determine owner of %s", path))
}
+ if st.Nlink != 1 {
+ return fail(fmt.Errorf("%s has %d hard links; refusing to mutate a shared inode", path, st.Nlink))
+ }
if before != nil && (before.Dev != st.Dev || before.Ino != st.Ino) {
return fail(fmt.Errorf("%s was replaced while opening it", path))
}
@@ -263,7 +337,7 @@ func openAuditFile(path string) (*os.File, bool, error) {
return fail(err)
}
st, ok = fi.Sys().(*syscall.Stat_t)
- if !ok || !fi.Mode().IsRegular() || st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != 0o600 {
+ if !ok || !fi.Mode().IsRegular() || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != 0o600 {
return fail(fmt.Errorf("%s metadata remains unsafe after repair", path))
}
return f, created, nil
diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go
index 40b992c..0602874 100644
--- a/internal/audit/audit_test.go
+++ b/internal/audit/audit_test.go
@@ -100,6 +100,120 @@ func TestLogRepairsAndVerifiesExistingFileMetadata(t *testing.T) {
}
}
+func TestLogRepairsIncompleteExistingTail(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root")
+ }
+ for _, tc := range []struct {
+ name string
+ validFirst bool
+ partialPad int
+ want []string
+ }{
+ {name: "after complete record", validFirst: true, want: []string{"before", "after"}},
+ {name: "only incomplete record", want: []string{"after"}},
+ {name: "newline beyond one scan block", validFirst: true, partialPad: (64 << 10) + 17, want: []string{"before", "after"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ file := filepath.Join(dir, "audit.log")
+ l := &Logger{Dir: dir, File: file}
+ var existing []byte
+ if tc.validFirst {
+ if err := l.Log(Event{Action: "before"}); err != nil {
+ t.Fatal(err)
+ }
+ var err error
+ existing, err = os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ existing = append(existing, `{"time":"crash","action":"partial"`...)
+ existing = append(existing, strings.Repeat("x", tc.partialPad)...)
+ if err := os.WriteFile(file, existing, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := l.Log(Event{Action: "after"}); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSuffix(string(got), "\n"), "\n")
+ if len(lines) != len(tc.want) {
+ t.Fatalf("audit line count = %d, want %d: %q", len(lines), len(tc.want), got)
+ }
+ for i, line := range lines {
+ var rec record
+ if err := json.Unmarshal([]byte(line), &rec); err != nil {
+ t.Fatalf("line %d is invalid JSON after recovery: %v: %q", i, err, line)
+ }
+ if rec.Action != tc.want[i] {
+ t.Errorf("line %d action = %q, want %q", i, rec.Action, tc.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestLogRetryAfterTailRepairSyncFailure(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ file := filepath.Join(dir, "audit.log")
+ base := &Logger{Dir: dir, File: file}
+ if err := base.Log(Event{Action: "before"}); err != nil {
+ t.Fatal(err)
+ }
+ complete, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(file, append(append([]byte(nil), complete...), `{"partial":true`...), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ wantErr := errors.New("injected repaired-tail sync failure")
+ failing := &Logger{Dir: dir, File: file, sync: func(*os.File) error { return wantErr }}
+ if err := failing.Log(Event{Action: "not-appended"}); !errors.Is(err, wantErr) {
+ t.Fatalf("Log error = %v, want repaired-tail sync failure", err)
+ }
+ got, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(complete) {
+ t.Fatalf("failed repair sync did not leave the last complete boundary: got=%q want=%q", got, complete)
+ }
+ if err := base.Log(Event{Action: "retry"}); err != nil {
+ t.Fatalf("retry Log: %v", err)
+ }
+ got, err = os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSuffix(string(got), "\n"), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("audit line count after repair retry = %d, want 2: %q", len(lines), got)
+ }
+}
+
func TestLogRejectsExistingNonRegularFile(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires root")
@@ -200,6 +314,17 @@ func TestLogBoundsRecordAndTotalFileSize(t *testing.T) {
if err := os.Truncate(file, maxAuditLogBytes); err != nil {
t.Fatal(err)
}
+ f, err := os.OpenFile(file, os.O_WRONLY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.WriteAt([]byte{'\n'}, maxAuditLogBytes-1); err != nil {
+ _ = f.Close()
+ t.Fatal(err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatal(err)
+ }
if err := l.Log(Event{Action: "at-cap"}); err == nil || !strings.Contains(err.Error(), "archive or rotate") {
t.Fatalf("full audit log error = %v, want total-size refusal", err)
}
@@ -210,6 +335,96 @@ func TestLogBoundsRecordAndTotalFileSize(t *testing.T) {
if fi.Size() != maxAuditLogBytes {
t.Fatalf("refused append changed audit size to %d, want %d", fi.Size(), maxAuditLogBytes)
}
+
+ if err := os.Truncate(file, maxAuditLogBytes+1); err != nil {
+ t.Fatal(err)
+ }
+ if err := l.Log(Event{Action: "over-cap"}); err == nil || !strings.Contains(err.Error(), "archive or rotate") {
+ t.Fatalf("oversized audit log error = %v, want pre-repair size refusal", err)
+ }
+ fi, err = os.Stat(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Size() != maxAuditLogBytes+1 {
+ t.Fatalf("oversized audit log was scanned/repaired before refusal: size=%d", fi.Size())
+ }
+}
+
+func TestLogRejectsFileOutsideDedicatedDirectoryWithoutMutation(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ victim := filepath.Join(t.TempDir(), "victim")
+ want := []byte("do not append\n")
+ if err := os.WriteFile(victim, want, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := (&Logger{Dir: dir, File: victim}).Log(Event{Action: "outside"}); err == nil {
+ t.Fatal("Logger accepted an audit file outside its dedicated directory")
+ }
+ got, err := os.ReadFile(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("outside file content changed: %q", got)
+ }
+ fi, err := os.Stat(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o644 {
+ t.Fatalf("outside file mode changed to %o", fi.Mode().Perm())
+ }
+}
+
+func TestLogRejectsHardLinkedFileWithoutMutation(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ victim := filepath.Join(t.TempDir(), "outside")
+ want := []byte("outside content must not change\n")
+ if err := os.WriteFile(victim, want, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ file := filepath.Join(dir, "audit.log")
+ if err := os.Link(victim, file); err != nil {
+ t.Fatal(err)
+ }
+
+ err := (&Logger{Dir: dir, File: file}).Log(Event{Action: "hard-link"})
+ if err == nil || !strings.Contains(err.Error(), "hard links") {
+ t.Fatalf("hard-linked audit file error = %v, want shared-inode refusal", err)
+ }
+ got, err := os.ReadFile(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("outside hard link content changed: %q", got)
+ }
+ fi, err := os.Stat(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o644 {
+ t.Fatalf("outside hard link mode changed to %o", fi.Mode().Perm())
+ }
}
func TestLogRollsBackPartialWrite(t *testing.T) {
@@ -328,6 +543,56 @@ func TestLogReportsSyncFailureAfterCompleteLine(t *testing.T) {
}
}
+func TestLogRetriesDirectorySyncAfterFailure(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ realSync := syncAuditDirectory
+ t.Cleanup(func() { syncAuditDirectory = realSync })
+ calls := 0
+ wantErr := errors.New("injected audit directory sync failure")
+ syncAuditDirectory = func(path string) error {
+ calls++
+ if calls == 1 {
+ return wantErr
+ }
+ return realSync(path)
+ }
+
+ l := &Logger{Dir: dir, File: filepath.Join(dir, "audit.log")}
+ if err := l.Log(Event{Action: "first"}); !errors.Is(err, wantErr) {
+ t.Fatalf("first Log error = %v, want directory sync failure", err)
+ }
+ if err := l.Log(Event{Action: "retry"}); err != nil {
+ t.Fatalf("retry Log: %v", err)
+ }
+ if calls != 2 {
+ t.Fatalf("audit directory sync calls = %d, want retry after prior failure", calls)
+ }
+
+ b, err := os.ReadFile(l.File)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("audit line count after retry = %d, want 2: %q", len(lines), b)
+ }
+ for i, line := range lines {
+ if !json.Valid([]byte(line)) {
+ t.Fatalf("audit line %d is invalid JSON after retry: %q", i, line)
+ }
+ }
+}
+
func TestRealActor(t *testing.T) {
t.Setenv("SUDO_USER", "bob")
if a, _ := realActor(); a != "bob" {
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 1888daa..0756534 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -30,6 +30,7 @@ import (
"github.com/xxvcc/linux-temp-admin/internal/sudoers"
"github.com/xxvcc/linux-temp-admin/internal/sysinfo"
"github.com/xxvcc/linux-temp-admin/internal/user"
+ "github.com/xxvcc/linux-temp-admin/internal/userjobs"
"golang.org/x/term"
)
@@ -55,10 +56,11 @@ type App struct {
// SSHDConfig reads sshd's effective configuration for a user; injectable so a
// test's verdict comes from a fixture, not from the test host's own sshd.
SSHDConfig func(user string) (*sysinfo.SSHDConfig, error)
- // SSHDHasConnectionScopedMatch covers the part of sshd policy that a user-only
- // effective-config probe cannot evaluate. Keep it beside SSHDConfig so tests
- // can source the complete policy verdict from fixtures instead of the host.
- SSHDHasConnectionScopedMatch func() bool
+ // SSHDHasUnverifiableMatch covers the part of sshd policy that a user-only
+ // effective-config probe cannot evaluate in the current account phase. Keep it
+ // beside SSHDConfig so tests can source the complete policy verdict from
+ // fixtures instead of the host.
+ SSHDHasUnverifiableMatch func(accountExists bool) bool
InstallPath string
// StateDir and AuditLogDir are the paths an uninstall removes RECURSIVELY, so
@@ -84,10 +86,24 @@ type App struct {
// TerminateProcesses is injectable so revoke's fail-closed handling can be
// exercised without signalling real processes in tests.
TerminateProcesses func(int) error
+ // ClearScheduledJobs removes personal cron and at/batch work before an account
+ // identity can be released. DrainScheduledJobs waits out a daemon that may have
+ // read a due job before its spool entry disappeared. Both are injected together
+ // in tests so no host queue is inspected or delayed accidentally.
+ ClearScheduledJobs func(string, int) error
+ DrainScheduledJobs func() error
// LookupUser is the single passwd snapshot source for identity-sensitive CLI
// operations. Production uses user.Lookup; tests inject account replacement
// sequences without modifying the host account database.
LookupUser func(string) (user.Passwd, bool, error)
+ // ListMarkerAccounts discovers exact pending/legacy/generation passwd markers
+ // for uninstall's fail-closed inventory. Marker presence is only a blocker and
+ // must never be used as identity proof for automatic deletion.
+ ListMarkerAccounts func() ([]string, error)
+ // RunningLegacyRevoke detects an already-started name-only revoke command from
+ // an older release before invite reuses the username. Production scans /proc;
+ // tests inject the process inventory they intend to exercise.
+ RunningLegacyRevoke func(installPath, username string) (bool, error)
inReader *bufio.Reader // lazily wraps In; reused so buffered stdin isn't lost between prompts
}
@@ -95,29 +111,29 @@ type App struct {
// NewApp builds an App with real collaborators and the resolved language.
func NewApp(lang i18n.Lang) *App {
return &App{
- Out: os.Stdout,
- Err: os.Stderr,
- In: os.Stdin,
- P: i18n.Printer{Lang: lang},
- Users: user.New(),
- Sudoers: sudoers.New(),
- SSHD: sshdconf.New(),
- Scheduler: schedule.New(),
- Registry: registry.Default(),
- Detector: netdetect.New(),
- Selfmanage: selfmanage.New(config.InstallPath, config.MaxUpgradeBytes),
- Audit: audit.Default(),
- Lifecycle: lifecycle.New(config.LifecycleLockFile),
- SSHDConfig: sysinfo.SSHDEffective,
- SSHDHasConnectionScopedMatch: sysinfo.HasConnectionScopedMatch,
- InstallPath: config.InstallPath,
- StateDir: config.StateDir,
- AuditLogDir: config.AuditLogDir,
- Now: time.Now,
- RandHex: randHex,
- RandPassword: randPassword,
- StdoutIsTTY: func() bool { return term.IsTerminal(int(os.Stdout.Fd())) },
- StdinIsTTY: func() bool { return term.IsTerminal(int(os.Stdin.Fd())) },
+ Out: os.Stdout,
+ Err: os.Stderr,
+ In: os.Stdin,
+ P: i18n.Printer{Lang: lang},
+ Users: user.New(),
+ Sudoers: sudoers.New(),
+ SSHD: sshdconf.New(),
+ Scheduler: schedule.New(),
+ Registry: registry.Default(),
+ Detector: netdetect.New(),
+ Selfmanage: selfmanage.New(config.InstallPath, config.MaxUpgradeBytes),
+ Audit: audit.Default(),
+ Lifecycle: lifecycle.New(config.LifecycleLockFile),
+ SSHDConfig: sysinfo.SSHDEffective,
+ SSHDHasUnverifiableMatch: sysinfo.HasUnverifiableMatch,
+ InstallPath: config.InstallPath,
+ StateDir: config.StateDir,
+ AuditLogDir: config.AuditLogDir,
+ Now: time.Now,
+ RandHex: randHex,
+ RandPassword: randPassword,
+ StdoutIsTTY: func() bool { return term.IsTerminal(int(os.Stdout.Fd())) },
+ StdinIsTTY: func() bool { return term.IsTerminal(int(os.Stdin.Fd())) },
TerminalWidth: func() int {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
@@ -128,7 +144,13 @@ func NewApp(lang i18n.Lang) *App {
Geteuid: os.Geteuid,
RemoveAll: os.RemoveAll,
TerminateProcesses: user.TerminateProcesses,
+ ClearScheduledJobs: userjobs.Clear,
+ DrainScheduledJobs: userjobs.WaitForDrain,
LookupUser: user.Lookup,
+ ListMarkerAccounts: user.LifecycleMarkerAccounts,
+ RunningLegacyRevoke: func(installPath, username string) (bool, error) {
+ return runningLegacyRevokeProcess("/proc", installPath, username)
+ },
}
}
@@ -139,6 +161,20 @@ func (a *App) lookupUser(name string) (user.Passwd, bool, error) {
return user.Lookup(name)
}
+func (a *App) listMarkerAccounts() ([]string, error) {
+ if a.ListMarkerAccounts != nil {
+ return a.ListMarkerAccounts()
+ }
+ return user.LifecycleMarkerAccounts()
+}
+
+func (a *App) runningLegacyRevoke(username string) (bool, error) {
+ if a.RunningLegacyRevoke != nil {
+ return a.RunningLegacyRevoke(a.InstallPath, username)
+ }
+ return runningLegacyRevokeProcess("/proc", a.InstallPath, username)
+}
+
func (a *App) terminateProcesses(uid int) error {
if a.TerminateProcesses != nil {
return a.TerminateProcesses(uid)
@@ -146,6 +182,20 @@ func (a *App) terminateProcesses(uid int) error {
return user.TerminateProcesses(uid)
}
+func (a *App) clearScheduledJobs(name string, uid int) error {
+ if a.ClearScheduledJobs == nil {
+ return fmt.Errorf("scheduled-job cleanup is not configured")
+ }
+ return a.ClearScheduledJobs(name, uid)
+}
+
+func (a *App) drainScheduledJobs() error {
+ if a.DrainScheduledJobs == nil {
+ return fmt.Errorf("scheduled-job drain is not configured")
+ }
+ return a.DrainScheduledJobs()
+}
+
func randHex(nBytes int) (string, error) {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
@@ -476,6 +526,10 @@ func (a *App) withLifecycleLockMode(allowUninstalled bool, fn func() int) int {
a.errorf("%s: %v", a.P.M("无法获取生命周期锁", "cannot acquire the lifecycle lock"), err)
return 1
}
+ return a.withAcquiredLifecycleLock(allowUninstalled, release, fn)
+}
+
+func (a *App) withAcquiredLifecycleLock(allowUninstalled bool, release func() error, fn func() int) int {
if !allowUninstalled {
uninstalled, markerErr := a.Lifecycle.IsUninstalled()
if markerErr != nil {
@@ -498,6 +552,72 @@ func (a *App) withLifecycleLockMode(allowUninstalled bool, fn func() int) int {
return rc
}
+// accountLifecycleLock is a per-username reader/writer barrier adjacent to the
+// global lifecycle lock. Account locks are always acquired before the global
+// lock: invite is the exclusive writer, while revoke is a shared reader whose
+// actual mutation remains serialized by the global lock.
+func (a *App) accountLifecycleLock(username string) *lifecycle.Lock {
+ if a.Lifecycle == nil || a.Lifecycle.Path == "" {
+ return nil
+ }
+ return lifecycle.New(a.Lifecycle.Path + ".account-" + username)
+}
+
+func (a *App) withAccountExclusiveLock(username string, fn func() int) int {
+ lock := a.accountLifecycleLock(username)
+ if lock == nil {
+ return fn()
+ }
+ release, err := lock.Acquire()
+ if err != nil {
+ a.errorf("%s: %v", a.P.M("无法获取账号生命周期锁", "cannot acquire the account lifecycle lock"), err)
+ return 1
+ }
+ return a.withAcquiredAccountLock(release, fn)
+}
+
+func (a *App) withAccountSharedLock(username string, fn func() int) int {
+ lock := a.accountLifecycleLock(username)
+ if lock == nil {
+ return fn()
+ }
+ release, err := lock.AcquireShared()
+ if err != nil {
+ a.errorf("%s: %v", a.P.M("无法获取账号生命周期锁", "cannot acquire the account lifecycle lock"), err)
+ return 1
+ }
+ return a.withAcquiredAccountLock(release, fn)
+}
+
+// withAccountTrySharedLock distinguishes contention from an acquisition error.
+// A legacy name-only revoke may be abandoned only when an invite already owns
+// the exclusive barrier for that same username; unrelated lifecycle work still
+// queues normally under the global lock.
+func (a *App) withAccountTrySharedLock(username string, fn func() int) (rc int, busy bool) {
+ lock := a.accountLifecycleLock(username)
+ if lock == nil {
+ return fn(), false
+ }
+ release, err := lock.TryAcquireShared()
+ if errors.Is(err, lifecycle.ErrBusy) {
+ return 0, true
+ }
+ if err != nil {
+ a.errorf("%s: %v", a.P.M("无法获取账号生命周期锁", "cannot acquire the account lifecycle lock"), err)
+ return 1, false
+ }
+ return a.withAcquiredAccountLock(release, fn), false
+}
+
+func (a *App) withAcquiredAccountLock(release func() error, fn func() int) int {
+ rc := fn()
+ if err := release(); err != nil {
+ a.errorf("%s: %v", a.P.M("无法释放账号生命周期锁", "cannot release the account lifecycle lock"), err)
+ return 1
+ }
+ return rc
+}
+
const (
maxInteractiveLineBytes = 64 << 10
rejectedInteractiveLine = "\x00"
diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go
index f3f0fa2..c487840 100644
--- a/internal/cli/cli_e2e_test.go
+++ b/internal/cli/cli_e2e_test.go
@@ -39,23 +39,25 @@ const (
// fakeSched satisfies schedule.System without touching real systemd/at.
type fakeSched struct{}
-func (fakeSched) HasSystemctl() bool { return false }
-func (fakeSched) Systemctl(...string) error { return nil }
-func (fakeSched) HasAt() bool { return true }
-func (fakeSched) ScheduleAt(string, int) (string, error) { return "1", nil }
-func (fakeSched) RemoveAtJobsFor(string) error { return nil }
-func (fakeSched) AtrmJob(string) error { return nil }
-func (fakeSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
+func (fakeSched) HasSystemctl() bool { return false }
+func (fakeSched) Systemctl(...string) error { return nil }
+func (fakeSched) HasAt() bool { return true }
+func (fakeSched) ScheduleAt(string, time.Time) (string, error) { return "1", nil }
+func (fakeSched) RemoveAtJobsFor(string) error { return nil }
+func (fakeSched) AtrmJob(string) error { return nil }
+func (fakeSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
type unavailableSched struct{}
-func (unavailableSched) HasSystemctl() bool { return false }
-func (unavailableSched) Systemctl(...string) error { return nil }
-func (unavailableSched) HasAt() bool { return false }
-func (unavailableSched) ScheduleAt(string, int) (string, error) { return "", errors.New("unavailable") }
-func (unavailableSched) RemoveAtJobsFor(string) error { return nil }
-func (unavailableSched) AtrmJob(string) error { return nil }
-func (unavailableSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
+func (unavailableSched) HasSystemctl() bool { return false }
+func (unavailableSched) Systemctl(...string) error { return nil }
+func (unavailableSched) HasAt() bool { return false }
+func (unavailableSched) ScheduleAt(string, time.Time) (string, error) {
+ return "", errors.New("unavailable")
+}
+func (unavailableSched) RemoveAtJobsFor(string) error { return nil }
+func (unavailableSched) AtrmJob(string) error { return nil }
+func (unavailableSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
type trackingSched struct {
jobs map[string]string
@@ -69,7 +71,7 @@ func newTrackingSched() *trackingSched { return &trackingSched{jobs: map[string]
func (*trackingSched) HasSystemctl() bool { return false }
func (*trackingSched) Systemctl(...string) error { return nil }
func (*trackingSched) HasAt() bool { return true }
-func (s *trackingSched) ScheduleAt(command string, _ int) (string, error) {
+func (s *trackingSched) ScheduleAt(command string, _ time.Time) (string, error) {
if s.beforeSchedule != nil {
if err := s.beforeSchedule(command); err != nil {
return "", err
@@ -145,6 +147,13 @@ func rootDir(t *testing.T, mode os.FileMode) string {
return d
}
+// These account-lifecycle tests isolate the scheduler with fakeSched and do not
+// exercise the host's personal cron/at queues. The dedicated userjobs suite owns
+// that behavior; make the isolation explicit so App's production fail-closed nil
+// checks do not turn an unrelated fixture omission into an account leak.
+func noDeferredJobs(string, int) error { return nil }
+func noDeferredJobDrain() error { return nil }
+
func TestInviteThenRevokeEndToEnd(t *testing.T) {
if os.Getuid() != 0 {
t.Skip("requires root")
@@ -184,10 +193,10 @@ func TestInviteThenRevokeEndToEnd(t *testing.T) {
Dir: sshdDir, Validate: func() error { return nil }, Reload: func() error { return nil },
Effective: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil },
},
- SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil },
- SSHDHasConnectionScopedMatch: func() bool { return false },
- Detector: netdetect.New(),
- Selfmanage: &selfmanage.Manager{InstallPath: installPath},
+ SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil },
+ SSHDHasUnverifiableMatch: func(bool) bool { return false },
+ Detector: netdetect.New(),
+ Selfmanage: &selfmanage.Manager{InstallPath: installPath},
Audit: &audit.Logger{
Dir: filepath.Dir(auditFile), File: auditFile, Now: now,
Actor: func() (string, int) { return "e2e", 0 },
@@ -201,9 +210,11 @@ func TestInviteThenRevokeEndToEnd(t *testing.T) {
}
return "abcdef0123", nil
},
- StdoutIsTTY: func() bool { return true },
- StdinIsTTY: func() bool { return false },
- Geteuid: func() int { return 0 },
+ StdoutIsTTY: func() bool { return true },
+ StdinIsTTY: func() bool { return false },
+ Geteuid: func() int { return 0 },
+ ClearScheduledJobs: noDeferredJobs,
+ DrainScheduledJobs: noDeferredJobDrain,
}
// --- invite ---
@@ -491,8 +502,8 @@ func TestInviteFixSSHDThenRevokeEndToEnd(t *testing.T) {
Dir: sshdDir, Validate: func() error { return nil }, Effective: effective,
Reload: func() error { reloads++; return nil },
},
- SSHDConfig: effective,
- SSHDHasConnectionScopedMatch: func() bool { return false },
+ SSHDConfig: effective,
+ SSHDHasUnverifiableMatch: func(bool) bool { return false },
Scheduler: &schedule.Scheduler{
SystemdDir: rootDir(t, 0o755), InstallPath: installPath,
UnitPrefix: config.AutoRevokeUnitPrefix, Now: now, Sys: fakeSched{},
@@ -511,9 +522,11 @@ func TestInviteFixSSHDThenRevokeEndToEnd(t *testing.T) {
}
return "abcdef0123", nil
},
- StdoutIsTTY: func() bool { return true },
- StdinIsTTY: func() bool { return false },
- Geteuid: func() int { return 0 },
+ StdoutIsTTY: func() bool { return true },
+ StdinIsTTY: func() bool { return false },
+ Geteuid: func() int { return 0 },
+ ClearScheduledJobs: noDeferredJobs,
+ DrainScheduledJobs: noDeferredJobDrain,
}
// Without --fix-sshd, a non-interactive invite must refuse and change nothing:
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 938ca0d..74cae09 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"strconv"
@@ -17,8 +18,10 @@ import (
"github.com/xxvcc/linux-temp-admin/internal/buildinfo"
"github.com/xxvcc/linux-temp-admin/internal/config"
+ "github.com/xxvcc/linux-temp-admin/internal/fsutil"
"github.com/xxvcc/linux-temp-admin/internal/i18n"
"github.com/xxvcc/linux-temp-admin/internal/lifecycle"
+ "github.com/xxvcc/linux-temp-admin/internal/netdetect"
"github.com/xxvcc/linux-temp-admin/internal/prefs"
"github.com/xxvcc/linux-temp-admin/internal/registry"
"github.com/xxvcc/linux-temp-admin/internal/schedule"
@@ -36,12 +39,12 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { re
type failingScheduleSystem struct{}
-func (failingScheduleSystem) HasSystemctl() bool { return false }
-func (failingScheduleSystem) Systemctl(...string) error { return nil }
-func (failingScheduleSystem) HasAt() bool { return true }
-func (failingScheduleSystem) ScheduleAt(string, int) (string, error) { return "", nil }
-func (failingScheduleSystem) RemoveAtJobsFor(string) error { return nil }
-func (failingScheduleSystem) AtrmJob(string) error { return nil }
+func (failingScheduleSystem) HasSystemctl() bool { return false }
+func (failingScheduleSystem) Systemctl(...string) error { return nil }
+func (failingScheduleSystem) HasAt() bool { return true }
+func (failingScheduleSystem) ScheduleAt(string, time.Time) (string, error) { return "", nil }
+func (failingScheduleSystem) RemoveAtJobsFor(string) error { return nil }
+func (failingScheduleSystem) AtrmJob(string) error { return nil }
func (failingScheduleSystem) AtJobs() ([]schedule.AtJob, error) {
return nil, errors.New("at queue unreadable")
}
@@ -65,6 +68,11 @@ func (r *revokeRunner) RunInput(_ string, name string, args ...string) error {
func (*revokeRunner) Look(name string) bool { return name == "userdel" }
+var (
+ testClearScheduledJobs = func(string, int) error { return nil }
+ testDrainScheduledJobs = func() error { return nil }
+)
+
// newTestApp builds a minimal, root-free App: Geteuid is faked to 0 and the
// registry points at a temp dir. Collaborators that only the mutating paths need
// (Users/Sudoers/Scheduler/Selfmanage) are left nil; the tests here exercise
@@ -75,19 +83,51 @@ func newTestApp(t *testing.T, in string) (*App, *bytes.Buffer, *bytes.Buffer) {
var out, errb bytes.Buffer
a := &App{
Out: &out, Err: &errb, In: strings.NewReader(in),
- P: i18n.Printer{Lang: i18n.EN},
- Registry: ®istry.Store{Dir: dir, File: filepath.Join(dir, "r.tsv"), Lock: filepath.Join(dir, "r.lock")},
- InstallPath: filepath.Join(dir, "lta"),
- Now: func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) },
- RandHex: func(int) (string, error) { return "abcdef0123", nil },
- StdoutIsTTY: func() bool { return true },
- StdinIsTTY: func() bool { return false },
- Geteuid: func() int { return 0 },
- SSHDHasConnectionScopedMatch: func() bool { return false },
+ P: i18n.Printer{Lang: i18n.EN},
+ Registry: ®istry.Store{Dir: dir, File: filepath.Join(dir, "r.tsv"), Lock: filepath.Join(dir, "r.lock")},
+ InstallPath: filepath.Join(dir, "lta"),
+ Now: func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) },
+ RandHex: func(int) (string, error) { return "abcdef0123", nil },
+ StdoutIsTTY: func() bool { return true },
+ StdinIsTTY: func() bool { return false },
+ Geteuid: func() int { return 0 },
+ SSHDHasUnverifiableMatch: func(bool) bool { return false },
+ ClearScheduledJobs: testClearScheduledJobs,
+ DrainScheduledJobs: testDrainScheduledJobs,
+ ListMarkerAccounts: func() ([]string, error) { return nil, nil },
+ RunningLegacyRevoke: func(string, string) (bool, error) { return false, nil },
}
return a, &out, &errb
}
+func setTestRegistryRecord(t *testing.T, a *App, rec registry.Record) {
+ t.Helper()
+ dir := t.TempDir()
+ a.Registry = ®istry.Store{
+ Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"),
+ }
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Port == 0 {
+ rec.Port = 22
+ }
+ deletionStarted := rec.DeletionStarted
+ rec.DeletionStarted = false
+ if err := a.Registry.Record(rec); err != nil {
+ t.Fatal(err)
+ }
+ if deletionStarted {
+ generation := rec.Generation
+ if rec.Pending || !rec.IdentityBound {
+ generation = ""
+ }
+ if err := a.Registry.BeginDeletion(rec.User, rec.UID, generation); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
func TestPrintInviteClearsPrivateKeySource(t *testing.T) {
a, out, _ := newTestApp(t, "")
privatePEM := []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n-----END OPENSSH PRIVATE KEY-----\n")
@@ -380,6 +420,141 @@ func TestQueuedLifecycleMutationStopsAfterUninstallMarker(t *testing.T) {
}
}
+func TestLegacyUnboundRevokeSkipsSameNameInviteBarrier(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ a.Lifecycle = lifecycle.New(filepath.Join(t.TempDir(), "lifecycle.lock"))
+ release, err := a.accountLifecycleLock("xxvcc-a1").Acquire()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := release(); err != nil {
+ t.Error(err)
+ }
+ }()
+
+ started := time.Now()
+ rc := a.revoke([]string{"--user", "xxvcc-a1", "--yes"})
+ if rc != 0 {
+ t.Fatalf("contending legacy revoke rc = %d, want successful stale-job skip", rc)
+ }
+ if elapsed := time.Since(started); elapsed > time.Second {
+ t.Fatalf("legacy revoke queued behind the lifecycle lock for %s", elapsed)
+ }
+ if got := errb.String(); !strings.Contains(got, "no account was revoked") ||
+ !strings.Contains(got, "invoke revoke again") {
+ t.Fatalf("legacy revoke did not explain the non-destructive retry requirement: %q", got)
+ }
+}
+
+func TestGenerationBoundRevokeWaitsForSameNameInviteBarrier(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.Lifecycle = lifecycle.New(filepath.Join(t.TempDir(), "lifecycle.lock"))
+ release, err := a.accountLifecycleLock("xxvcc-a1").Acquire()
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan int, 1)
+ go func() {
+ done <- a.revoke([]string{
+ "--user", "xxvcc-a1", "--yes", "--force", "--confirm-force", "xxvcc-a1",
+ "--expected-uid", "1001", "--generation", "0123456789abcdef0123456789abcdef",
+ })
+ }()
+ select {
+ case rc := <-done:
+ _ = release()
+ t.Fatalf("generation-bound revoke bypassed serialization with rc=%d", rc)
+ case <-time.After(50 * time.Millisecond):
+ }
+ if err := release(); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case rc := <-done:
+ if rc != 0 {
+ t.Fatalf("stale generation-bound revoke rc = %d, want safe skip", rc)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("generation-bound revoke did not resume after account-barrier release")
+ }
+}
+
+func TestLegacyUnboundRevokeStillQueuesBehindUnrelatedGlobalMutation(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ a.Lifecycle = lifecycle.New(filepath.Join(t.TempDir(), "lifecycle.lock"))
+ release, err := a.Lifecycle.Acquire()
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan int, 1)
+ go func() {
+ done <- a.revoke([]string{"--user", "xxvcc-a1", "--yes"})
+ }()
+ select {
+ case rc := <-done:
+ _ = release()
+ t.Fatalf("legacy revoke treated unrelated global contention as stale with rc=%d", rc)
+ case <-time.After(50 * time.Millisecond):
+ }
+ if err := release(); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case rc := <-done:
+ if rc != 1 {
+ t.Fatalf("legacy revoke after global release rc=%d, want normal unregistered-user refusal", rc)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("legacy revoke did not resume after global lifecycle release")
+ }
+ if strings.Contains(errb.String(), "no account was revoked") {
+ t.Fatalf("unrelated global contention was reported as a stale same-name collision: %q", errb.String())
+ }
+}
+
+func TestLegacyUnboundRevokesShareSameNameBarrier(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "lifecycle.lock")
+ globalRelease, err := lifecycle.New(path).Acquire()
+ if err != nil {
+ t.Fatal(err)
+ }
+ a1, _, err1 := newTestApp(t, "")
+ a2, _, err2 := newTestApp(t, "")
+ a1.Lifecycle = lifecycle.New(path)
+ a2.Lifecycle = lifecycle.New(path)
+
+ done1 := make(chan int, 1)
+ done2 := make(chan int, 1)
+ go func() { done1 <- a1.revoke([]string{"--user", "xxvcc-a1", "--yes"}) }()
+ go func() { done2 <- a2.revoke([]string{"--user", "xxvcc-a1", "--yes"}) }()
+ for i, done := range []<-chan int{done1, done2} {
+ select {
+ case rc := <-done:
+ _ = globalRelease()
+ t.Fatalf("legacy revoke %d was swallowed while another reader held the same-name barrier: rc=%d", i+1, rc)
+ case <-time.After(50 * time.Millisecond):
+ }
+ }
+ if err := globalRelease(); err != nil {
+ t.Fatal(err)
+ }
+ for i, done := range []<-chan int{done1, done2} {
+ select {
+ case rc := <-done:
+ if rc != 1 {
+ t.Fatalf("legacy revoke %d rc=%d, want normal unregistered-user refusal", i+1, rc)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatalf("legacy revoke %d did not resume after global lifecycle release", i+1)
+ }
+ }
+ if strings.Contains(err1.String(), "no account was revoked") || strings.Contains(err2.String(), "no account was revoked") {
+ t.Fatalf("shared same-name revokes were treated as writer contention: first=%q second=%q", err1.String(), err2.String())
+ }
+}
+
func TestReadRunningBinaryUsesProcSelfExe(t *testing.T) {
got, err := (&App{}).readRunningBinary()
if err != nil {
@@ -445,13 +620,34 @@ func TestOrphanScanErrorsAreNotHealthy(t *testing.T) {
}
func TestDoctorFailsWhenSSHDLoginCannotBeConfirmed(t *testing.T) {
+ t.Run("future account phase", func(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n"), nil
+ }
+ var phases []bool
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool {
+ phases = append(phases, accountExists)
+ return !accountExists
+ }
+ if rc := a.doctor(nil); rc != 1 {
+ t.Fatalf("doctor rc=%d, want 1 for a pre-account Match uncertainty", rc)
+ }
+ if len(phases) != 2 || !phases[0] || phases[1] {
+ t.Fatalf("doctor Match account phases = %v, want [true false]", phases)
+ }
+ if !strings.Contains(errb.String(), "before creation") {
+ t.Fatalf("doctor did not report the pre-account uncertainty: %q", errb.String())
+ }
+ })
+
t.Run("connection-dependent rule", func(t *testing.T) {
a, _, errb := newTestApp(t, "")
a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\nallowusers xxvcc-doctor@203.0.113.0/24\n"), nil
}
- rep := a.checkKeyLogin(mustSSHDConfig(t, a, "xxvcc-doctor"), "xxvcc-doctor", []string{"xxvcc-doctor"})
+ rep := a.checkKeyLogin(mustSSHDConfig(t, a, "xxvcc-doctor"), "xxvcc-doctor", []string{"xxvcc-doctor"}, false)
if !rep.OK() || rep.Certain() {
t.Fatalf("fixture report: OK=%v Certain=%v blockers=%v unverifiable=%v", rep.OK(), rep.Certain(), rep.Blockers, rep.Unverifiable)
}
@@ -477,6 +673,78 @@ func TestDoctorFailsWhenSSHDLoginCannotBeConfirmed(t *testing.T) {
})
}
+func TestDoctorDependencyPolicyTreatsConditionalHelpersAsOptionalFeatures(t *testing.T) {
+ for _, label := range []string{"sudo", "visudo", "chpasswd"} {
+ if doctorDependencyIsFatal(label) {
+ t.Errorf("missing %s should disable only its invite feature, not fail the base doctor report", label)
+ }
+ }
+ for _, label := range []string{"id", "useradd", "usermod", "chage", "userdel"} {
+ if !doctorDependencyIsFatal(label) {
+ t.Errorf("missing core dependency %s should fail the doctor report", label)
+ }
+ }
+}
+
+func TestDoctorDescribesOnlyEffectiveConfigCredentialVerdicts(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ lang i18n.Lang
+ config string
+ wantOut string
+ wantErr string
+ forbidden string
+ }{
+ {
+ name: "English success",
+ lang: i18n.EN,
+ config: "pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n",
+ wantOut: "effective sshd config check found no blocker for a new account's key credential",
+ forbidden: "sshd accepts public-key logins",
+ },
+ {
+ name: "English blocker",
+ lang: i18n.EN,
+ config: "pubkeyauthentication no\nauthorizedkeysfile .ssh/authorized_keys\n",
+ wantErr: "effective sshd config check found a blocker for a freshly created temporary account's key credential",
+ forbidden: "sshd would not accept a public-key login",
+ },
+ {
+ name: "Chinese success",
+ lang: i18n.ZH,
+ config: "pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n",
+ wantOut: "sshd 有效配置检查未发现新账号公钥凭据的阻碍",
+ forbidden: "sshd 接受公钥登录",
+ },
+ {
+ name: "Chinese blocker",
+ lang: i18n.ZH,
+ config: "pubkeyauthentication no\nauthorizedkeysfile .ssh/authorized_keys\n",
+ wantErr: "sshd 有效配置检查发现新建临时账号公钥凭据的阻碍",
+ forbidden: "sshd 不会接受",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ a, out, errb := newTestApp(t, "")
+ a.P = i18n.Printer{Lang: tc.lang}
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD(tc.config), nil
+ }
+ _ = a.doctor(nil)
+ combined := out.String() + errb.String()
+ if tc.wantOut != "" && !strings.Contains(out.String(), tc.wantOut) {
+ t.Fatalf("doctor stdout missing %q: %q", tc.wantOut, out.String())
+ }
+ if tc.wantErr != "" && !strings.Contains(errb.String(), tc.wantErr) {
+ t.Fatalf("doctor stderr missing %q: %q", tc.wantErr, errb.String())
+ }
+ if strings.Contains(combined, tc.forbidden) {
+ t.Fatalf("doctor retained end-to-end claim %q: %q", tc.forbidden, combined)
+ }
+ })
+ }
+}
+
func mustSSHDConfig(t *testing.T, a *App, user string) *sysinfo.SSHDConfig {
t.Helper()
cfg, err := a.sshdConfig(user)
@@ -861,20 +1129,42 @@ func TestRevokeGuardsReject(t *testing.T) {
}
}
+func TestForceWarningDoesNotClaimManagedIdentityChecksAreBypassed(t *testing.T) {
+ a, _, errb := newTestApp(t, "not-alice\n")
+ a.LookupUser = func(string) (user.Passwd, bool, error) {
+ return user.Passwd{Name: "alice", UID: 1001, GID: 1001, Home: "/home/alice", Shell: "/bin/sh"}, true, nil
+ }
+ if rc := a.revoke([]string{"--user", "alice", "--force"}); rc != 0 {
+ t.Fatalf("cancelled revoke rc = %d, want 0", rc)
+ }
+ got := errb.String()
+ if !strings.Contains(got, "only if the remaining managed-identity checks also pass") {
+ t.Fatalf("force warning omits the protection boundary: %q", got)
+ }
+ if strings.Contains(got, "delete a real system user") {
+ t.Fatalf("force warning contradicts the real-account protection gate: %q", got)
+ }
+}
+
func TestTeardownLocalAccountStopsWhenDisableLoginIsIncomplete(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, Home: "/home/xxvcc-a1", Shell: "/bin/sh", GECOS: config.ManagedGenerationGECOSPrefix + generation}
for _, failedCommand := range []string{"chage", "usermod"} {
t.Run(failedCommand, func(t *testing.T) {
runner := &revokeRunner{failOn: failedCommand}
terminateCalls := 0
a := &App{
Users: &user.Manager{Runner: runner},
+ LookupUser: func(string) (user.Passwd, bool, error) {
+ return pw, true, nil
+ },
TerminateProcesses: func(int) error {
terminateCalls++
return nil
},
}
- stage, err := a.teardownLocalAccount("xxvcc-a1", user.Passwd{Name: "xxvcc-a1", UID: 1001})
+ stage, err := a.teardownLocalAccount("xxvcc-a1", pw, func() error { return nil })
if err == nil || stage != revokeDisableLogin {
t.Fatalf("teardownLocalAccount = stage %v, err %v; want disable failure", stage, err)
}
@@ -893,8 +1183,13 @@ func TestTeardownLocalAccountReachesDeleteOnlyAfterDisableSucceeds(t *testing.T)
pw := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, Home: "/home/xxvcc-a1", Shell: "/bin/sh", GECOS: config.ManagedGenerationGECOSPrefix + generation}
runner := &revokeRunner{failOn: "userdel"}
terminateCalls := 0
+ lookup := func(string) (user.Passwd, bool, error) { return pw, true, nil }
a := &App{
- Users: &user.Manager{Runner: runner},
+ Users: &user.Manager{
+ Runner: runner, LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { return nil },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ },
TerminateProcesses: func(uid int) error {
terminateCalls++
if uid != 1001 {
@@ -902,22 +1197,24 @@ func TestTeardownLocalAccountReachesDeleteOnlyAfterDisableSucceeds(t *testing.T)
}
return nil
},
- LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil },
+ LookupUser: lookup,
+ ClearScheduledJobs: testClearScheduledJobs,
+ DrainScheduledJobs: testDrainScheduledJobs,
}
- stage, err := a.teardownLocalAccount("xxvcc-a1", pw)
+ stage, err := a.teardownLocalAccount("xxvcc-a1", pw, func() error { return nil })
if err == nil || stage != revokeDeleteAccount {
t.Fatalf("teardownLocalAccount = stage %v, err %v; want delete failure", stage, err)
}
- if terminateCalls != 1 {
- t.Fatalf("TerminateProcesses calls = %d, want 1", terminateCalls)
+ if terminateCalls != 3 {
+ t.Fatalf("TerminateProcesses calls = %d, want initial two-pass drain plus final pre-userdel pass", terminateCalls)
}
if got, want := strings.Join(runner.calls, ","), "chage,usermod,userdel"; got != want {
t.Fatalf("account commands = %q, want %q", got, want)
}
}
-func TestRollbackInviteAccountRequiresCompletedIdentity(t *testing.T) {
+func TestRollbackInviteAccountRequiresCompleteCapturedIdentity(t *testing.T) {
runner := &revokeRunner{}
terminateCalls := 0
a := &App{
@@ -928,12 +1225,15 @@ func TestRollbackInviteAccountRequiresCompletedIdentity(t *testing.T) {
},
}
- for _, rec := range []registry.Record{
- {User: "xxvcc-a1", Pending: true},
- {User: "xxvcc-a1", UID: 0},
+ for _, tc := range []struct {
+ rec registry.Record
+ expected user.Passwd
+ }{
+ {rec: registry.Record{User: "xxvcc-a1", Pending: true}},
+ {rec: registry.Record{User: "xxvcc-a1", UID: 1001, Generation: "0123456789abcdef0123456789abcdef", IdentityBound: true}},
} {
- if err := a.rollbackInviteAccount("xxvcc-a1", rec, true); err == nil || !strings.Contains(err.Error(), "pending") {
- t.Fatalf("rollbackInviteAccount(%+v) error = %v, want pending-identity refusal", rec, err)
+ if err := a.rollbackInviteAccount("xxvcc-a1", tc.rec, tc.expected, true); err == nil || !strings.Contains(err.Error(), "identity") {
+ t.Fatalf("rollbackInviteAccount(%+v) error = %v, want incomplete-identity refusal", tc.rec, err)
}
}
if len(runner.calls) != 0 || terminateCalls != 0 {
@@ -948,8 +1248,18 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) {
t.Run("success", func(t *testing.T) {
runner := &revokeRunner{}
terminateCalls := 0
+ lookup := func(string) (user.Passwd, bool, error) {
+ if len(runner.calls) > 0 && runner.calls[len(runner.calls)-1] == "userdel" {
+ return user.Passwd{}, false, nil
+ }
+ return pw, true, nil
+ }
a := &App{
- Users: &user.Manager{Runner: runner},
+ Users: &user.Manager{
+ Runner: runner, LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { return nil },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ },
TerminateProcesses: func(uid int) error {
terminateCalls++
if uid != 1001 {
@@ -957,12 +1267,15 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) {
}
return nil
},
- LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil },
+ LookupUser: lookup,
+ ClearScheduledJobs: testClearScheduledJobs,
+ DrainScheduledJobs: testDrainScheduledJobs,
}
- if err := a.rollbackInviteAccount("xxvcc-a1", rec, true); err != nil {
+ setTestRegistryRecord(t, a, rec)
+ if err := a.rollbackInviteAccount("xxvcc-a1", rec, pw, true); err != nil {
t.Fatal(err)
}
- if terminateCalls != 1 || strings.Join(runner.calls, ",") != "chage,usermod,userdel" {
+ if terminateCalls != 3 || strings.Join(runner.calls, ",") != "chage,usermod,userdel" {
t.Fatalf("rollback order wrong: commands=%v terminateCalls=%d", runner.calls, terminateCalls)
}
})
@@ -974,8 +1287,10 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) {
Users: &user.Manager{Runner: runner},
TerminateProcesses: func(int) error { return wantErr },
LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil },
+ ClearScheduledJobs: testClearScheduledJobs,
+ DrainScheduledJobs: testDrainScheduledJobs,
}
- err := a.rollbackInviteAccount("xxvcc-a1", rec, true)
+ err := a.rollbackInviteAccount("xxvcc-a1", rec, pw, true)
if !errors.Is(err, wantErr) {
t.Fatalf("rollback error = %v, want %v", err, wantErr)
}
@@ -984,6 +1299,95 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) {
}
})
+ t.Run("captured pending identity can be rolled back", func(t *testing.T) {
+ pending := pw
+ pending.GECOS = config.PendingGenerationGECOSPrefix + generation
+ pendingRec := rec
+ pendingRec.UID = 0
+ pendingRec.Pending = true
+ runner := &revokeRunner{}
+ lookup := func(string) (user.Passwd, bool, error) {
+ if len(runner.calls) > 0 && runner.calls[len(runner.calls)-1] == "userdel" {
+ return user.Passwd{}, false, nil
+ }
+ return pending, true, nil
+ }
+ a := &App{
+ Users: &user.Manager{
+ Runner: runner, LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { return nil },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ },
+ TerminateProcesses: func(int) error { return nil },
+ LookupUser: lookup,
+ ClearScheduledJobs: testClearScheduledJobs,
+ DrainScheduledJobs: testDrainScheduledJobs,
+ }
+ setTestRegistryRecord(t, a, pendingRec)
+ if err := a.rollbackInviteAccount("xxvcc-a1", pendingRec, pending, true); err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.Join(runner.calls, ","); got != "chage,usermod,userdel" {
+ t.Fatalf("pending rollback order = %q", got)
+ }
+ })
+
+ t.Run("retained pending identity uses captured UID", func(t *testing.T) {
+ pending := pw
+ pending.GECOS = config.PendingGenerationGECOSPrefix + generation
+ pendingRec := rec
+ pendingRec.UID = 0
+ pendingRec.Pending = true
+ runner := &revokeRunner{}
+ terminatedUID := 0
+ a := &App{
+ Users: &user.Manager{Runner: runner},
+ TerminateProcesses: func(uid int) error { terminatedUID = uid; return nil },
+ LookupUser: func(string) (user.Passwd, bool, error) { return pending, true, nil },
+ ClearScheduledJobs: testClearScheduledJobs,
+ DrainScheduledJobs: testDrainScheduledJobs,
+ }
+ if err := a.rollbackInviteAccount("xxvcc-a1", pendingRec, pending, false); err != nil {
+ t.Fatal(err)
+ }
+ if terminatedUID != pending.UID {
+ t.Fatalf("rollback terminated UID %d, want captured UID %d", terminatedUID, pending.UID)
+ }
+ if got := strings.Join(runner.calls, ","); got != "chage,usermod" {
+ t.Fatalf("retained pending rollback commands = %q", got)
+ }
+ })
+
+ t.Run("already absent account never reaches home cleanup", func(t *testing.T) {
+ runner := &revokeRunner{}
+ lookup := func(string) (user.Passwd, bool, error) { return user.Passwd{}, false, nil }
+ homeTouched := false
+ mailCalls := 0
+ a := &App{
+ Users: &user.Manager{
+ Runner: runner, LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { mailCalls++; return nil },
+ RemoveManagedHome: func(got user.Passwd) error {
+ homeTouched = true
+ if got != pw {
+ t.Fatalf("cleanup identity = %+v, want %+v", got, pw)
+ }
+ return nil
+ },
+ },
+ LookupUser: lookup,
+ }
+ if err := a.rollbackInviteAccount("xxvcc-a1", rec, pw, true); err != nil {
+ t.Fatal(err)
+ }
+ if len(runner.calls) != 0 {
+ t.Fatalf("absent account reached a name-scoped helper: %v", runner.calls)
+ }
+ if homeTouched || mailCalls != 2 {
+ t.Fatalf("absent cleanup touched Home=%v or mail calls=%d, want Home=false mail=2", homeTouched, mailCalls)
+ }
+ })
+
t.Run("same UID replacement is retained", func(t *testing.T) {
runner := &revokeRunner{}
lookups := 0
@@ -1003,12 +1407,12 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) {
return nil
},
}
- err := a.rollbackInviteAccount("xxvcc-a1", rec, true)
+ err := a.rollbackInviteAccount("xxvcc-a1", rec, pw, true)
if err == nil || !strings.Contains(err.Error(), "identity changed") {
t.Fatalf("rollback error = %v, want replacement refusal", err)
}
- if got := strings.Join(runner.calls, ","); got != "chage,usermod" {
- t.Fatalf("replacement reached delete: commands=%q", got)
+ if got := strings.Join(runner.calls, ","); got != "" {
+ t.Fatalf("replacement reached account helper: commands=%q", got)
}
})
}
@@ -1024,8 +1428,8 @@ func TestUninstallRefusesOnRegistryReadError(t *testing.T) {
}
}
-func TestRecursiveRemovalNeverAcceptsRootOrRelativePaths(t *testing.T) {
- for _, path := range []string{"", ".", "relative/state", "/"} {
+func TestRecursiveRemovalNeverAcceptsBroadNonCanonicalOrRelativePaths(t *testing.T) {
+ for _, path := range []string{"", ".", "relative/state", "/", "/etc", "/tmp", "/var", "/var/lib", "/var/log", "/usr/local", "/var/lib/../etc"} {
if err := safeRecursiveRemovalPath(path); err == nil {
t.Errorf("safeRecursiveRemovalPath(%q) unexpectedly allowed recursive removal", path)
}
@@ -1035,6 +1439,84 @@ func TestRecursiveRemovalNeverAcceptsRootOrRelativePaths(t *testing.T) {
}
}
+func TestRecursiveRemovalRefusesSymlinkedParentEvenWithForce(t *testing.T) {
+ base := t.TempDir()
+ realParent := filepath.Join(base, "real-parent")
+ if err := os.Mkdir(realParent, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ realState := filepath.Join(realParent, "state")
+ if err := os.Mkdir(realState, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ sentinel := filepath.Join(realState, "must-survive")
+ if err := os.WriteFile(sentinel, []byte("unrelated"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ linkParent := filepath.Join(base, "linked-parent")
+ if err := os.Symlink(realParent, linkParent); err != nil {
+ t.Fatal(err)
+ }
+
+ removeCalled := false
+ a := &App{
+ StateDir: filepath.Join(linkParent, "state"),
+ RemoveAll: func(path string) error {
+ removeCalled = true
+ return os.RemoveAll(path)
+ },
+ }
+ err := a.removeStateDir(true)
+ if err == nil || !strings.Contains(err.Error(), "symlinked parent") {
+ t.Fatalf("symlinked-parent removal error = %v, want refusal", err)
+ }
+ if removeCalled {
+ t.Fatal("symlinked parent reached recursive removal")
+ }
+ if got, err := os.ReadFile(sentinel); err != nil || string(got) != "unrelated" {
+ t.Fatalf("redirected tree changed: content=%q err=%v", got, err)
+ }
+}
+
+func TestRecursiveRemovalRetrySyncsParentAfterVisibleDeletion(t *testing.T) {
+ parent := filepath.Join(t.TempDir(), "managed")
+ if err := os.Mkdir(parent, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ state := filepath.Join(parent, "state")
+ if err := os.Mkdir(state, 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ realSync := syncRecursiveRemovalParent
+ t.Cleanup(func() { syncRecursiveRemovalParent = realSync })
+ wantErr := errors.New("forced recursive-removal parent sync failure")
+ syncs := 0
+ syncRecursiveRemovalParent = func(*os.File) error {
+ syncs++
+ if syncs == 1 {
+ return wantErr
+ }
+ return nil
+ }
+
+ a := &App{StateDir: state}
+ err := a.removeStateDir(true)
+ var durability *fsutil.DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "recursive removal" {
+ t.Fatalf("first removal error = %v, want recursive-removal DurabilityError", err)
+ }
+ if _, err := os.Lstat(state); !os.IsNotExist(err) {
+ t.Fatalf("first removal was not visible: %v", err)
+ }
+ if err := a.removeStateDir(true); err != nil {
+ t.Fatalf("retry after visible removal: %v", err)
+ }
+ if syncs != 2 {
+ t.Fatalf("recursive-removal parent sync calls = %d, want failed sync plus absent-root retry", syncs)
+ }
+}
+
func TestRecursiveRemovalRejectsRootAndNestedMounts(t *testing.T) {
base := "28 1 254:4 / / rw,relatime - ext4 /dev/root rw\n"
for _, line := range []string{
@@ -1114,6 +1596,23 @@ func TestInviteRefusesBeforeAskingAnything(t *testing.T) {
}
}
+func TestDetectedLocalHostRequiresOperatorConfirmation(t *testing.T) {
+ metadata := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("8.8.4.4"))
+ }))
+ t.Cleanup(metadata.Close)
+
+ a, _, errb := newTestApp(t, "admin.example.com\n")
+ a.Detector = netdetect.New()
+ a.Detector.MetadataServices = []string{metadata.URL}
+ if got := a.detectOrPromptHost(); got != "admin.example.com" {
+ t.Fatalf("detected Host = %q, want the operator's confirmed override", got)
+ }
+ if !strings.Contains(errb.String(), "[8.8.4.4]") {
+ t.Fatalf("metadata Host was not presented as a confirmable default: %q", errb.String())
+ }
+}
+
// TestInviteSurvivesAnUnwiredSSHDProbe pins that a root-run tool has no path that
// panics: an unset probe is reported, not dereferenced.
func TestInviteSurvivesAnUnwiredSSHDProbe(t *testing.T) {
@@ -1132,6 +1631,16 @@ func TestInviteSurvivesAnUnwiredSSHDProbe(t *testing.T) {
}
}
+func TestSSHDConfigRejectsNilResult(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return nil, nil }
+
+ cfg, err := a.sshdConfig("xxvcc-a1")
+ if err == nil || cfg != nil || !strings.Contains(err.Error(), "returned no configuration") {
+ t.Fatalf("sshdConfig nil result = %#v, %v; want nil, descriptive error", cfg, err)
+ }
+}
+
func TestPasswordLoginFailsClosedWhenSSHDProbeFails(t *testing.T) {
a, _, errb := newTestApp(t, "")
a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
@@ -1165,7 +1674,7 @@ func TestPasswordLoginFailsClosedWhenSSHDPolicyIsUnverifiable(t *testing.T) {
if plan, ok := a.planLogin("xxvcc-a1", true, "no", true); ok {
t.Fatalf("password plan unexpectedly accepted an address-dependent policy: %+v", plan)
}
- if !strings.Contains(errb.String(), "cannot prove") {
+ if !strings.Contains(errb.String(), "effective sshd config check cannot confirm") {
t.Fatalf("password refusal did not name the unverifiable policy: %q", errb.String())
}
}
@@ -1193,23 +1702,197 @@ func TestPasswordFallbackIsNotOfferedForUnverifiablePolicy(t *testing.T) {
}
}
-func TestLoginChecksUseInjectedConnectionScopedMatchProbe(t *testing.T) {
+func TestDeferredPasswordFallbackRequiresExplicitConsentAndPostAccountCheck(t *testing.T) {
+ const conf = "pubkeyauthentication no\npasswordauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n"
+
+ t.Run("default no declines", func(t *testing.T) {
+ a, errb := interactiveApp(t, "\n", conf)
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ if plan, ok := a.offerPasswordFallback(sysinfo.ParseSSHD(conf), "xxvcc-a1", true); ok {
+ t.Fatalf("default-No consent unexpectedly produced a password plan: %+v", plan)
+ }
+ diagnostic := errb.String()
+ if !strings.Contains(diagnostic, "brute-forceable") || !strings.Contains(diagnostic, "[y/N]") {
+ t.Fatalf("deferred fallback omitted its risk warning or default-No prompt: %q", diagnostic)
+ }
+ })
+
+ t.Run("yes still fails closed after creation", func(t *testing.T) {
+ a, errb := interactiveApp(t, "y\n", conf)
+ var phases []bool
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool {
+ phases = append(phases, accountExists)
+ return !accountExists
+ }
+ plan, ok := a.offerPasswordFallback(sysinfo.ParseSSHD(conf), "xxvcc-a1", true)
+ if !ok || !plan.password || plan.verified || plan.unverified == "" {
+ t.Fatalf("consented deferred fallback = (%+v, %v), want an unverified password plan", plan, ok)
+ }
+ if !strings.Contains(errb.String(), "brute-forceable") || !strings.Contains(errb.String(), "[y/N]") {
+ t.Fatalf("consented deferred fallback omitted its risk warning or prompt: %q", errb.String())
+ }
+
+ // Account creation resolves Match Group, but consent must not authorize a
+ // password by itself. The mandatory post-account check sees the real policy
+ // and refuses before runInvite reaches SetPassword.
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("passwordauthentication no\n"), nil
+ }
+ if a.confirmLogin("xxvcc-a1", []string{"xxvcc-a1"}, &plan) {
+ t.Fatal("password fallback consent bypassed the post-account policy check")
+ }
+ if len(phases) != 3 || !phases[0] || phases[1] || !phases[2] {
+ t.Fatalf("deferred fallback Match phases = %v, want [true false true]", phases)
+ }
+ })
+}
+
+func TestDeferredPasswordFallbackDoesNotHideKnownBlocker(t *testing.T) {
+ const conf = "pubkeyauthentication no\npasswordauthentication no\nauthorizedkeysfile .ssh/authorized_keys\n"
+ a, errb := interactiveApp(t, "y\n", conf)
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ if plan, ok := a.offerPasswordFallback(sysinfo.ParseSSHD(conf), "xxvcc-a1", true); ok {
+ t.Fatalf("known password blocker was hidden by Match Group deferral: %+v", plan)
+ }
+ if strings.Contains(errb.String(), "Issue a password login instead?") {
+ t.Fatalf("known password blocker still reached the fallback consent prompt: %q", errb.String())
+ }
+}
+
+func TestLoginChecksPassAccountPhaseToInjectedUnverifiableMatchProbe(t *testing.T) {
a, _, _ := newTestApp(t, "")
- probes := 0
- a.SSHDHasConnectionScopedMatch = func() bool {
- probes++
- return true
+ var phases []bool
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool {
+ phases = append(phases, accountExists)
+ return !accountExists
}
cfg := sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n")
- rep := a.checkKeyLogin(cfg, "xxvcc-a1", []string{"xxvcc-a1"})
+ rep := a.checkKeyLogin(cfg, "xxvcc-a1", []string{"xxvcc-a1"}, false)
if rep.Certain() || len(rep.Unverifiable) != 1 {
- t.Fatalf("connection-scoped Match probe did not downgrade the report: %+v", rep)
+ t.Fatalf("unverifiable Match probe did not downgrade the report: %+v", rep)
+ }
+ post := a.checkKeyLogin(cfg, "xxvcc-a1", []string{"xxvcc-a1"}, true)
+ if !post.Certain() {
+ t.Fatalf("post-account Group-only Match remained unverifiable: %+v", post)
}
- if probes != 1 {
- t.Fatalf("connection-scoped Match probes = %d, want 1", probes)
+ if len(phases) != 3 || !phases[0] || phases[1] || !phases[2] {
+ t.Fatalf("unverifiable Match account phases = %v, want [true false true]", phases)
}
}
+func TestLoginPlanningAndConfirmationUseTheirActualAccountPhase(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n"), nil
+ }
+ var phases []bool
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool {
+ phases = append(phases, accountExists)
+ return false
+ }
+ plan, ok := a.planLogin("xxvcc-a1", false, "no", true)
+ if !ok {
+ t.Fatal("key login planning unexpectedly failed")
+ }
+ if len(phases) != 2 || !phases[0] || phases[1] {
+ t.Fatalf("planning Match account phases = %v, want [true false]", phases)
+ }
+ phases = nil
+ if !a.confirmLogin("xxvcc-a1", []string{"xxvcc-a1"}, &plan) {
+ t.Fatal("key login confirmation unexpectedly failed")
+ }
+ if len(phases) != 1 || !phases[0] {
+ t.Fatalf("confirmation Match account phases = %v, want [true]", phases)
+ }
+}
+
+func TestExplicitSSHDRepairPermissionSurvivesPreAccountMatchGroup(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHD = &sshdconf.Manager{}
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("pubkeyauthentication no\nauthorizedkeysfile .ssh/authorized_keys\n"), nil
+ }
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ plan, ok := a.planLogin("xxvcc-a1", false, "yes", true)
+ if !ok || !plan.fixSSHD {
+ t.Fatalf("pre-account Match Group discarded explicit repair permission: ok=%v plan=%+v", ok, plan)
+ }
+ if !a.confirmLogin("xxvcc-a1", []string{"xxvcc-a1"}, &plan) {
+ t.Fatal("post-account fixable blocker was refused despite explicit repair permission")
+ }
+ if !plan.fixSSHD || !plan.report.Has(sysinfo.BlockPubkeyDisabled) {
+ t.Fatalf("confirmed repair plan = %+v, want retained fix with real blocker", plan)
+ }
+}
+
+func TestPreAccountMatchGroupDoesNotBypassKeyRepairChoice(t *testing.T) {
+ const conf = "pubkeyauthentication no\nauthorizedkeysfile .ssh/authorized_keys\n"
+
+ t.Run("explicit refusal remains refusal", func(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHD = &sshdconf.Manager{}
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD(conf), nil
+ }
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ if plan, ok := a.planLogin("xxvcc-a1", false, "no", true); ok {
+ t.Fatalf("known key blocker bypassed --no-fix-sshd through Match Group deferral: %+v", plan)
+ }
+ })
+
+ t.Run("interactive consent is still required", func(t *testing.T) {
+ a, _ := interactiveApp(t, "y\n", conf)
+ a.SSHD = &sshdconf.Manager{}
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ plan, ok := a.planLogin("xxvcc-a1", false, "ask", false)
+ if !ok || !plan.fixSSHD {
+ t.Fatalf("known key blocker bypassed the normal repair choice: ok=%v plan=%+v", ok, plan)
+ }
+ })
+}
+
+func TestPasswordDefersOnlyPreAccountMatchGroup(t *testing.T) {
+ t.Run("group is resolved before password", func(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("passwordauthentication yes\n"), nil
+ }
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ plan, ok := a.planLogin("xxvcc-a1", true, "no", true)
+ if !ok || !plan.password {
+ t.Fatalf("Group-only pre-account uncertainty was not deferred: ok=%v plan=%+v", ok, plan)
+ }
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("passwordauthentication yes\n"), nil
+ }
+ if !a.confirmLogin("xxvcc-a1", []string{"xxvcc-a1"}, &plan) {
+ t.Fatal("password was not accepted after the post-account config became conclusive")
+ }
+ })
+
+ t.Run("known blocker is rejected before account creation", func(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("passwordauthentication no\n"), nil
+ }
+ a.SSHDHasUnverifiableMatch = func(accountExists bool) bool { return !accountExists }
+ if plan, ok := a.planLogin("xxvcc-a1", true, "no", true); ok {
+ t.Fatalf("known password blocker was deferred until after account creation: %+v", plan)
+ }
+ })
+
+ t.Run("connection uncertainty still fails closed", func(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("passwordauthentication yes\n"), nil
+ }
+ a.SSHDHasUnverifiableMatch = func(bool) bool { return true }
+ if plan, ok := a.planLogin("xxvcc-a1", true, "no", true); ok {
+ t.Fatalf("password plan accepted persistent Match uncertainty: %+v", plan)
+ }
+ })
+}
+
func TestDetachedSignatureURLPreservesQueryAndFragment(t *testing.T) {
cases := map[string]string{
"https://example.com/releases/lta": "https://example.com/releases/lta.sig",
@@ -1611,8 +2294,17 @@ func TestPromptYesNoStopsAfterInvalidNonTTYInputAndEOF(t *testing.T) {
func TestClassifyRegisteredAccountIdentityStates(t *testing.T) {
const generation = "0123456789abcdef0123456789abcdef"
- managed := user.Passwd{UID: 1001, GECOS: config.ManagedGenerationGECOSPrefix + generation}
- legacy := user.Passwd{UID: 1001, GECOS: config.ManagedGECOS}
+ managed := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, GECOS: config.ManagedGenerationGECOSPrefix + generation, Home: "/home/xxvcc-a1", Shell: "/bin/sh"}
+ legacy := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, GECOS: config.ManagedGECOS, Home: "/home/xxvcc-a1", Shell: "/bin/sh"}
+ rootUID := managed
+ rootUID.UID = 0
+ rootGID := managed
+ rootGID.GID = 0
+ invalidGID := managed
+ reservedKernelID := uint64(^uint32(0))
+ invalidGID.GID = int(reservedKernelID)
+ homeMismatch := managed
+ homeMismatch.Home = "/srv/xxvcc-a1"
tests := []struct {
name string
rec registry.Record
@@ -1624,12 +2316,18 @@ func TestClassifyRegisteredAccountIdentityStates(t *testing.T) {
{name: "missing", want: registeredMissing},
{name: "lookup error", err: errors.New("passwd unreadable"), want: registeredUnknown},
{name: "pending", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true, Pending: true}, pw: managed, exists: true, want: registeredPending},
+ {name: "pending with root primary GID", rec: registry.Record{Generation: generation, IdentityBound: true, Pending: true}, pw: rootGID, exists: true, want: registeredIdentityUnverified},
{name: "no trusted UID", rec: registry.Record{}, pw: managed, exists: true, want: registeredIdentityUnverified},
+ {name: "root account UID", rec: registry.Record{UID: 1001}, pw: rootUID, exists: true, want: registeredIdentityUnverified},
+ {name: "root primary GID", rec: registry.Record{UID: 1001}, pw: rootGID, exists: true, want: registeredIdentityUnverified},
+ {name: "reserved kernel GID", rec: registry.Record{UID: 1001}, pw: invalidGID, exists: true, want: registeredIdentityUnverified},
+ {name: "reserved recorded UID", rec: registry.Record{UID: int(reservedKernelID)}, pw: managed, exists: true, want: registeredIdentityUnverified},
{name: "UID mismatch", rec: registry.Record{UID: 1002, Generation: generation, IdentityBound: true}, pw: managed, exists: true, want: registeredUIDMismatch},
{name: "legacy", rec: registry.Record{UID: 1001}, pw: legacy, exists: true, want: registeredLegacyIdentity},
- {name: "marker mismatch", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true}, pw: user.Passwd{UID: 1001}, exists: true, want: registeredMarkerMismatch},
+ {name: "marker mismatch", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true}, pw: user.Passwd{UID: 1001, GID: 1001}, exists: true, want: registeredMarkerMismatch},
{name: "generation mismatch", rec: registry.Record{UID: 1001, Generation: "fedcba9876543210fedcba9876543210", IdentityBound: true}, pw: managed, exists: true, want: registeredMarkerMismatch},
- {name: "active", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true}, pw: managed, exists: true, want: registeredActive},
+ {name: "home mismatch", rec: registry.Record{User: "xxvcc-a1", UID: 1001, Generation: generation, IdentityBound: true}, pw: homeMismatch, exists: true, want: registeredHomeMismatch},
+ {name: "active", rec: registry.Record{User: "xxvcc-a1", UID: 1001, Generation: generation, IdentityBound: true}, pw: managed, exists: true, want: registeredActive},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -1659,7 +2357,7 @@ func TestPlanDepsAllPresent(t *testing.T) {
}
t.Setenv("PATH", binDir)
- pkgs, ok := a.planDeps(false, false, false, true)
+ pkgs, ok := a.planDeps(false, false, false, false, true)
if !ok || len(pkgs) != 0 {
t.Errorf("planDeps = %v, %v; want nil,true when nothing is missing", pkgs, ok)
}
@@ -1673,7 +2371,7 @@ func TestPlanDepsRefusesAutomaticPacmanPartialUpgrade(t *testing.T) {
}
t.Setenv("PATH", dir)
- pkgs, ok := a.planDeps(false, true, false, true)
+ pkgs, ok := a.planDeps(false, false, true, false, true)
if ok || len(pkgs) != 0 {
t.Fatalf("planDeps = %v, %v; want refusal on pacman", pkgs, ok)
}
@@ -1684,6 +2382,27 @@ func TestPlanDepsRefusesAutomaticPacmanPartialUpgrade(t *testing.T) {
}
}
+func TestPlanDepsRequiresChpasswdOnlyForPasswordLogin(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ binDir := t.TempDir()
+ for _, name := range []string{"id", "useradd", "usermod", "chage", "userdel"} {
+ if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("PATH", binDir)
+
+ if pkgs, ok := a.planDeps(false, false, false, false, true); !ok || len(pkgs) != 0 {
+ t.Fatalf("key-only planDeps = %v, %v; want nil,true", pkgs, ok)
+ }
+ if pkgs, ok := a.planDeps(false, true, false, false, true); ok || len(pkgs) != 0 {
+ t.Fatalf("password planDeps = %v, %v; want pre-transaction refusal", pkgs, ok)
+ }
+ if got := errb.String(); !strings.Contains(got, "missing dependencies") || !strings.Contains(got, "chpasswd") {
+ t.Fatalf("password dependency refusal did not name chpasswd: %q", got)
+ }
+}
+
// A generated username is chosen before dependency planning. If `id` itself is
// missing, that early step must still reach the dependency gate so --install-deps
// can repair the host; the authoritative NSS collision check runs later, after
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 6806736..be15090 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -47,26 +47,37 @@ func (a *App) status(args []string) int {
a.errorf("%s", a.P.M("用户名不合法:"+u, "invalid username: "+u))
return 1
}
+ rec, found, err := a.Registry.Lookup(u)
+ if err != nil {
+ a.errorf("%s: %v", a.P.M("读取注册表失败", "reading registry failed"), err)
+ return 1
+ }
pw, ok, err := a.lookupUser(u)
if err != nil {
a.errorf("%s: %v", a.P.M("读取账号数据库失败", "reading account database failed"), err)
return 1
}
if !ok {
+ if found && rec.DeletionStarted {
+ a.printf("user=%s uid=%d exists=false managed=false identity=deletion-recovery-absent", rec.User, rec.UID)
+ if rec.AutoUnit != "" {
+ a.printf("auto-revoke unit=%s", rec.AutoUnit)
+ }
+ return 0
+ }
a.errorf("%s", a.P.M("用户不存在:"+u, "user does not exist: "+u))
return 1
}
- rec, found, err := a.Registry.Lookup(u)
- if err != nil {
- a.errorf("%s: %v", a.P.M("读取注册表失败", "reading registry failed"), err)
- return 1
- }
managed := false
identity := "unregistered"
if found {
switch classifyRegisteredAccount(rec, pw, true, nil) {
case registeredActive:
managed, identity = true, "generation-bound"
+ case registeredRecoveryBound:
+ identity = "deletion-recovery-bound"
+ case registeredRecoveryManual:
+ identity = "deletion-recovery-manual"
case registeredLegacyIdentity:
identity = "legacy-unverified"
case registeredPending:
@@ -75,6 +86,8 @@ func (a *App) status(args []string) int {
identity = "uid-mismatch"
case registeredMarkerMismatch:
identity = "generation-marker-mismatch"
+ case registeredHomeMismatch:
+ identity = "home-mismatch"
default:
identity = "unverified"
}
@@ -148,6 +161,12 @@ func (a *App) userCells(r registry.Record) []string {
switch classifyRegisteredAccount(r, pw, exists, err) {
case registeredActive:
state = a.P.M("在册", "active")
+ case registeredRecoveryAbsent:
+ state = a.P.M("删除后恢复", "post-delete recovery")
+ case registeredRecoveryBound:
+ state = a.P.M("删除恢复(可续删)", "deletion recovery (bound retry)")
+ case registeredRecoveryManual:
+ state = a.P.M("删除恢复(需人工)", "deletion recovery (manual)")
case registeredPending:
state = a.P.M("创建未完成", "pending")
case registeredIdentityUnverified:
@@ -158,6 +177,8 @@ func (a *App) userCells(r registry.Record) []string {
state = a.P.M("UID 不匹配", "UID mismatch")
case registeredMarkerMismatch:
state = a.P.M("标记不匹配", "marker mismatch")
+ case registeredHomeMismatch:
+ state = a.P.M("家目录不匹配", "home mismatch")
case registeredUnknown:
state = a.P.M("未知", "unknown")
default:
@@ -257,11 +278,15 @@ type registeredAccountState uint8
const (
registeredMissing registeredAccountState = iota
registeredUnknown
+ registeredRecoveryAbsent
+ registeredRecoveryBound
+ registeredRecoveryManual
registeredPending
registeredIdentityUnverified
registeredLegacyIdentity
registeredUIDMismatch
registeredMarkerMismatch
+ registeredHomeMismatch
registeredActive
)
@@ -269,11 +294,19 @@ func classifyRegisteredAccount(rec registry.Record, pw user.Passwd, exists bool,
switch {
case lookupErr != nil:
return registeredUnknown
+ case rec.DeletionStarted && !exists:
+ return registeredRecoveryAbsent
+ case rec.DeletionStarted && rec.IdentityBound && deletionRecordMatchesPasswd(rec, pw):
+ return registeredRecoveryBound
+ case rec.DeletionStarted:
+ return registeredRecoveryManual
case !exists:
return registeredMissing
+ case !validate.AccountID(pw.UID) || !validate.AccountID(pw.GID):
+ return registeredIdentityUnverified
case rec.Pending:
return registeredPending
- case rec.UID < 1:
+ case !validate.AccountID(rec.UID):
return registeredIdentityUnverified
case pw.UID != rec.UID:
return registeredUIDMismatch
@@ -284,6 +317,8 @@ func classifyRegisteredAccount(rec registry.Record, pw user.Passwd, exists bool,
return registeredMarkerMismatch
case !user.MatchesManagedGeneration(pw, rec.Generation):
return registeredMarkerMismatch
+ case !validate.ManagedHome(rec.User, pw.Home):
+ return registeredHomeMismatch
default:
return registeredActive
}
@@ -455,6 +490,29 @@ func (a *App) accountIsOursAndLive(name string) (bool, error) {
return state == registeredActive || state == registeredLegacyIdentity, nil
}
+// accountNeedsAutoRevoke reports whether a managed auto-revoke task must be
+// retained. An absent recovery row needs its retry path for owner-checked mail
+// cleanup, and an exactly bound live recovery may safely retry deletion. A legacy
+// identity and a live UID-only or generation-mismatched recovery are manual-only,
+// so their old unattended tasks are stale and must be swept while the registry
+// witness remains.
+func (a *App) accountNeedsAutoRevoke(name string) (bool, error) {
+ if a.Registry == nil {
+ return false, fmt.Errorf("no registry available to verify %s", name)
+ }
+ rec, found, err := a.Registry.Lookup(name)
+ if err != nil || !found {
+ return false, err
+ }
+ pw, exists, err := a.lookupUser(name)
+ if err != nil {
+ return false, err
+ }
+ state := classifyRegisteredAccount(rec, pw, exists, nil)
+ return state == registeredActive || state == registeredRecoveryAbsent ||
+ state == registeredRecoveryBound, nil
+}
+
// completedAccountIdentity returns whether name currently resolves to the
// completed v2 identity recorded by this tool, and whether a local account with
// that name exists at all. The UID and marker are checked on the same passwd
@@ -475,10 +533,11 @@ func (a *App) completedAccountIdentity(name string) (ours, live bool, err error)
if !exists {
return false, false, nil
}
- if !found || rec.Pending || rec.UID < 1 || pw.UID != rec.UID || !rec.IdentityBound {
+ if !found {
return false, true, nil
}
- return user.MatchesManagedGeneration(pw, rec.Generation), true, nil
+ state := classifyRegisteredAccount(rec, pw, true, nil)
+ return state == registeredActive || state == registeredRecoveryBound, true, nil
}
// installedCommandVersion best-effort reads the version of the binary at
@@ -549,7 +608,7 @@ func (a *App) orphanArtifacts(recs []registry.Record) ([]orphanArtifact, error)
}
}
if a.Scheduler != nil {
- if o, err := a.Scheduler.Orphans(a.accountIsOursAndLive); err != nil {
+ if o, err := a.Scheduler.Orphans(a.accountNeedsAutoRevoke); err != nil {
scanErrs = append(scanErrs, fmt.Errorf("scheduler: %w", err))
} else {
addKind(o, a.P.M("自动删除任务", "auto-delete task"))
@@ -633,7 +692,7 @@ func (a *App) compactLocked() int {
// after an uninstall). Scheduler.Orphans mirrors the two sweeps above, and
// globs the v1 prefix too.
if a.Scheduler != nil {
- orphans, err := a.Scheduler.Orphans(a.accountIsOursAndLive)
+ orphans, err := a.Scheduler.Orphans(a.accountNeedsAutoRevoke)
if err != nil {
a.warnf("%v", err)
rc = 1
@@ -653,7 +712,9 @@ func (a *App) compactLocked() int {
"orphan scanning or cleanup did not complete; the registry was not compacted so recovery evidence is retained."))
return rc
}
- removed, err := a.Registry.Compact(user.Exists)
+ removed, err := a.Registry.Compact(func(rec registry.Record) (bool, error) {
+ return user.Exists(rec.User)
+ })
if err != nil {
a.warnf("%v", err)
rc = 1
@@ -710,12 +771,12 @@ func (a *App) doctor(args []string) int {
} else {
a.success(a.P.M("pidfd 进程撤销能力可用。", "pidfd process revocation is available."))
}
- for _, d := range sysinfo.RequiredDeps(true) {
+ for _, d := range sysinfo.RequiredDeps(true, true) {
if d.Present {
a.success(a.P.M("依赖存在:", "dependency found: ") + d.Label)
} else {
a.warnf("%s%s", a.P.M("缺少依赖:", "missing dependency: "), d.Label)
- if d.Label != "sudo" { // sudo is only needed for --sudo invites
+ if doctorDependencyIsFatal(d.Label) {
rc = 1
}
}
@@ -725,35 +786,36 @@ func (a *App) doctor(args []string) int {
a.info(fmt.Sprintf(a.P.M("探测到 SSH 端口:%d", "detected SSH port: %d"), sysinfo.SSHPort()))
// Probe with a name shaped like a fresh invite account: brand new, on no
// whitelist, and in no group but its own. That is what an invite actually hits,
- // and reporting on it here is the only way an operator can learn that key logins
- // are off *before* they hand out an invite.
+ // and reporting on it here lets an operator see effective-configuration blockers
+ // before handing out an invite.
//
// The probe name is passed to SSHDConfig, not just to the check: `sshd -T` alone
// cannot see `Match User` blocks, so asking the global view a per-user question
// would let doctor contradict the invite it is meant to predict.
probe := config.DefaultPrefix + "-doctor"
if cfg, err := a.sshdConfig(probe); err != nil {
- a.warnf("%s (%v)", a.P.M("无法读取 sshd 有效配置;invite 无法验证公钥登录是否真的可用。",
- "cannot read the effective sshd config; invite cannot verify that a key login would work."), err)
+ a.warnf("%s (%v)", a.P.M("无法读取 sshd 有效配置;无法运行新邀请的公钥凭据检查。",
+ "cannot read the effective sshd config; cannot run the public-key credential check for a new invite."), err)
rc = 1
} else {
- rep := a.checkKeyLogin(cfg, probe, []string{probe})
+ rep := a.checkKeyLogin(cfg, probe, []string{probe}, false)
for _, w := range rep.Warnings {
a.warnf("%s", w)
}
if rep.Certain() {
- a.success(a.P.M("sshd 接受公钥登录。", "sshd accepts public-key logins."))
+ a.success(a.P.M("sshd 有效配置检查未发现新账号公钥凭据的阻碍。",
+ "the effective sshd config check found no blocker for a new account's key credential."))
} else if rep.OK() {
- a.warnf("%s", a.P.M("sshd 没有显示阻断公钥登录,但存在无法求值的连接条件,不能确认新邀请可登录。",
- "sshd has no explicit key-login blocker, but connection-dependent rules could not be evaluated; a new invite cannot be confirmed healthy."))
+ a.warnf("%s", a.P.M("sshd 有效配置检查未发现明确的公钥凭据阻碍,但存在创建前或连接时无法求值的 Match 条件,配置结论不完整。",
+ "the effective sshd config check found no explicit public-key credential blocker, but a Match rule cannot be evaluated before creation or without connection attributes; the configuration verdict is inconclusive."))
rc = 1
} else {
- a.warnf("%s", a.P.M("sshd 不会接受新建临时账号的公钥登录:",
- "sshd would not accept a public-key login for a freshly created temporary account:"))
+ a.warnf("%s", a.P.M("sshd 有效配置检查发现新建临时账号公钥凭据的阻碍:",
+ "the effective sshd config check found a blocker for a freshly created temporary account's key credential:"))
a.reportBlockers(rep)
if rep.Fixable() {
- a.warnf("%s", a.P.M("可用 `invite --fix-sshd` 只为该账号开启(不改动全局策略)。",
- "`invite --fix-sshd` can enable it for that one account, leaving the global policy untouched."))
+ a.warnf("%s", a.P.M("可用 `invite --fix-sshd` 只为该账号移除已知配置阻碍(不改动全局策略)。",
+ "`invite --fix-sshd` can remove the known configuration blocker for that account only, leaving the global policy untouched."))
}
rc = 1
}
@@ -782,32 +844,76 @@ func (a *App) doctor(args []string) int {
rc = 1
continue
}
- switch {
- case rec.Pending:
+ switch classifyRegisteredAccount(rec, pw, exists, nil) {
+ case registeredRecoveryAbsent:
+ a.warnf("%s%s", a.P.M(
+ "删除事务已持久化且账号已不存在;见证只允许重试按 UID 校验所属者的邮件清扫,请运行 revoke 完成恢复:",
+ "deletion was durably started and the account is absent; the witness authorizes only an owner-checked UID-bound mail cleanup retry. Run revoke to finish recovery: "), rec.User)
+ rc = 1
+ case registeredRecoveryBound:
+ a.warnf("%s%s", a.P.M(
+ "活账号与已持久化的删除世代精确匹配;这是可重试的中断删除,请运行 revoke 完成:",
+ "the live account exactly matches a durably started deletion generation; this interrupted deletion can be retried with revoke: "), rec.User)
+ rc = 1
+ case registeredRecoveryManual:
+ a.warnf("%s%s", a.P.M(
+ "活账号的删除恢复见证未绑定当前世代或已不匹配;自动删除、--yes 和卸载批量删除均被拒绝。人工核查后,请直接运行 revoke --force 并输入完整用户名:",
+ "the live account's deletion-recovery witness is unbound to the current generation or no longer matches; automatic deletion, --yes, and uninstall bulk deletion are refused. Inspect it, then invoke revoke --force directly and type the full username: "), rec.User)
+ rc = 1
+ case registeredPending:
a.warnf("%s%s", a.P.M("登记仍是未完成的 pending 创建意图,不能证明当前账号身份:",
"registry row is still an incomplete pending creation intent and cannot prove the current account identity: "), rec.User)
rc = 1
- case !exists:
+ case registeredMissing:
a.warnf("%s%s", a.P.M("登记指向已不存在的账号(可用 cleanup-expired --compact 清理):",
"registry row points to an absent account (remove it with cleanup-expired --compact): "), rec.User)
rc = 1
- case rec.UID < 1:
- a.warnf("%s%s", a.P.M("活账号登记没有可信 UID,不能证明身份:",
- "live account registry row has no trusted UID and cannot prove identity: "), rec.User)
+ case registeredIdentityUnverified:
+ a.warnf("%s%s", a.P.M("活账号或登记没有安全的非 root UID/GID,不能证明身份:",
+ "live account or registry row has no safe non-root UID/GID and cannot prove identity: "), rec.User)
rc = 1
- case pw.UID != rec.UID:
+ case registeredUIDMismatch:
a.warnf("%s", fmt.Sprintf(a.P.M("登记账号 %s 的 UID 不匹配:记录为 %d,当前为 %d;拒绝自动删除。",
"registered account %s has a UID mismatch: recorded %d, current %d; automatic deletion is refused."), rec.User, rec.UID, pw.UID))
rc = 1
- case !rec.IdentityBound:
+ case registeredLegacyIdentity:
a.warnf("%s%s", a.P.M("登记账号来自旧版固定身份标记,无法排除同名/同 UID 重用;自动和批量删除已禁用,请人工核查后用 revoke --force 处理:",
"registered account uses a legacy fixed identity marker, so same-name/same-UID reuse cannot be excluded; automatic and bulk deletion are disabled; inspect it and use revoke --force: "), rec.User)
rc = 1
- case !user.MatchesManagedGeneration(pw, rec.Generation):
+ case registeredMarkerMismatch:
a.warnf("%s%s", a.P.M("登记账号缺少与登记世代精确匹配的受管身份标记,可能已被替换或篡改:",
"registered account lacks a managed identity marker matching its recorded generation and may have been replaced or modified: "), rec.User)
rc = 1
+ case registeredHomeMismatch:
+ a.warnf("%s%s", a.P.M("登记账号的家目录不是本工具使用的确定路径;自动删除已禁用:",
+ "registered account home is not the deterministic path used by this tool; automatic deletion is disabled: "), rec.User)
+ rc = 1
+ }
+ }
+ }
+ }
+ // A lifecycle marker is deliberately weaker than identity proof, but it is
+ // still the only durable witness for a permanent no-sudo/no-timer account
+ // after its registry row is lost. Compare markers only after a complete,
+ // successful registry read: an unreadable registry is already a failure and
+ // cannot support a meaningful missing-row comparison.
+ if a.Registry != nil && registryReadable {
+ registered := make(map[string]struct{}, len(registryRecords))
+ for _, rec := range registryRecords {
+ registered[rec.User] = struct{}{}
+ }
+ markerAccounts, err := a.listMarkerAccounts()
+ if err != nil {
+ a.warnf("%s: %v", a.P.M("无法扫描账号生命周期标记", "cannot scan account lifecycle markers"), err)
+ rc = 1
+ } else {
+ for _, name := range markerAccounts {
+ if _, ok := registered[name]; ok {
+ continue
}
+ a.warnf("%s%s", a.P.M("账号带有本工具的生命周期标记,但登记表中没有对应记录;该标记只能用于发现异常,不能授权自动或批量删除:",
+ "account carries this tool's lifecycle marker but has no registry row; the marker is discovery evidence only and cannot authorize automatic or bulk deletion: "), name)
+ rc = 1
}
}
}
@@ -858,7 +964,7 @@ func (a *App) doctor(args []string) int {
// against a binary that no longer exists — so it belongs in the same health list
// the two grants are in.
if a.Scheduler != nil {
- if orphans, err := a.Scheduler.Orphans(a.accountIsOursAndLive); err != nil {
+ if orphans, err := a.Scheduler.Orphans(a.accountNeedsAutoRevoke); err != nil {
a.warnf("%s: %v", a.P.M("无法扫描孤儿自动删除任务", "cannot scan for orphaned auto-delete tasks"), err)
rc = 1
} else if len(orphans) > 0 {
@@ -910,6 +1016,13 @@ func (a *App) doctor(args []string) int {
return rc
}
+// sudo and visudo are mandatory only for --sudo invites. chpasswd is mandatory
+// only for password invites. Missing optional-feature helpers do not make the
+// base key-only doctor verdict fail.
+func doctorDependencyIsFatal(label string) bool {
+ return label != "sudo" && label != "visudo" && label != "chpasswd"
+}
+
// menuItems are the interactive menu entries in order. An entry's position is
// both the digit shown and the action run, so a label can never drift away from
// the command it launches. A nil run means "leave the menu".
diff --git a/internal/cli/doctor_identity_test.go b/internal/cli/doctor_identity_test.go
new file mode 100644
index 0000000..15d3ec5
--- /dev/null
+++ b/internal/cli/doctor_identity_test.go
@@ -0,0 +1,229 @@
+package cli
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/xxvcc/linux-temp-admin/internal/config"
+ "github.com/xxvcc/linux-temp-admin/internal/registry"
+ "github.com/xxvcc/linux-temp-admin/internal/sysinfo"
+ "github.com/xxvcc/linux-temp-admin/internal/user"
+)
+
+func TestDoctorReportsUnsafeLiveAccountGID(t *testing.T) {
+ const (
+ name = "xxvcc-a1"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ a, _, errb := newTestApp(t, "")
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Registry.Record(registry.Record{
+ User: name, UID: 1001, Generation: generation, IdentityBound: true, Port: 22,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ a.LookupUser = func(string) (user.Passwd, bool, error) {
+ return user.Passwd{
+ Name: name, UID: 1001, GID: 0,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/" + name, Shell: "/bin/sh",
+ }, true, nil
+ }
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n"), nil
+ }
+ if rc := a.doctor(nil); rc != 1 {
+ t.Fatalf("doctor rc = %d, want unsafe-identity failure", rc)
+ }
+ if got := errb.String(); !strings.Contains(got, "no safe non-root UID/GID") || !strings.Contains(got, name) {
+ t.Fatalf("doctor hid unsafe live-account GID: %q", got)
+ }
+}
+
+func TestCompletedAccountIdentityRejectsUnsafeLiveAccountGID(t *testing.T) {
+ const (
+ name = "xxvcc-a1"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ a, _, _ := newTestApp(t, "")
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Registry.Record(registry.Record{
+ User: name, UID: 1001, Generation: generation, IdentityBound: true, Port: 22,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ a.LookupUser = func(string) (user.Passwd, bool, error) {
+ return user.Passwd{
+ Name: name, UID: 1001, GID: 0,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/" + name, Shell: "/bin/sh",
+ }, true, nil
+ }
+
+ ours, live, err := a.completedAccountIdentity(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ours || !live {
+ t.Fatalf("completedAccountIdentity = ours %v, live %v; want false, true", ours, live)
+ }
+}
+
+func TestDoctorDistinguishesDeletionRecoveryStates(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ for _, tc := range []struct {
+ name string
+ rec registry.Record
+ pw user.Passwd
+ exists bool
+ wantMsg string
+ }{
+ {
+ name: "absent recovery",
+ rec: registry.Record{
+ User: "xxvcc-recovery-a", UID: 1001, DeletionStarted: true, Port: 22,
+ },
+ wantMsg: "account is absent; the witness authorizes only an owner-checked UID-bound mail cleanup retry",
+ },
+ {
+ name: "live bound retry",
+ rec: registry.Record{
+ User: "xxvcc-recovery-b", UID: 1002, Generation: generation,
+ IdentityBound: true, DeletionStarted: true, Port: 22,
+ },
+ pw: user.Passwd{
+ Name: "xxvcc-recovery-b", UID: 1002, GID: 1002,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-recovery-b", Shell: "/bin/sh",
+ },
+ exists: true, wantMsg: "exactly matches a durably started deletion generation",
+ },
+ {
+ name: "live UID-only manual recovery",
+ rec: registry.Record{
+ User: "xxvcc-recovery-c", UID: 1003, DeletionStarted: true, Port: 22,
+ },
+ pw: user.Passwd{
+ Name: "xxvcc-recovery-c", UID: 1003, GID: 1003,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-recovery-c", Shell: "/bin/sh",
+ },
+ exists: true, wantMsg: "unbound to the current generation",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ setTestRegistryRecord(t, a, tc.rec)
+ a.LookupUser = func(string) (user.Passwd, bool, error) { return tc.pw, tc.exists, nil }
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n"), nil
+ }
+ if rc := a.doctor(nil); rc != 1 {
+ t.Fatalf("doctor rc = %d, want recovery warning", rc)
+ }
+ if got := errb.String(); !strings.Contains(got, tc.wantMsg) || !strings.Contains(got, tc.rec.User) {
+ t.Fatalf("doctor recovery output missing %q: %q", tc.wantMsg, got)
+ }
+ })
+ }
+}
+
+func TestStatusReportsAbsentDeletionRecovery(t *testing.T) {
+ rec := registry.Record{User: "xxvcc-recovery-status", UID: 1001, DeletionStarted: true, Port: 22}
+ a, out, _ := newTestApp(t, "")
+ setTestRegistryRecord(t, a, rec)
+ a.LookupUser = func(string) (user.Passwd, bool, error) { return user.Passwd{}, false, nil }
+ if rc := a.status([]string{"--user", rec.User}); rc != 0 {
+ t.Fatalf("status rc = %d, want recovery status", rc)
+ }
+ if got := out.String(); !strings.Contains(got, "identity=deletion-recovery-absent") || !strings.Contains(got, "uid=1001") {
+ t.Fatalf("status hid absent recovery: %q", got)
+ }
+}
+
+func TestDoctorReportsLifecycleMarkerWithoutRegistryRow(t *testing.T) {
+ const (
+ registered = "xxvcc-registered"
+ markerOnly = "xxvcc-marker-only"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ a, _, errb := newTestApp(t, "")
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Registry.Record(registry.Record{
+ User: registered, UID: 1001, Generation: generation, IdentityBound: true, Port: 22,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ a.LookupUser = func(name string) (user.Passwd, bool, error) {
+ if name != registered {
+ t.Fatalf("unexpected passwd lookup for %q", name)
+ }
+ return user.Passwd{
+ Name: name, UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/" + name, Shell: "/bin/sh",
+ }, true, nil
+ }
+ a.ListMarkerAccounts = func() ([]string, error) {
+ return []string{registered, markerOnly}, nil
+ }
+
+ if rc := a.doctor(nil); rc != 1 {
+ t.Fatalf("doctor rc = %d, want marker-only anomaly failure", rc)
+ }
+ got := errb.String()
+ if !strings.Contains(got, "lifecycle marker but has no registry row") || !strings.Contains(got, markerOnly) {
+ t.Fatalf("doctor did not report the marker-only account: %q", got)
+ }
+ if strings.Contains(got, "deletion: "+registered) {
+ t.Fatalf("doctor reported a marker backed by a registry row: %q", got)
+ }
+}
+
+func TestDoctorFailsWhenLifecycleMarkerScanFails(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ wantErr := errors.New("passwd fixture unreadable")
+ a.ListMarkerAccounts = func() ([]string, error) { return nil, wantErr }
+
+ if rc := a.doctor(nil); rc != 1 {
+ t.Fatalf("doctor rc = %d, want marker-scan failure", rc)
+ }
+ got := errb.String()
+ if !strings.Contains(got, "cannot scan account lifecycle markers") || !strings.Contains(got, wantErr.Error()) {
+ t.Fatalf("doctor hid the marker-scan failure: %q", got)
+ }
+}
+
+func TestDoctorDoesNotCompareMarkersAgainstUnreadableRegistry(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ if err := os.Mkdir(a.Registry.File, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ scans := 0
+ a.ListMarkerAccounts = func() ([]string, error) {
+ scans++
+ return []string{"xxvcc-marker-only"}, nil
+ }
+
+ if rc := a.doctor(nil); rc != 1 {
+ t.Fatalf("doctor rc = %d, want unreadable-registry failure", rc)
+ }
+ if scans != 0 {
+ t.Fatalf("marker scan calls = %d, want none without a readable registry", scans)
+ }
+ got := errb.String()
+ if !strings.Contains(got, "cannot read registry") {
+ t.Fatalf("doctor hid the registry failure: %q", got)
+ }
+ if strings.Contains(got, "lifecycle marker but has no registry row") {
+ t.Fatalf("doctor made a missing-row claim without a readable registry: %q", got)
+ }
+}
diff --git a/internal/cli/invite.go b/internal/cli/invite.go
index cd1f583..af78a25 100644
--- a/internal/cli/invite.go
+++ b/internal/cli/invite.go
@@ -282,7 +282,7 @@ func (a *App) invite(args []string) int {
// Work out what would have to be installed BEFORE the summary, so the summary
// can name it and the YES can be its consent. This only decides — the install
// itself is a host change and waits until after the confirmation.
- depPkgs, ok := a.planDeps(grantSudo == "yes", fInstallDeps, fNoInstallDeps, fYes)
+ depPkgs, ok := a.planDeps(grantSudo == "yes", plan.password, fInstallDeps, fNoInstallDeps, fYes)
if !ok {
return 1
}
@@ -308,12 +308,15 @@ func (a *App) invite(args []string) int {
// Dependency packages are host prerequisites, not lifecycle state; install
// them before taking the account lock so a package manager cannot delay an
- // already-due scheduled revoke. The account transaction starts below.
- if !a.installDeps(grantSudo == "yes", depPkgs) {
+ // already-due scheduled revoke. The account transaction starts below. Keep the
+ // per-name lock outside the global lock: every account path uses that order.
+ if !a.installDeps(grantSudo == "yes", plan.password, depPkgs) {
return 1
}
- return a.withLifecycleLock(func() int {
- return a.runInvite(username, host, port, hours, grantSudo == "yes", autoRev == "yes", plan)
+ return a.withAccountExclusiveLock(username, func() int {
+ return a.withLifecycleLock(func() int {
+ return a.runInvite(username, host, port, hours, grantSudo == "yes", autoRev == "yes", plan)
+ })
})
}
@@ -404,11 +407,11 @@ func (a *App) loginSummary(plan loginPlan, username string) string {
// effective configuration before a single change is made to the host.
type loginPlan struct {
password bool // issue a password instead of a key (--password-login)
- fixSSHD bool // write a per-account sshd drop-in to make the key work
+ fixSSHD bool // write a per-account sshd drop-in to admit the key in config
report sysinfo.LoginReport // what the check found (drives the drop-in's contents)
- verified bool // sshd's effective config was read and it says this login works
- // unverified says, in the invite's own words, why the login could not be
- // proved. Non-empty exactly when verified is false.
+ verified bool // the effective-config check completed without a blocker or unknown
+ // unverified says, in the invite's own words, why the config verdict was
+ // inconclusive. Non-empty exactly when verified is false.
unverified string
}
@@ -420,25 +423,33 @@ func (a *App) sshdConfig(user string) (*sysinfo.SSHDConfig, error) {
if a.SSHDConfig == nil {
return nil, fmt.Errorf("no sshd config probe is wired")
}
- return a.SSHDConfig(user)
+ cfg, err := a.SSHDConfig(user)
+ if err != nil {
+ return nil, err
+ }
+ if cfg == nil {
+ return nil, fmt.Errorf("sshd config probe returned no configuration")
+ }
+ return cfg, nil
}
-// planLogin decides how the invitee will log in, and is the gate that stops the
-// tool from printing an invite nobody can use.
+// planLogin decides how the invitee will log in. It blocks known configuration
+// incompatibilities and requires a conclusive effective-config verdict before a
+// password is issued; a key invite may instead be marked UNVERIFIED.
//
// It runs before any mutation: on refusal the account does not exist, so there
// is nothing to roll back and nothing left behind.
func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool) (loginPlan, bool) {
- // useradd -m gives the account a primary group of its own name; that is the
- // group an AllowGroups whitelist would have to admit. The real group set is
- // re-checked after creation, when the drop-in is proved.
+ // A same-name primary group is the common useradd default and is the only safe
+ // pre-creation prediction. Some distributions instead select a shared group;
+ // the real group set is re-checked after creation before any credential lands.
predicted := []string{username}
cfg, err := a.sshdConfig(username)
if err != nil {
// Password authentication exposes a reusable secret. Never issue one unless
- // the effective configuration was read successfully and proved that sshd
- // accepts it. Key-only invitations can remain explicitly UNVERIFIED.
+ // the effective-configuration check is conclusive. Key-only invitations can
+ // remain explicitly UNVERIFIED.
if wantPassword {
a.errorf("%s: %v", a.P.M("无法读取 sshd 有效配置,拒绝创建密码登录",
"cannot read the effective sshd config; refusing a password login"), err)
@@ -451,15 +462,24 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool
}
if wantPassword {
- rep := a.checkPasswordLogin(cfg, username, predicted)
+ rep, deferred := a.checkPasswordLoginDetailed(cfg, username, predicted, false)
+ if deferred && rep.OK() {
+ a.warnf("%s", a.P.M(
+ "sshd 含有账号创建前无法求值的 Match Group;将先创建无凭据账号,并在设置密码前按真实用户组重新检查。",
+ "sshd has a Match Group that cannot be evaluated before the account exists; a credential-less account will be created and re-checked against its real groups before any password is set."))
+ a.warnf("%s", a.P.M(
+ "密码登录会削弱本工具的安全模型:密码在账号的整个生命周期内都可被全网爆破,且必须以明文交付。用完请立即撤销。",
+ "password login weakens this tool's security model: the password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear. Revoke as soon as you are done."))
+ return loginPlan{password: true, report: rep, unverified: uncertainReason(rep)}, true
+ }
if !rep.Certain() {
if rep.OK() {
- a.errorf("%s", a.P.M("无法证明 sshd 会接受该账号的密码登录,拒绝创建密码登录:",
- "cannot prove sshd would accept a password login for this account; refusing a password login:"))
+ a.errorf("%s", a.P.M("sshd 有效配置检查无法确认该账号的密码凭据,拒绝创建密码登录:",
+ "the effective sshd config check cannot confirm the password credential for this account; refusing a password login:"))
a.reportUncertainty(rep)
return loginPlan{}, false
}
- a.errorf("%s", a.P.M("sshd 不接受该账号的密码登录:", "sshd would not accept a password login for this account:"))
+ a.errorf("%s", a.P.M("sshd 有效配置检查发现该账号密码凭据的阻碍:", "the effective sshd config check found a blocker for this account's password credential:"))
a.reportBlockers(rep)
return loginPlan{}, false
}
@@ -469,17 +489,27 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool
return loginPlan{password: true, verified: true, report: rep}, true
}
- rep := a.checkKeyLogin(cfg, username, predicted)
+ rep, deferred := a.checkKeyLoginDetailed(cfg, username, predicted, false)
a.reportUncertainty(rep)
+ if deferred && rep.OK() {
+ // A future account's Match Group result is unknowable until NSS can resolve
+ // its real memberships. Preserve an explicit repair authorization through that
+ // phase; confirmLogin will either discover no blocker, update report with the
+ // real fixable blockers, or fail closed before credentials are installed.
+ return loginPlan{
+ fixSSHD: fix == "yes",
+ report: rep,
+ verified: false,
+ unverified: "sshd Match Group cannot be evaluated until the account exists",
+ }, true
+ }
if rep.OK() {
// Certain(), not OK(): a rule that could not be evaluated — an AllowUsers
- // entry that also pins the source address — is not a proof, and an invite
- // that called it one would be exactly the false promise this check exists
- // to end.
+ // entry that also pins the source address — prevents a conclusive verdict.
return loginPlan{verified: rep.Certain(), unverified: uncertainReason(rep)}, true
}
- a.errorf("%s", a.P.M("sshd 不会接受该账号的公钥登录:", "sshd would not accept a public-key login for this account:"))
+ a.errorf("%s", a.P.M("sshd 有效配置检查发现该账号公钥凭据的阻碍:", "the effective sshd config check found a blocker for this account's public-key credential:"))
a.reportBlockers(rep)
// An interactive operator who ends up unable to use a key gets one offer of the
@@ -547,10 +577,11 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool
return loginPlan{}, false
}
-// confirmLogin re-runs the preflight against the account's REAL groups, now that
-// it exists, and updates the plan. It returns false if the login the invite is
-// about to promise would not actually work — the caller rolls back, so the host
-// is left as it was found.
+// confirmLogin re-runs the effective-config check against the account's REAL
+// groups, now that it exists, and updates the plan. It returns false when that
+// check blocks the credential, or cannot conclusively assess a password. The
+// caller then attempts rollback; any cleanup it cannot prove complete retains the
+// credential-less account and registry witness for explicit recovery.
//
// This is where a wrong prediction is caught. planLogin had to guess the group
// set before the account existed; sshd decides Allow/DenyGroups on the real one.
@@ -560,7 +591,7 @@ func (a *App) confirmLogin(username string, groups []string, plan *loginPlan) bo
if plan.fixSSHD || plan.password {
// We were about to modify sshd on the strength of a reading we can no
// longer take, or issue a reusable password whose login path can no
- // longer be proved. Refuse and let the caller roll the account back.
+ // longer be checked conclusively. Refuse and let the caller roll the account back.
a.errorf("%s: %v", a.P.M("无法重新读取 sshd 有效配置", "cannot re-read the effective sshd config"), err)
return false
}
@@ -568,19 +599,21 @@ func (a *App) confirmLogin(username string, groups []string, plan *loginPlan) bo
plan.unverified = "the effective sshd config could not be read"
return true
}
- rep := a.checkKeyLogin(cfg, username, groups)
+ var rep sysinfo.LoginReport
if plan.password {
- rep = a.checkPasswordLogin(cfg, username, groups)
+ rep = a.checkPasswordLogin(cfg, username, groups, true)
if !rep.Certain() {
if rep.OK() {
- a.errorf("%s", a.P.M("无法证明 sshd 会接受该账号的密码登录,拒绝签发密码:",
- "cannot prove sshd would accept a password login for this account; refusing to issue a password:"))
+ a.errorf("%s", a.P.M("sshd 有效配置检查无法确认该账号的密码凭据,拒绝签发密码:",
+ "the effective sshd config check cannot confirm the password credential for this account; refusing to issue a password:"))
a.reportUncertainty(rep)
} else {
a.reportBlockers(rep)
}
return false
}
+ } else {
+ rep = a.checkKeyLogin(cfg, username, groups, true)
}
switch {
case rep.OK():
@@ -591,8 +624,8 @@ func (a *App) confirmLogin(username string, groups []string, plan *loginPlan) bo
// The confirmation summary promised an sshd exception at a named path.
// Not writing it is the right outcome, but the operator was told it would
// appear, so say plainly that it will not.
- a.info(a.P.M("按该账号的真实用户组复核后,sshd 本就接受此登录;未写入 sshd 例外。",
- "re-checked against the account's real groups: sshd accepts this login as it is; no sshd exception was written."))
+ a.info(a.P.M("按该账号的真实用户组复核后,sshd 有效配置本就允许此凭据;未写入 sshd 例外。",
+ "re-checked against the account's real groups: the effective sshd config already permits this credential; no sshd exception was written."))
}
plan.fixSSHD = false
plan.report = sysinfo.LoginReport{}
@@ -656,37 +689,58 @@ func (a *App) reportBlockers(rep sysinfo.LoginReport) {
}
}
-// checkKeyLogin runs the key-login check and augments it with the one thing the
-// per-user `sshd -T` probe cannot see: a connection-scoped `Match` block.
-// Whether such a block admits the invitee depends on attributes such as source
-// address or local port, which are unknowable here, so its mere presence makes the login unverifiable — never a
-// blocker, so it neither refuses the invite nor triggers a fix, only downgrades a
-// "verified" claim to an honest UNVERIFIED.
-func (a *App) checkKeyLogin(cfg *sysinfo.SSHDConfig, user string, groups []string) sysinfo.LoginReport {
- return a.withConnectionScopedMatch(sysinfo.CheckKeyLogin(cfg, user, groups))
+// checkKeyLogin runs the key-login check and augments it with Match criteria a
+// user-only `sshd -T` probe cannot evaluate in the current account phase.
+func (a *App) checkKeyLogin(cfg *sysinfo.SSHDConfig, user string, groups []string, accountExists bool) sysinfo.LoginReport {
+ rep, _ := a.checkKeyLoginDetailed(cfg, user, groups, accountExists)
+ return rep
}
-func (a *App) checkPasswordLogin(cfg *sysinfo.SSHDConfig, user string, groups []string) sysinfo.LoginReport {
- return a.withConnectionScopedMatch(sysinfo.CheckPasswordLogin(cfg, user, groups))
+func (a *App) checkPasswordLogin(cfg *sysinfo.SSHDConfig, user string, groups []string, accountExists bool) sysinfo.LoginReport {
+ rep, _ := a.checkPasswordLoginDetailed(cfg, user, groups, accountExists)
+ return rep
}
-func (a *App) withConnectionScopedMatch(rep sysinfo.LoginReport) sysinfo.LoginReport {
- hasConnectionScopedMatch := sysinfo.HasConnectionScopedMatch
- if a.SSHDHasConnectionScopedMatch != nil {
- hasConnectionScopedMatch = a.SSHDHasConnectionScopedMatch
+func (a *App) checkKeyLoginDetailed(cfg *sysinfo.SSHDConfig, user string, groups []string, accountExists bool) (sysinfo.LoginReport, bool) {
+ return a.withUnverifiableMatch(sysinfo.CheckKeyLogin(cfg, user, groups), accountExists)
+}
+
+func (a *App) checkPasswordLoginDetailed(cfg *sysinfo.SSHDConfig, user string, groups []string, accountExists bool) (sysinfo.LoginReport, bool) {
+ return a.withUnverifiableMatch(sysinfo.CheckPasswordLogin(cfg, user, groups), accountExists)
+}
+
+// withUnverifiableMatch returns the augmented report and whether account
+// creation alone can resolve every unknown. That second result is true only for
+// a pre-account Match Group: connection-scoped rules, incomplete scans, and
+// address-qualified AllowUsers remain unknown after useradd and are never
+// eligible for deferred password issuance.
+func (a *App) withUnverifiableMatch(rep sysinfo.LoginReport, accountExists bool) (sysinfo.LoginReport, bool) {
+ hasUnverifiableMatch := sysinfo.HasUnverifiableMatch
+ if a.SSHDHasUnverifiableMatch != nil {
+ hasUnverifiableMatch = a.SSHDHasUnverifiableMatch
+ }
+ persistent := hasUnverifiableMatch(true)
+ if persistent {
+ rep.Unverifiable = append(rep.Unverifiable,
+ "sshd has a connection-scoped or otherwise unreadable Match rule; whether this account is admitted cannot be checked from user and group information alone")
+ return rep, false
}
- if hasConnectionScopedMatch() {
+ if accountExists {
+ return rep, false
+ }
+ if hasUnverifiableMatch(false) {
+ deferred := len(rep.Unverifiable) == 0
rep.Unverifiable = append(rep.Unverifiable,
- "sshd has a connection-scoped Match rule; whether this account is admitted depends on address, port, or routing attributes that cannot be checked here")
+ "sshd has a Match Group rule that cannot be evaluated until the account exists and its NSS groups can be resolved")
+ return rep, deferred
}
- return rep
+ return rep, false
}
-// offerPasswordFallback is the escape hatch for when a key login cannot be made
-// to work. On an interactive run, if sshd would accept a password for this
-// account, it offers that instead — so an operator driving the menu (who cannot
-// reach --password-login, a flag) is not dead-ended on a locked-down host with no
-// working invite and no obvious way forward.
+// offerPasswordFallback is the escape hatch when the effective-config check
+// reports a blocker for the planned key. If the same check conclusively admits a
+// password for this account, it offers that instead, so an operator driving the
+// menu (who cannot reach --password-login, a flag) has an alternative.
//
// Password login is the weakest grant the tool issues, so the offer states that
// cost first and defaults to No: it removes the dead-end without nudging anyone
@@ -698,27 +752,40 @@ func (a *App) offerPasswordFallback(cfg *sysinfo.SSHDConfig, username string, in
if !interactive {
return loginPlan{}, false
}
- rep := a.checkPasswordLogin(cfg, username, []string{username})
- if !rep.Certain() {
+ rep, deferred := a.checkPasswordLoginDetailed(cfg, username, []string{username}, false)
+ if deferred && rep.OK() {
+ a.warnf("%s", a.P.M(
+ "密码策略取决于账号创建后的真实用户组;将在设置任何密码前重新检查。",
+ "password policy depends on the account's real post-creation groups; it will be re-checked before any password is set."))
+ } else if !rep.Certain() {
if rep.OK() {
- a.warnf("%s", a.P.M("无法证明 sshd 会接受密码登录,因此不提供密码回退。",
- "cannot prove sshd would accept a password login, so no password fallback is offered."))
+ a.warnf("%s", a.P.M("sshd 有效配置检查无法确认密码凭据,因此不提供密码回退。",
+ "the effective sshd config check cannot confirm the password credential, so no password fallback is offered."))
a.reportUncertainty(rep)
}
return loginPlan{}, false
}
- a.warnf("%s", a.P.M(
- "该账号无法用公钥登录,但 sshd 接受密码登录。密码在账号整个生命周期内可被全网爆破、且必须以明文交付,是本工具最弱的授权方式。",
- "this account cannot log in with a key, but sshd accepts a password. A password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear — the weakest grant this tool issues."))
+ if deferred && rep.OK() {
+ a.warnf("%s", a.P.M(
+ "密码在账号整个生命周期内可被全网爆破、且必须以明文交付,是本工具最弱的授权方式。",
+ "A password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear — the weakest grant this tool issues."))
+ } else {
+ a.warnf("%s", a.P.M(
+ "sshd 有效配置检查发现该账号公钥凭据的阻碍,但未发现密码凭据的阻碍或无法判断项。密码在账号整个生命周期内可被全网爆破、且必须以明文交付,是本工具最弱的授权方式。",
+ "the effective sshd config check found a blocker for this account's key credential but no blocker or unevaluated rule for a password credential. A password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear — the weakest grant this tool issues."))
+ }
usePassword, answered := a.promptYesNo(a.P.M("改用密码登录?[y/N]: ", "Issue a password login instead? [y/N]: "), false)
if !answered || !usePassword {
return loginPlan{}, false
}
+ if deferred && rep.OK() {
+ return loginPlan{password: true, report: rep, unverified: uncertainReason(rep)}, true
+ }
return loginPlan{password: true, verified: true, report: rep}, true
}
-// reportUncertainty prints the notes and the could-not-evaluate rules that keep
-// a report from being a proof.
+// reportUncertainty prints the notes and unevaluated rules that keep an
+// effective-config verdict from being conclusive.
func (a *App) reportUncertainty(rep sysinfo.LoginReport) {
for _, w := range rep.Warnings {
a.warnf("%s", w)
@@ -728,8 +795,8 @@ func (a *App) reportUncertainty(rep sysinfo.LoginReport) {
}
}
-// uncertainReason is the invite's own words for why a login could not be proved,
-// or "" when it was.
+// uncertainReason is the invite's own words for why an effective-config verdict
+// was inconclusive, or "" when it was conclusive.
func uncertainReason(rep sysinfo.LoginReport) string {
if rep.Certain() {
return ""
@@ -740,8 +807,8 @@ func uncertainReason(rep sysinfo.LoginReport) string {
return "the effective sshd config could not be read"
}
-// printSSHDFixHint prints the manual change that would make this account's key
-// login work, for an operator who would rather do it themselves.
+// printSSHDFixHint prints the manual change that would make the effective config
+// admit this account's key, for an operator who would rather do it themselves.
//
// It renders the same per-account Match block the tool would have written, not a
// global directive. The blocker is often something other than the pubkey switch
@@ -781,8 +848,8 @@ func (a *App) printSSHDFixHint(username string, rep sysinfo.LoginReport) {
// "no" could ever have meant is "do not create the account", which typing
// anything but YES already achieves. A --yes run keeps the old rule — install
// only with --install-deps — since it has no YES gate to stand in.
-func (a *App) planDeps(needSudo, installDeps, noInstallDeps, yes bool) ([]string, bool) {
- missing := sysinfo.MissingDeps(needSudo)
+func (a *App) planDeps(needSudo, needPassword, installDeps, noInstallDeps, yes bool) ([]string, bool) {
+ missing := sysinfo.MissingDeps(needSudo, needPassword)
if len(missing) == 0 {
return nil, true
}
@@ -815,7 +882,7 @@ func (a *App) planDeps(needSudo, installDeps, noInstallDeps, yes bool) ([]string
// installDeps installs the packages planDeps selected and confirms the tools are
// now present. It is a no-op for an empty list.
-func (a *App) installDeps(needSudo bool, pkgs []string) bool {
+func (a *App) installDeps(needSudo, needPassword bool, pkgs []string) bool {
if len(pkgs) == 0 {
return true
}
@@ -824,7 +891,7 @@ func (a *App) installDeps(needSudo bool, pkgs []string) bool {
a.errorf("%s: %v", a.P.M("安装依赖失败", "dependency install failed"), err)
return false
}
- if still := sysinfo.MissingDeps(needSudo); len(still) > 0 {
+ if still := sysinfo.MissingDeps(needSudo, needPassword); len(still) > 0 {
a.errorf("%s %v", a.P.M("安装后仍缺少:", "still missing after install:"), still)
return false
}
@@ -874,13 +941,16 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
fingerprint = kp.Fingerprint
}
permanent := !wantAuto
+ createdAt := a.Now()
+ var revokeDeadline time.Time
expiresDisplay := a.P.M("永久(不会过期,也不会自动删除)", "never (does not expire or auto-delete)")
if !permanent {
- expiresDisplay = expiry.DisplayLocal(a.Now(), hours)
+ revokeDeadline = expiry.Deadline(createdAt, hours)
+ expiresDisplay = expiry.DisplayLocal(revokeDeadline)
}
rec := registry.Record{
User: username,
- Created: a.Now().Format("2006-01-02 15:04:05 MST"),
+ Created: createdAt.Format("2006-01-02 15:04:05 MST"),
Expires: expiresDisplay,
Sudo: wantSudo,
Host: host,
@@ -899,6 +969,7 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
// disappears out of band.
sudoRemovalConfirmed := true
sshdRemovalConfirmed := true
+ accountCleanupConfirmed := true
confirmSudoRemoved := func() error {
err := a.removeSudoGrant(username)
if err == nil {
@@ -958,6 +1029,21 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
"an account with this name already exists locally or in NSS; refusing creation: "+username))
return 1
}
+ // A previous revoke/rollback may have reached userdel and left a durable
+ // recovery witness. Never consume that witness as a side effect of creating a
+ // replacement account: the operator must finish the old transaction through
+ // revoke, where its recovery state is visible and auditable. In particular,
+ // creating a new generation must not depend on an invite-time mail cleanup and
+ // then overwrite the only evidence needed to retry it.
+ staleRec, staleRegistered, err := a.Registry.Lookup(username)
+ if err != nil {
+ return failf("%s: %v", a.P.M("读取同名账号的旧删除状态失败", "reading prior deletion state for this username failed"), err)
+ }
+ if staleRegistered && staleRec.DeletionStarted {
+ return failf("%s", a.P.M(
+ "同名账号存在未完成的删除恢复见证;拒绝复用用户名或覆盖登记。请先运行 revoke --user "+username+" 完成恢复。",
+ "an unfinished deletion-recovery witness exists for this username; refusing to reuse the name or overwrite the registry. Run revoke --user "+username+" first to complete recovery."))
+ }
if err := errors.Join(a.removeSudoGrant(username), a.removeSSHDException(username)); err != nil {
return failf("%s: %v", a.P.M("无法清除同名账号的遗留授权,拒绝创建", "cannot remove grants left by this username; refusing creation"), err)
}
@@ -971,7 +1057,23 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
if err := a.Scheduler.Cancel(username, staleUnit); err != nil {
return failf("%s: %v", a.P.M("无法确认旧自动删除任务已清除", "cannot confirm stale auto-delete tasks were removed"), err)
}
-
+ // Cancel can remove queued work, but an older at/systemd command may already be
+ // executing an old binary and waiting for this transaction's lifecycle lock. It
+ // has no UID/generation arguments, so letting it resume after a same-name invite
+ // would apply the old deletion intent to the new account. Refuse reuse while that
+ // exact root-owned command is still visible. New binaries also make this legacy
+ // command shape take a nonblocking shared barrier for the username; invite owns
+ // the exclusive side, closing the start-after-scan interval without suppressing
+ // revokes delayed by unrelated lifecycle work.
+ legacyRevoke, err := a.runningLegacyRevoke(username)
+ if err != nil {
+ return failf("%s: %v", a.P.M("无法检查正在运行的旧版撤销任务,拒绝复用用户名", "cannot inspect running legacy revoke tasks; refusing username reuse"), err)
+ }
+ if legacyRevoke {
+ return failf("%s", a.P.M(
+ "检测到该用户名的旧版无世代绑定撤销进程仍在运行;已拒绝复用,请等待其退出并重新运行。",
+ "a legacy revoke process without a generation binding is still running for this username; reuse was refused; wait for it to exit and retry."))
+ }
// Persist the account intent before useradd. A kill or power loss after account
// creation must leave a registry witness even if no sudo/sshd/schedule artifact
// exists. UID 0 means pending and is replaced immediately after lookup.
@@ -989,6 +1091,9 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
if !sshdRemovalConfirmed {
unconfirmed = append(unconfirmed, fmt.Errorf("sshd removal is unconfirmed; keeping registry record"))
}
+ if !accountCleanupConfirmed {
+ unconfirmed = append(unconfirmed, fmt.Errorf("account artifact cleanup is unconfirmed; keeping registry record"))
+ }
if err := errors.Join(unconfirmed...); err != nil {
return err
}
@@ -999,12 +1104,22 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
if exists {
return fmt.Errorf("account still exists; keeping registry record")
}
- return a.Registry.Remove(username)
+ return a.releaseRegistryAfterCleanup(username)
})
- if err := a.Users.CreatePending(username, resolveShell(), generation); err != nil {
+ // useradd can create the account (and Home) before reporting an
+ // error. Close the registry-removal gate before invoking the helper: until a
+ // complete passwd identity is captured, rollback cannot prove which artifacts
+ // are safe to delete, so the pending row must remain as the recovery witness.
+ accountCleanupConfirmed = false
+ pw, err := a.Users.CreatePendingIdentity(username, resolveShell(), generation)
+ if err != nil {
return failf("%s: %v", a.P.M("创建用户失败", "create user failed"), err)
}
+ // Keep a separately named rollback witness. A failed MarkManagedExpected call
+ // may return a zero Passwd even after usermod partially ran; overwriting this
+ // value would discard the last complete identity that this transaction proved.
+ rollbackIdentity := pw
cleanups = append(cleanups, func() error {
var causes []error
if !sudoRemovalConfirmed {
@@ -1014,16 +1129,13 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
causes = append(causes, fmt.Errorf("sshd removal is unconfirmed; account disabled and retained"))
}
mayDelete := sudoRemovalConfirmed && sshdRemovalConfirmed
- return errors.Join(errors.Join(causes...), a.rollbackInviteAccount(username, rec, mayDelete))
+ cleanupErr := errors.Join(errors.Join(causes...), a.rollbackInviteAccount(username, rec, rollbackIdentity, mayDelete))
+ if cleanupErr == nil {
+ accountCleanupConfirmed = true
+ }
+ return cleanupErr
})
- pw, ok, lookupErr := a.lookupUser(username)
- if lookupErr != nil {
- return failf("%s: %v", a.P.M("读取新账号信息失败", "reading the new account failed"), lookupErr)
- }
- if !ok {
- return failf("%s", a.P.M("无法定位新用户家目录", "cannot locate new user's home"))
- }
rec.UID = pw.UID
// Persist the UID while the passwd entry still carries PendingGECOS. An older
// binary ignores the appended Pending field, but it does understand that this
@@ -1032,9 +1144,84 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
if err := a.Registry.Record(rec); err != nil {
return failf("%s: %v", a.P.M("登记新账号身份失败", "recording the new account identity failed"), err)
}
- if err := a.Users.MarkManaged(username, generation); err != nil {
+ // Put an account-level login gate in place before any deferred work is
+ // drained, the pending marker is promoted, or a credential is installed.
+ // useradd's initial password lock is not enough for a key-based account: a
+ // public key bypasses that lock, while an account expiry is enforced for both
+ // key and password authentication. If this process is killed at any later
+ // setup boundary, the account therefore remains unusable until a subsequent
+ // successful invite installs its requested lifetime below.
+ if err := a.Users.DisableLogin(username); err != nil {
+ return failf("%s: %v", a.P.M("建立新账号的安全失效状态失败", "establishing the new account's fail-closed login state failed"), err)
+ }
+ // A previously deleted account can leave a same-name personal crontab or an at
+ // job carrying the numeric UID that useradd just selected. The pending account
+ // is expired, password-locked, and has no credential yet; it keeps that identity
+ // occupied while two clear/kill passes wait out daemon-cached work. Do this
+ // before the managed marker, password, key, or sudo policy exists, so inherited
+ // deferred work never reaches a grant.
+ if err := a.quiesceScheduledAccount(username, pw); err != nil {
+ return failf("%s: %v", a.P.M("无法清除同名账号或复用 UID 的遗留 cron/at 任务", "cannot clear cron/at work left by the reused username or UID"), err)
+ }
+ // The drain intentionally gives daemon-cached work a full polling cycle to
+ // start. Re-scan now, while the account is still expired, password-locked,
+ // credential-less, and marked pending. A legacy command found here triggers the
+ // ordinary rollback before this identity can become usable.
+ legacyRevoke, err = a.runningLegacyRevoke(username)
+ if err != nil {
+ return failf("%s: %v", a.P.M("任务清场后无法复查正在运行的旧版撤销任务", "cannot recheck running legacy revoke tasks after deferred-job cleanup"), err)
+ }
+ if legacyRevoke {
+ return failf("%s", a.P.M(
+ "任务清场期间启动了该用户名的旧版无世代绑定撤销进程;已回滚新账号,请等待旧任务退出后重试。",
+ "a legacy revoke process without a generation binding started during deferred-job cleanup; the new account was rolled back; wait for the old task to exit before retrying."))
+ }
+ // Delivery can recreate /var/mail/USER during the deliberate daemon drain even
+ // though pending-account creation swept an older generation's spool. Run the
+ // same identity-checked cleanup again while this account is still expired,
+ // password-locked, pending, credential-less, and without a Home.
+ if err := a.Users.ClearManagedMailExpected(username, pw); err != nil {
+ return failf("%s: %v", a.P.M(
+ "任务清场后无法安全清除同名旧邮件,拒绝激活账号",
+ "cannot safely clear same-name stale mail after deferred-job cleanup; refusing to activate the account"), err)
+ }
+ if err := a.accountStillMatches(username, pw); err != nil {
+ return failf("%s: %v", a.P.M(
+ "创建账号目录前账号身份发生变化",
+ "the account identity changed before creating its Home"), err)
+ }
+ // Keep the Home absent until every inherited cron/at job and residual process
+ // has been drained. A previous account generation therefore has nowhere to
+ // plant authorized_keys or shell startup files that the new identity can inherit.
+ if err := a.Users.CreateManagedHomeExpected(username, pw); err != nil {
+ return failf("%s: %v", a.P.M(
+ "任务清场后无法安全创建账号目录,拒绝激活账号",
+ "cannot safely create the account Home after deferred-job cleanup; refusing to activate the account"), err)
+ }
+ if err := a.accountStillMatches(username, pw); err != nil {
+ return failf("%s: %v", a.P.M(
+ "创建账号目录期间账号身份发生变化",
+ "the account identity changed while creating its Home"), err)
+ }
+ // The requested lifetime starts only after the deliberate deferred-job drain;
+ // otherwise that safety wait would shorten a nominal one-hour invite. Persist
+ // the adjusted display and absolute target while the account is still pending.
+ createdAt = a.Now()
+ rec.Created = createdAt.Format("2006-01-02 15:04:05 MST")
+ if !permanent {
+ revokeDeadline = expiry.Deadline(createdAt, hours)
+ expiresDisplay = expiry.DisplayLocal(revokeDeadline)
+ rec.Expires = expiresDisplay
+ }
+ if err := a.Registry.Record(rec); err != nil {
+ return failf("%s: %v", a.P.M("登记任务清场后的账号时间失败", "recording the account time after deferred-job cleanup failed"), err)
+ }
+ managedIdentity, err := a.Users.MarkManagedExpected(username, generation, pw)
+ if err != nil {
return failf("%s: %v", a.P.M("完成新账号身份标记失败", "finalizing the new account identity marker failed"), err)
}
+ pw = managedIdentity
+ rollbackIdentity = managedIdentity
completed := rec
completed.Pending = false
if err := a.Registry.Record(completed); err != nil {
@@ -1045,17 +1232,23 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
// The preflight had to PREDICT this account's groups, because it ran before the
// account existed. Now they are real — and sshd decides AllowGroups/DenyGroups
// on exactly them. Re-run the same check against the real group set before
- // anything is printed: `useradd -m` only gives the account a group of its own
+ // anything is printed: `useradd` only gives the account a group of its own
// name when USERGROUPS_ENAB is on, and on a host that puts new accounts in a
// shared group instead (openSUSE ships GROUP=100 "users"), a `DenyGroups users`
- // rule would refuse this login while the invite claimed it was verified.
+ // rule would block the credential while the invite claimed config verification.
groups, groupsErr := user.Groups(pw)
if groupsErr != nil {
return failf("%s: %v", a.P.M("无法可靠读取新账号的用户组", "cannot reliably read the new account's groups"), groupsErr)
}
+ if err := a.accountStillMatches(username, pw); err != nil {
+ return failf("%s: %v", a.P.M("读取用户组期间账号身份发生变化", "the account identity changed while resolving its groups"), err)
+ }
if !a.confirmLogin(username, groups, &plan) {
- return failf("%s", a.P.M("按该账号的真实用户组复核后,sshd 不会接受此登录",
- "re-checked against the account's real groups: sshd would not accept this login"))
+ return failf("%s", a.P.M("按该账号的真实用户组复核后,sshd 有效配置检查发现此凭据的阻碍",
+ "re-checked against the account's real groups: the effective sshd config check found a blocker for this credential"))
+ }
+ if err := a.accountStillMatches(username, pw); err != nil {
+ return failf("%s: %v", a.P.M("复核 sshd 配置期间账号身份发生变化", "the account identity changed during the sshd configuration check"), err)
}
if plan.password {
@@ -1072,8 +1265,8 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
}
// The sshd exception goes in only once the account (and its key) exist, and it
- // is proved effective against the account's real groups before sshd is
- // reloaded. Grant attempts its own rollback on failure; the CLI retries removal
+ // is confirmed in the effective config against the account's real groups before
+ // sshd is reloaded. Grant attempts its own rollback on failure; the CLI retries removal
// independently before account rollback can free the username.
sshdDropIn := ""
if plan.fixSSHD {
@@ -1085,12 +1278,12 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
}
sshdDropIn = res.Path
cleanups = append(cleanups, confirmSSHDRemoved)
- a.success(a.P.M("已为该账号单独开启公钥登录(全局策略未改动):"+res.Path,
- "public-key login enabled for this account only (the global policy is untouched): "+res.Path))
+ a.success(a.P.M("已在 sshd 配置中为该账号单独开启公钥认证(全局策略未改动):"+res.Path,
+ "public-key authentication enabled in the sshd config for this account only (the global policy is untouched): "+res.Path))
// Two independent things must both hold before the invite may say "verified":
- // 1. the running daemon adopted the change (res.Reloaded). `sshd -t` forks a
- // fresh sshd to parse the file, which says nothing about the one already
- // serving the port, so a reload we could not get through means unverified.
+ // 1. the reload request succeeded (res.Reloaded). `sshd -t` forks a fresh sshd
+ // to parse the file, which says nothing about the one already serving the
+ // port, so a reload request we could not complete means unverified.
// 2. nothing about this login remained unevaluable (report.Unverifiable): the
// drop-in lifts the blockers we understood, but an address-qualified
// AllowUsers we could not evaluate still stands, and the invitee's source
@@ -1099,8 +1292,8 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
case !res.Reloaded:
plan.verified = false
plan.unverified = "sshd could not be asked to re-read its configuration; reload it yourself"
- a.warnf("%s", a.P.M("未能通知正在运行的 sshd 重新读取配置。若 sshd 是常驻进程,请手动 `sshd -t && systemctl reload ssh` 后此邀请才会生效(socket 激活的 sshd 无需 reload)。",
- "could not ask the running sshd to re-read its configuration. If sshd is a long-running process, run `sshd -t && systemctl reload ssh` yourself before this invite will work (a socket-activated sshd needs no reload)."))
+ a.warnf("%s", a.P.M("未能通知正在运行的 sshd 重新读取配置。若 sshd 是常驻进程,请手动运行 `sshd -t && systemctl reload ssh`,使配置变更生效(socket 激活的 sshd 无需 reload)。",
+ "could not ask the running sshd to re-read its configuration. If sshd is a long-running process, run `sshd -t && systemctl reload ssh` yourself so the configuration change takes effect (a socket-activated sshd needs no reload)."))
case len(plan.report.Unverifiable) > 0:
plan.verified = false
plan.unverified = plan.report.Unverifiable[0]
@@ -1110,17 +1303,6 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
}
}
- // Expiry is set only when the account will auto-delete. Without auto-delete the
- // account is permanent — no chage expiry, no deletion — which is what "not
- // auto-deleting" now means; the old behaviour (login expires via chage but the
- // account is never deleted) was neither temporary nor permanent, and surprised
- // operators who read "no auto-delete" as "keep it".
- if !permanent {
- if err := a.Users.SetExpiry(username, expiry.Date(a.Now(), hours)); err != nil {
- return failf("%s: %v", a.P.M("设置到期失败", "set expiry failed"), err)
- }
- }
-
sudoGranted := false
if wantSudo {
// Grant can make the drop-in live before a later verification step fails.
@@ -1168,7 +1350,7 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
if err := fsutil.RootSafeFile(a.InstallPath); err != nil {
return failf("%s: %v", a.P.M("稳定命令不安全", "the stable command is unsafe"), err)
}
- unit, err := a.Scheduler.Schedule(username, pw.UID, generation, hours)
+ unit, err := a.Scheduler.Schedule(username, pw.UID, generation, revokeDeadline)
if err != nil {
return failf("%s: %v", a.P.M("自动删除任务创建失败,已拒绝创建临时账号", "auto-delete scheduling failed; refusing to create the temporary account"), err)
}
@@ -1181,6 +1363,24 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
}
}
+ // The pending account was deliberately expired and password-locked before the
+ // deferred-job drain. Credentials replace the password lock, but the past-date
+ // expiry remains the authentication-method-neutral gate until sudo/sshd policy,
+ // the durable registry state, and any automatic revoke task are all complete.
+ // This is the transaction's activation point: install either the requested
+ // expiry or an explicit never-expire value only after every rollback witness is
+ // in place. Skipping it for a permanent invite would leave the credential
+ // unusable forever.
+ if permanent {
+ if err := a.Users.ClearExpiry(username); err != nil {
+ return failf("%s: %v", a.P.M("恢复永久账号的登录有效期失败", "restoring never-expire login state for the permanent account failed"), err)
+ }
+ } else {
+ if err := a.Users.SetExpiry(username, expiry.Date(revokeDeadline)); err != nil {
+ return failf("%s: %v", a.P.M("设置到期失败", "set expiry failed"), err)
+ }
+ }
+
if err := a.printInvite(inviteBundle{
user: username, host: host, port: port, hours: hours,
sudo: sudoGranted, auto: autoScheduled, autoUnit: autoUnit,
@@ -1213,36 +1413,60 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu
// name-scoped grants are confirmed gone, login is disabled, and every process
// carrying the UID is confirmed terminated. Any uncertainty retains both the
// account and the registry witness for manual recovery.
-func (a *App) rollbackInviteAccount(username string, rec registry.Record, mayDelete bool) error {
- if rec.Pending || rec.UID < 1 || !rec.IdentityBound || !validate.Generation(rec.Generation) {
- return fmt.Errorf("account identity is still pending; account and registry record retained")
+func (a *App) rollbackInviteAccount(username string, rec registry.Record, expected user.Passwd, mayDelete bool) error {
+ if rec.User != username || !rec.IdentityBound || !validate.Generation(rec.Generation) ||
+ expected.Name != username || !validate.AccountID(expected.UID) || !validate.AccountID(expected.GID) ||
+ !validate.ManagedHome(username, expected.Home) || expected.Shell == "" {
+ return fmt.Errorf("account identity is incomplete; account and registry record retained")
+ }
+ if rec.UID > 0 && rec.UID != expected.UID {
+ return fmt.Errorf("account identity differs from the durable registry witness; account and registry record retained")
+ }
+ marker := expected.GECOS
+ if i := strings.IndexByte(marker, ','); i >= 0 {
+ marker = marker[:i]
+ }
+ pendingMarker := config.PendingGenerationGECOSPrefix + rec.Generation
+ managedMarker := config.ManagedGenerationGECOSPrefix + rec.Generation
+ if marker != managedMarker && (marker != pendingMarker || !rec.Pending) {
+ return fmt.Errorf("account generation marker does not match rollback state; account and registry record retained")
}
pw, exists, err := a.lookupUser(username)
if err != nil {
return fmt.Errorf("verify rollback account identity: %w", err)
}
if !exists {
+ if rec.DeletionStarted {
+ if !mayDelete {
+ return nil
+ }
+ return a.reconcileDeletionStarted(rec)
+ }
+ if mayDelete {
+ return a.Users.DeleteExpected(username, expected, func() error {
+ return a.finalScheduledAccountCheck(username, expected)
+ })
+ }
return nil
}
- if pw.UID != rec.UID || !user.MatchesManagedGeneration(pw, rec.Generation) {
+ if pw != expected {
return fmt.Errorf("account identity changed before rollback; account and registry record retained")
}
if !mayDelete {
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return fmt.Errorf("verify retained account before login disable: %w", err)
+ }
if err := a.Users.DisableLogin(username); err != nil {
return fmt.Errorf("disable retained account: %w", err)
}
- if err := a.accountStillMatches(username, pw); err != nil {
- return fmt.Errorf("verify retained account before process termination: %w", err)
- }
- if err := a.terminateProcesses(rec.UID); err != nil {
- return fmt.Errorf("terminate retained account processes: %w", err)
- }
- if err := a.accountStillMatches(username, pw); err != nil {
- return fmt.Errorf("verify retained account after process termination: %w", err)
+ if err := a.quiesceScheduledAccount(username, expected); err != nil {
+ return fmt.Errorf("quiesce retained account cron/at work and processes: %w", err)
}
return nil
}
- stage, err := a.teardownLocalAccount(username, pw)
+ stage, err := a.teardownLocalAccount(username, expected, func() error {
+ return a.persistDeletionStarted(rec, true, expected)
+ })
if err != nil {
return fmt.Errorf("fail-closed account teardown stopped at stage %d: %w", stage, err)
}
@@ -1273,7 +1497,7 @@ type inviteBundle struct {
kp *sshkey.KeyPair // nil for a password invite
password string // empty for a key invite
sshdDropIn string // empty when sshd was not touched
- verified bool // sshd's effective config confirms this login works
+ verified bool // the effective-config check completed without a blocker or unknown
unverified string // why it could not be confirmed; set exactly when verified is false
}
@@ -1470,23 +1694,23 @@ func resolveShell() string {
return "/bin/sh"
}
-// detectOrPromptHost resolves the invite's Host. Local interfaces and cloud
-// metadata never leave this host or its link, so they are probed silently. The
-// external echo services would disclose this server's address to a third party,
-// so they stay behind an explicit yes: a root-run tool must not phone home
-// unasked. Either way the result is offered as a default the operator can
-// override, because a multi-homed box can present the wrong public IP and a
-// wrong Host silently produces an invite nobody can connect with.
+// detectOrPromptHost resolves the invite's Host. Local-interface inspection sends
+// no traffic. Fixed-address cloud metadata probes avoid DNS, redirects, and
+// environment proxies, but may traverse the local or cloud-provider network. The
+// external echo services disclose this server's address to a public third party,
+// so they stay behind an explicit yes. Either way the result is offered as a
+// default the operator must confirm or override. Metadata is unauthenticated and
+// a multi-homed box can present the wrong public IP, so silently accepting either
+// source could direct the invite (and a password) to the wrong SSH server.
func (a *App) detectOrPromptHost() string {
- // A locally-detected public IP is authoritative — it comes from cloud metadata
- // or a routable address on one of this host's own interfaces — so take it
- // without a prompt. `--host` overrides it when the operator wants a domain or a
- // specific address; the summary below prints the Host, so a wrong guess is
- // still visible before anything is created.
+ // A local result can come from an unauthenticated HTTP metadata response or a
+ // routable address on one of this host's interfaces. Neither proves that the
+ // address is the SSH endpoint the invitee should use, so require an explicit
+ // prompt and treat it only as the default.
if ip, ok := a.Detector.LocalPublicIP(2 * time.Second); ok {
a.info(fmt.Sprintf(a.P.M("使用探测到的公网 IP:%s(如需域名或其他地址请用 --host)",
"using the detected public IP: %s (use --host for a domain or a different address)"), ip))
- return ip
+ return a.promptHost(ip)
}
queryExternal, answered := a.promptYesNo(a.P.M("本机未探测到公网 IP。是否向外部服务查询?[y/N]: ",
"No public IP found locally. Ask an external service? [y/N]: "), false)
diff --git a/internal/cli/invite_reuse_root_test.go b/internal/cli/invite_reuse_root_test.go
index 30c6d07..75508b1 100644
--- a/internal/cli/invite_reuse_root_test.go
+++ b/internal/cli/invite_reuse_root_test.go
@@ -61,26 +61,28 @@ func inviteApp(t *testing.T) (*cli.App, *sudoers.Manager, *sshdconf.Manager, str
UnitPrefix: config.AutoRevokeUnitPrefix, LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix},
Now: now, Sys: fakeSched{},
},
- Registry: ®istry.Store{Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock")},
- SSHD: sshdMgr,
- SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil },
- SSHDHasConnectionScopedMatch: func() bool { return false },
- Detector: netdetect.New(),
- Selfmanage: &selfmanage.Manager{InstallPath: installPath},
- Audit: &audit.Logger{Dir: filepath.Dir(auditFile), File: auditFile, Now: now, Actor: func() (string, int) { return "test", 0 }},
- InstallPath: installPath,
- Executable: func() (string, error) { return installPath, nil },
- Now: now,
+ Registry: ®istry.Store{Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock")},
+ SSHD: sshdMgr,
+ SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil },
+ SSHDHasUnverifiableMatch: func(bool) bool { return false },
+ Detector: netdetect.New(),
+ Selfmanage: &selfmanage.Manager{InstallPath: installPath},
+ Audit: &audit.Logger{Dir: filepath.Dir(auditFile), File: auditFile, Now: now, Actor: func() (string, int) { return "test", 0 }},
+ InstallPath: installPath,
+ Executable: func() (string, error) { return installPath, nil },
+ Now: now,
RandHex: func(n int) (string, error) {
if n == 16 {
return "0123456789abcdef0123456789abcdef", nil
}
return "abcdef0123", nil
},
- RandPassword: func(int) (string, error) { return "pw-abcdefgh", nil },
- StdoutIsTTY: func() bool { return true },
- StdinIsTTY: func() bool { return false },
- Geteuid: func() int { return 0 },
+ RandPassword: func(int) (string, error) { return "pw-abcdefgh", nil },
+ StdoutIsTTY: func() bool { return true },
+ StdinIsTTY: func() bool { return false },
+ Geteuid: func() int { return 0 },
+ ClearScheduledJobs: noDeferredJobs,
+ DrainScheduledJobs: noDeferredJobDrain,
}
return a, sudoMgr, sshdMgr, installPath
}
@@ -411,16 +413,36 @@ func TestLegacyIdentityRequiresDirectForceConfirmation(t *testing.T) {
t.Fatal("scheduled revoke deleted a legacy account")
}
if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes", "--force"}); rc == 0 {
- t.Fatal("legacy revoke without --confirm-force succeeded")
+ t.Fatal("unconfirmed legacy revoke succeeded")
}
if !mustExternalUserExists(t, name) {
t.Fatal("unconfirmed legacy revoke deleted the account")
}
- if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes", "--force", "--confirm-force", name}); rc != 0 {
- t.Fatalf("direct confirmed legacy revoke rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String())
+ // v2.6.1 through v2.7.0 emitted exactly this unattended command. It must not
+ // become deletion authority merely because the current binary sees it as an
+ // externally dispatched CLI invocation.
+ if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes", "--force", "--confirm-force", name}); rc == 0 {
+ t.Fatal("historical eight-argument timer command accepted a legacy identity")
+ }
+ if !mustExternalUserExists(t, name) {
+ t.Fatal("historical eight-argument timer command deleted the legacy account")
+ }
+ // App intentionally reuses one buffered reader across prompts. Feed both
+ // confirmations up front so this external-package test does not reach into
+ // private reader state between dispatches.
+ a.In = strings.NewReader(name + "\n" + name + "\n")
+ if rc := a.Dispatch([]string{"revoke", "--user", name, "--force"}); rc == 0 {
+ t.Fatal("piped full-name confirmation accepted a legacy identity")
+ }
+ if !mustExternalUserExists(t, name) {
+ t.Fatal("piped full-name confirmation deleted the legacy account")
+ }
+ a.StdinIsTTY = func() bool { return true }
+ if rc := a.Dispatch([]string{"revoke", "--user", name, "--force"}); rc != 0 {
+ t.Fatalf("interactive confirmed legacy revoke rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String())
}
if mustExternalUserExists(t, name) {
- t.Fatal("direct confirmed legacy revoke did not delete the account")
+ t.Fatal("interactive confirmed legacy revoke did not delete the account")
}
}
@@ -506,7 +528,12 @@ func TestInviteOutputFailureRetainsAccountWhenProcessCleanupIsUncertain(t *testi
partial := &partialFailingWriter{}
a.Out = partial
- a.TerminateProcesses = func(int) error { return errors.New("injected rollback process uncertainty") }
+ a.TerminateProcesses = func(int) error {
+ if partial.wrote == 0 {
+ return nil
+ }
+ return errors.New("injected rollback process uncertainty")
+ }
rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5",
"--no-sudo", "--no-fix-sshd", "--no-auto-revoke", "--yes"})
@@ -628,6 +655,50 @@ func TestInviteRetainsAccountWhenLaterSudoRollbackCannotRemoveGrant(t *testing.T
}
}
+func TestInviteRetainsRegistryWhenPostDeleteMailCleanupFails(t *testing.T) {
+ a, _, _, _ := inviteApp(t)
+ a.Scheduler.Sys = unavailableSched{}
+ const name = "xxvcc-mailhold1"
+ remove := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() }
+ remove()
+ t.Cleanup(remove)
+
+ wantErr := errors.New("injected final mail cleanup failure")
+ mailCalls := 0
+ postDeleteCalls := 0
+ a.Users.RemoveManagedMail = func(user.Passwd) error {
+ mailCalls++
+ exists, err := user.Exists(name)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ postDeleteCalls++
+ return wantErr
+ }
+ return nil
+ }
+
+ rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5",
+ "--hours", "1", "--no-sudo", "--auto-revoke", "--no-fix-sshd", "--yes"})
+ if rc != 1 {
+ t.Fatalf("invite rc=%d, want scheduling/rollback failure\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String())
+ }
+ if mustExternalUserExists(t, name) {
+ t.Fatal("account helper did not remove the failed invite account")
+ }
+ if mailCalls < 2 || postDeleteCalls != 1 {
+ t.Fatalf("mail cleanup calls=%d post-delete=%d, want at least one bound-identity sweep and one post-account sweep", mailCalls, postDeleteCalls)
+ }
+ if present, err := a.Registry.Contains(name); err != nil || !present {
+ t.Fatalf("registry witness was removed after final mail cleanup failed: present=%v err=%v", present, err)
+ }
+ if !strings.Contains(a.Err.(*bytes.Buffer).String(), wantErr.Error()) ||
+ !strings.Contains(a.Err.(*bytes.Buffer).String(), "account artifact cleanup is unconfirmed; keeping registry record") {
+ t.Fatalf("rollback did not report retained artifact witness:\n%s", a.Err.(*bytes.Buffer).String())
+ }
+}
+
func TestRevokeSudoCleanupFailureKeepsDisabledAccountAndRecoveryState(t *testing.T) {
a, sudoMgr, _, _ := inviteApp(t)
tracker := newTrackingSched()
@@ -771,10 +842,10 @@ func mustWriteInvite(t *testing.T, path, content string) {
}
}
-// TestInvitePermanentWhenNoAutoRevoke pins the new "no auto-delete = permanent"
-// semantics: no chage expiry is set (SetExpiry not called), no auto-delete task,
+// TestInvitePermanentWhenNoAutoRevoke pins the "no auto-delete = permanent"
+// semantics: the safety-drain expiry is cleared, no auto-delete task is created,
// and the bundle says permanent. Previously even a --no-auto-revoke account got a
-// chage login-expiry; now it is genuinely permanent.
+// lasting chage login-expiry; now it is genuinely permanent.
func TestInvitePermanentWhenNoAutoRevoke(t *testing.T) {
a, _, _, _ := inviteApp(t)
out := a.Out.(*bytes.Buffer)
@@ -788,7 +859,7 @@ func TestInvitePermanentWhenNoAutoRevoke(t *testing.T) {
if rc != 0 {
t.Fatalf("invite rc=%d: %s", rc, a.Err.(*bytes.Buffer).String())
}
- // No chage expiry: the shadow expiry field must be empty (never set).
+ // chage -E -1 represents never-expire as an empty shadow expiry field.
if line := passwdExpiryField(t, name); line != "" {
t.Errorf("a permanent account must have no chage expiry; shadow expire field = %q", line)
}
diff --git a/internal/cli/manage_root_test.go b/internal/cli/manage_root_test.go
index 1881914..4c3644b 100644
--- a/internal/cli/manage_root_test.go
+++ b/internal/cli/manage_root_test.go
@@ -4,29 +4,124 @@ package cli
import (
"bytes"
+ "errors"
+ "fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
+ "time"
"github.com/xxvcc/linux-temp-admin/internal/config"
+ "github.com/xxvcc/linux-temp-admin/internal/expiry"
"github.com/xxvcc/linux-temp-admin/internal/registry"
"github.com/xxvcc/linux-temp-admin/internal/schedule"
"github.com/xxvcc/linux-temp-admin/internal/sudoers"
+ "github.com/xxvcc/linux-temp-admin/internal/sysinfo"
"github.com/xxvcc/linux-temp-admin/internal/user"
)
// fakeSys satisfies schedule.System without touching systemd or at.
type fakeSys struct{}
-func (fakeSys) HasSystemctl() bool { return false }
-func (fakeSys) Systemctl(...string) error { return nil }
-func (fakeSys) HasAt() bool { return false }
-func (fakeSys) ScheduleAt(string, int) (string, error) { return "", nil }
-func (fakeSys) RemoveAtJobsFor(string) error { return nil }
-func (fakeSys) AtrmJob(string) error { return nil }
-func (fakeSys) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
+func (fakeSys) HasSystemctl() bool { return false }
+func (fakeSys) Systemctl(...string) error { return nil }
+func (fakeSys) HasAt() bool { return false }
+func (fakeSys) ScheduleAt(string, time.Time) (string, error) { return "", nil }
+func (fakeSys) RemoveAtJobsFor(string) error { return nil }
+func (fakeSys) AtrmJob(string) error { return nil }
+func (fakeSys) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
+
+type failedCreateRunner struct{}
+
+func (failedCreateRunner) Look(name string) bool { return name == "useradd" }
+func (failedCreateRunner) Run(string, ...string) error {
+ return os.ErrInvalid
+}
+func (r failedCreateRunner) RunInput(_ string, name string, args ...string) error {
+ return r.Run(name, args...)
+}
+
+type inviteTimingRunner struct {
+ account user.Passwd
+ present bool
+ events *[]string
+ eventsBeforeCredential []string
+ registry *registry.Store
+ recordAtCredential registry.Record
+ recordFound bool
+ recordErr error
+ stopErr error
+}
+
+func (*inviteTimingRunner) Look(name string) bool {
+ switch name {
+ case "useradd", "usermod", "chpasswd", "chage", "userdel":
+ return true
+ default:
+ return false
+ }
+}
+
+func (r *inviteTimingRunner) Run(name string, args ...string) error {
+ switch name {
+ case "useradd":
+ valueAfter := func(flag string) (string, bool) {
+ for i := 0; i+1 < len(args); i++ {
+ if args[i] == flag {
+ return args[i+1], true
+ }
+ }
+ return "", false
+ }
+ home, homeOK := valueAfter("-d")
+ shell, shellOK := valueAfter("-s")
+ gecos, gecosOK := valueAfter("-c")
+ if len(args) == 0 || !homeOK || !shellOK || !gecosOK {
+ return fmt.Errorf("unexpected useradd arguments")
+ }
+ r.account.Name = args[len(args)-1]
+ r.account.Home = home
+ r.account.Shell = shell
+ r.account.GECOS = gecos
+ r.present = true
+ case "usermod":
+ for i := 0; i+1 < len(args); i++ {
+ if args[i] == "-c" {
+ r.account.GECOS = args[i+1]
+ }
+ }
+ if len(args) == 2 && args[0] == "-L" {
+ *r.events = append(*r.events, "password-lock")
+ }
+ case "chage":
+ if len(args) != 3 || args[0] != "-E" {
+ return fmt.Errorf("unexpected chage arguments: %v", args)
+ }
+ *r.events = append(*r.events, "expiry:"+args[1])
+ case "userdel":
+ r.present = false
+ }
+ return nil
+}
+
+func (r *inviteTimingRunner) RunInput(_ string, name string, args ...string) error {
+ if name != "chpasswd" {
+ return r.Run(name, args...)
+ }
+ r.eventsBeforeCredential = append([]string(nil), (*r.events)...)
+ *r.events = append(*r.events, "credential")
+ r.recordAtCredential, r.recordFound, r.recordErr = r.registry.Lookup(r.account.Name)
+ return r.stopErr
+}
+
+func (r *inviteTimingRunner) lookup(name string) (user.Passwd, bool, error) {
+ if !r.present || name != r.account.Name {
+ return user.Passwd{}, false, nil
+ }
+ return r.account, true, nil
+}
func mustUserExists(t *testing.T, name string) bool {
t.Helper()
@@ -55,6 +150,300 @@ func mustUserManaged(t *testing.T, name string) bool {
return managed
}
+func TestRunInviteRetainsPendingRegistryWhenCreateHelperReportsFailure(t *testing.T) {
+ dir := rootOwnedDir(t)
+ username := ""
+ for i := 0; i < 100; i++ {
+ candidate := fmt.Sprintf("ltagate%02d", i)
+ inUse, err := user.NameInUse(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inUse {
+ username = candidate
+ break
+ }
+ }
+ if username == "" {
+ t.Fatal("could not find an unused test username")
+ }
+
+ a, _, errb := newTestApp(t, "")
+ regDir := filepath.Join(dir, "registry")
+ a.Registry = ®istry.Store{
+ Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock"),
+ }
+ a.Users = &user.Manager{
+ Runner: failedCreateRunner{},
+ PrepareManagedHome: func(string) error { return nil },
+ CreateManagedHome: func(user.Passwd) error { return nil },
+ }
+ createdAt := time.Date(2026, 7, 7, 12, 34, 59, 0, time.FixedZone("test", 8*60*60))
+ clockCalls := 0
+ a.Now = func() time.Time {
+ clockCalls++
+ return createdAt.Add(time.Duration(clockCalls-1) * time.Hour)
+ }
+ a.Scheduler = &schedule.Scheduler{
+ SystemdDir: filepath.Join(dir, "systemd"), InstallPath: filepath.Join(dir, "linux-temp-admin"),
+ UnitPrefix: config.AutoRevokeUnitPrefix, Now: a.Now, Sys: fakeSys{},
+ }
+ a.RandHex = func(n int) (string, error) {
+ if n == 16 {
+ return "0123456789abcdef0123456789abcdef", nil
+ }
+ return "abcdef0123", nil
+ }
+
+ if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, true, loginPlan{verified: true}); rc != 1 {
+ t.Fatalf("runInvite rc=%d, want helper failure", rc)
+ }
+ rec, found, err := a.Registry.Lookup(username)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !found || !rec.Pending || rec.UID != 0 || !rec.IdentityBound {
+ t.Fatalf("pending recovery witness was removed after ambiguous helper failure: found=%v rec=%+v", found, rec)
+ }
+ if clockCalls != 1 {
+ t.Fatalf("invite transaction read its creation clock %d times, want once", clockCalls)
+ }
+ if got, want := rec.Created, createdAt.Format("2006-01-02 15:04:05 MST"); got != want {
+ t.Fatalf("recorded creation = %q, want %q", got, want)
+ }
+ if got, want := rec.Expires, expiry.DisplayLocal(expiry.Deadline(createdAt, 1)); got != want {
+ t.Fatalf("recorded deadline = %q, want %q", got, want)
+ }
+ if !strings.Contains(errb.String(), "account artifact cleanup is unconfirmed") {
+ t.Fatalf("ambiguous helper failure did not report retained registry evidence: %q", errb.String())
+ }
+}
+
+func TestRunInviteClearsStaleJobsBeforeCredentialAndRebasesLifetime(t *testing.T) {
+ const (
+ username = "xxvcc-timing1"
+ generation = "0123456789abcdef0123456789abcdef"
+ uid = 4_000_000
+ )
+ if _, exists, err := user.Lookup(username); err != nil {
+ t.Fatal(err)
+ } else if exists {
+ t.Fatalf("test username %s already exists", username)
+ }
+
+ binDir := t.TempDir()
+ idScript := "#!/bin/sh\n" +
+ "if [ \"$1\" = \"-u\" ]; then\n" +
+ " printf \"id: '%s': no such user\\n\" \"$3\" >&2\n" +
+ " exit 1\n" +
+ "fi\n" +
+ "if [ \"$1\" = \"-Gn\" ]; then\n" +
+ " printf '%s\\n' \"$2\"\n" +
+ " exit 0\n" +
+ "fi\n" +
+ "exit 2\n"
+ if err := os.WriteFile(filepath.Join(binDir, "id"), []byte(idScript), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", binDir)
+
+ baseDir := rootOwnedDir(t)
+ regDir := filepath.Join(baseDir, "registry")
+ a, _, errb := newTestApp(t, "")
+ a.Registry = ®istry.Store{
+ Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock"),
+ }
+ a.Scheduler = &schedule.Scheduler{
+ SystemdDir: filepath.Join(baseDir, "systemd"), InstallPath: filepath.Join(baseDir, "linux-temp-admin"),
+ UnitPrefix: config.AutoRevokeUnitPrefix, Sys: fakeSys{},
+ }
+
+ events := []string{}
+ stopErr := errors.New("stop after credential timing observation")
+ runner := &inviteTimingRunner{
+ account: user.Passwd{UID: uid, GID: uid},
+ events: &events,
+ registry: a.Registry,
+ stopErr: stopErr,
+ }
+ mailCalls := 0
+ a.Users = &user.Manager{
+ Runner: runner,
+ LookupUser: runner.lookup,
+ PrepareManagedHome: func(string) error { return nil },
+ CreateManagedHome: func(user.Passwd) error {
+ events = append(events, "home")
+ return nil
+ },
+ ValidateManagedHome: func(user.Passwd) error {
+ events = append(events, "home-validate")
+ return nil
+ },
+ RemoveManagedMail: func(user.Passwd) error {
+ mailCalls++
+ events = append(events, "mail")
+ return nil
+ },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ }
+ a.LookupUser = runner.lookup
+
+ t0 := time.Date(2026, 7, 7, 12, 0, 0, 0, time.FixedZone("test", 8*60*60))
+ t1 := t0.Add(65 * time.Second)
+ drained := false
+ beforeDrainClockCalls := 0
+ afterDrainClockCalls := 0
+ a.Now = func() time.Time {
+ if drained {
+ afterDrainClockCalls++
+ return t1
+ }
+ beforeDrainClockCalls++
+ return t0
+ }
+ a.Scheduler.Now = a.Now
+ a.ClearScheduledJobs = func(name string, gotUID int) error {
+ if name != username || gotUID != uid {
+ t.Fatalf("ClearScheduledJobs(%q, %d), want (%q, %d)", name, gotUID, username, uid)
+ }
+ events = append(events, "clear")
+ return nil
+ }
+ a.TerminateProcesses = func(gotUID int) error {
+ if gotUID != uid {
+ t.Fatalf("TerminateProcesses UID = %d, want %d", gotUID, uid)
+ }
+ events = append(events, "kill")
+ return nil
+ }
+ a.DrainScheduledJobs = func() error {
+ events = append(events, "drain")
+ drained = true
+ return nil
+ }
+ a.RandHex = func(n int) (string, error) {
+ if n != 16 {
+ return "", fmt.Errorf("unexpected random byte count %d", n)
+ }
+ return generation, nil
+ }
+ a.RandPassword = func(int) (string, error) { return "password-for-timing-test", nil }
+ sshdConfig := sysinfo.ParseSSHD("passwordauthentication yes\n")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sshdConfig, nil }
+
+ if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, true, loginPlan{password: true, verified: true}); rc != 1 {
+ t.Fatalf("runInvite rc = %d, want injected credential failure", rc)
+ }
+ if !strings.Contains(errb.String(), stopErr.Error()) {
+ t.Fatalf("runInvite did not reach the injected credential stop: %q", errb.String())
+ }
+ if runner.recordErr != nil || !runner.recordFound {
+ t.Fatalf("registry row at credential: found=%v err=%v", runner.recordFound, runner.recordErr)
+ }
+ if got, want := strings.Join(runner.eventsBeforeCredential, ","), "mail,expiry:1970-01-01,password-lock,kill,clear,drain,kill,clear,mail,home,home-validate"; got != want {
+ t.Fatalf("events before credential = %q, want %q", got, want)
+ }
+ if mailCalls < 2 {
+ t.Fatalf("mail cleanup calls before/during rollback = %d, want at least create and post-drain sweeps", mailCalls)
+ }
+ if got, want := runner.recordAtCredential.Created, t1.Format("2006-01-02 15:04:05 MST"); got != want {
+ t.Fatalf("creation time at credential = %q, want %q", got, want)
+ }
+ if got, want := runner.recordAtCredential.Expires, expiry.DisplayLocal(expiry.Deadline(t1, 1)); got != want {
+ t.Fatalf("expiry at credential = %q, want %q", got, want)
+ }
+ if runner.recordAtCredential.Pending {
+ t.Fatal("credential was attempted before the rebased registry record was finalized")
+ }
+ if beforeDrainClockCalls != 1 || afterDrainClockCalls != 1 {
+ t.Fatalf("clock calls before/after drain = %d/%d, want 1/1", beforeDrainClockCalls, afterDrainClockCalls)
+ }
+}
+
+func TestRunPermanentInviteClearsSafetyExpiry(t *testing.T) {
+ const (
+ username = "xxvcc-permtime"
+ generation = "fedcba9876543210fedcba9876543210"
+ uid = 4_000_001
+ )
+ if _, exists, err := user.Lookup(username); err != nil {
+ t.Fatal(err)
+ } else if exists {
+ t.Fatalf("test username %s already exists", username)
+ }
+
+ binDir := t.TempDir()
+ idScript := "#!/bin/sh\n" +
+ "if [ \"$1\" = \"-u\" ]; then printf \"id: '%s': no such user\\n\" \"$3\" >&2; exit 1; fi\n" +
+ "if [ \"$1\" = \"-Gn\" ]; then printf '%s\\n' \"$2\"; exit 0; fi\n" +
+ "exit 2\n"
+ if err := os.WriteFile(filepath.Join(binDir, "id"), []byte(idScript), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", binDir)
+
+ baseDir := rootOwnedDir(t)
+ regDir := filepath.Join(baseDir, "registry")
+ a, _, errb := newTestApp(t, "")
+ a.Registry = ®istry.Store{
+ Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock"),
+ }
+ a.Scheduler = &schedule.Scheduler{
+ SystemdDir: filepath.Join(baseDir, "systemd"), InstallPath: filepath.Join(baseDir, "linux-temp-admin"),
+ UnitPrefix: config.AutoRevokeUnitPrefix, Sys: fakeSys{},
+ }
+ events := []string{}
+ runner := &inviteTimingRunner{
+ account: user.Passwd{UID: uid, GID: uid}, events: &events, registry: a.Registry,
+ }
+ mailCalls := 0
+ a.Users = &user.Manager{
+ Runner: runner,
+ LookupUser: runner.lookup,
+ PrepareManagedHome: func(string) error { return nil },
+ CreateManagedHome: func(user.Passwd) error {
+ events = append(events, "home")
+ return nil
+ },
+ ValidateManagedHome: func(user.Passwd) error {
+ events = append(events, "home-validate")
+ return nil
+ },
+ RemoveManagedMail: func(user.Passwd) error {
+ mailCalls++
+ events = append(events, "mail")
+ return nil
+ },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ }
+ a.LookupUser = runner.lookup
+ a.ClearScheduledJobs = func(string, int) error { events = append(events, "clear"); return nil }
+ a.TerminateProcesses = func(int) error { events = append(events, "kill"); return nil }
+ a.DrainScheduledJobs = func() error { events = append(events, "drain"); return nil }
+ a.RandHex = func(int) (string, error) { return generation, nil }
+ a.RandPassword = func(int) (string, error) { return "password-for-permanent-test", nil }
+ sshdConfig := sysinfo.ParseSSHD("passwordauthentication yes\n")
+ a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sshdConfig, nil }
+
+ if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, false, loginPlan{password: true, verified: true}); rc != 0 {
+ t.Fatalf("permanent runInvite rc = %d: %s", rc, errb.String())
+ }
+ want := "mail,expiry:1970-01-01,password-lock,kill,clear,drain,kill,clear,mail,home,home-validate,credential,expiry:-1"
+ if got := strings.Join(events, ","); got != want {
+ t.Fatalf("permanent invite events = %q, want %q", got, want)
+ }
+ if mailCalls != 2 {
+ t.Fatalf("permanent invite mail cleanup calls = %d, want create and post-drain sweeps", mailCalls)
+ }
+ rec, found, err := a.Registry.Lookup(username)
+ if err != nil || !found {
+ t.Fatalf("permanent registry row: found=%v err=%v", found, err)
+ }
+ if rec.AutoRevoke || rec.Expires != "never (does not expire or auto-delete)" {
+ t.Fatalf("permanent registry row = %+v", rec)
+ }
+}
+
// newManageApp is newTestApp plus the collaborators a revoke reached from the
// menu actually touches, all pointed at temp dirs. It needs root: the registry
// is root-owned state by design — every write goes through a chown to 0:0 into a
diff --git a/internal/cli/revoke.go b/internal/cli/revoke.go
index fa260f8..aba03be 100644
--- a/internal/cli/revoke.go
+++ b/internal/cli/revoke.go
@@ -7,6 +7,8 @@ import (
"strconv"
"strings"
+ "github.com/xxvcc/linux-temp-admin/internal/config"
+ "github.com/xxvcc/linux-temp-admin/internal/registry"
"github.com/xxvcc/linux-temp-admin/internal/user"
"github.com/xxvcc/linux-temp-admin/internal/validate"
)
@@ -28,20 +30,20 @@ func (a *App) revoke(args []string) int {
return 1
}
if !opts.yes {
- _, registered, err := a.Registry.Lookup(opts.username)
+ rec, registered, err := a.Registry.Lookup(opts.username)
if err != nil {
a.errorf("%s: %v", a.P.M("读取注册表失败,拒绝继续", "reading registry failed; refusing to continue"), err)
return 1
}
- _, exists, err := a.lookupUser(opts.username)
+ pw, exists, err := a.lookupUser(opts.username)
if err != nil {
a.errorf("%s: %v", a.P.M("读取账号数据库失败,拒绝继续", "reading account database failed; refusing to continue"), err)
return 1
}
if exists {
if opts.force && !registered {
- a.warnf("%s", a.P.M("危险:用户 "+opts.username+" 未登记,--force 将删除真实系统用户及其家目录。",
- "DANGER: "+opts.username+" is not registered; --force will delete a real system user and its home directory."))
+ a.warnf("%s", a.P.M("危险:用户 "+opts.username+" 未登记;只有其余受管身份检查也通过时,--force 才会删除该账号及其确定的家目录。",
+ "DANGER: "+opts.username+" is not registered; --force deletes the account and its deterministic home only if the remaining managed-identity checks also pass."))
}
if a.prompt(a.P.M("请输入完整用户名 "+opts.username+" 以确认删除: ",
"type the full username "+opts.username+" to confirm deletion: ")) != opts.username {
@@ -49,20 +51,62 @@ func (a *App) revoke(args []string) int {
return 0
}
opts.liveConfirmed = true
+ opts.confirmedIdentity = &revokeIdentitySnapshot{
+ registered: registered,
+ record: rec,
+ passwd: pw,
+ }
+ }
+ }
+ run := func() int {
+ return a.withLifecycleLock(func() int { return a.revokeOptionsLocked(opts) })
+ }
+ // Releases before generation-bound scheduling emitted the same unattended
+ // command for every account generation. Such a process must never queue behind
+ // an invite for the same username: after invite releases its exclusive barrier,
+ // the old name-only intent could otherwise target the new account. A nonblocking
+ // shared acquisition makes only that collision a successful stale-job skip.
+ // Unrelated lifecycle work still queues under the global lock, and current
+ // generation-bound or interactive revokes use ordinary blocking semantics.
+ if opts.yes && opts.expectedUID == 0 && opts.generation == "" {
+ rc, busy := a.withAccountTrySharedLock(opts.username, run)
+ if busy {
+ a.warnf("%s", a.P.M(
+ "旧版无世代绑定的撤销命令与同名账号创建冲突,无法证明原删除意图仍指向当前账号;本次未撤销账号并以成功状态跳过,以免旧任务重试误删新世代。请在当前操作完成后运行 doctor,并按当前身份重新执行 revoke。",
+ "a legacy revoke command without a generation binding collided with creation of the same account name, so its original deletion intent can no longer be proved to target the current account; no account was revoked and this run was skipped successfully to keep an old job from retrying against a new generation. Run doctor and invoke revoke again against the current identity after the in-flight operation finishes."))
+ a.audit("account.delete", opts.username, "skip", "legacy unbound revoke collided with same-name invite", nil)
+ return 0
}
+ return rc
}
- return a.withLifecycleLock(func() int { return a.revokeOptionsLocked(opts) })
+ return a.withAccountSharedLock(opts.username, run)
}
type revokeOptions struct {
- username string
- confirmForce string
- expectedUID int
- generation string
- yes bool
- force bool
- liveConfirmed bool
- manualInvocation bool
+ username string
+ confirmForce string
+ expectedUID int
+ generation string
+ yes bool
+ force bool
+ liveConfirmed bool
+ manualInvocation bool
+ confirmedIdentity *revokeIdentitySnapshot
+}
+
+// revokeIdentitySnapshot binds an interactive full-name confirmation to the
+// complete account generation that was visible before the prompt. The account
+// lock is intentionally acquired only after human input, so the locked path must
+// reject a same-name replacement instead of applying the old confirmation to it.
+type revokeIdentitySnapshot struct {
+ registered bool
+ record registry.Record
+ passwd user.Passwd
+}
+
+func legacyRecoveryAuthorized(identityBound bool, opts revokeOptions, stdinTTY bool) bool {
+ return !identityBound && stdinTTY && opts.manualInvocation && opts.force &&
+ opts.generation == "" && opts.expectedUID == 0 && !opts.yes && opts.liveConfirmed
}
func (a *App) parseRevokeArgs(args []string) (revokeOptions, bool) {
@@ -146,9 +190,25 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
a.errorf("%s: %v", a.P.M("读取账号数据库失败,拒绝清理状态", "reading account database failed; refusing state cleanup"), err)
return 1
}
+ if confirmed := opts.confirmedIdentity; confirmed != nil &&
+ (!exists || registered != confirmed.registered || rec != confirmed.record || pw != confirmed.passwd) {
+ a.errorf("%s", a.P.M(
+ "输入确认后账号或登记身份发生变化;未清理授权、禁用或删除任何账号,请重新运行并确认当前世代。",
+ "the account or registry identity changed after confirmation; no grant was cleaned and no account was disabled or deleted; rerun and confirm the current generation."))
+ a.audit("account.delete", username, "fail", "account identity changed after interactive confirmation", nil)
+ return 1
+ }
if !exists {
a.warnf("%s", a.P.M("用户不存在,清理登记/sudoers/sshd 例外/自动删除任务:"+username,
"user does not exist; cleaning up registry/sudoers/sshd exception/auto-delete task: "+username))
+ if registered && rec.DeletionStarted {
+ if err := a.reconcileDeletionStarted(rec); err != nil {
+ a.errorf("%s: %v", a.P.M(
+ "账号删除已开始,但删除后的邮件清扫尚未安全完成;保留登记供重试",
+ "account deletion had started, but post-deletion mail cleanup could not be completed safely; keeping the registry record for retry"), err)
+ return 1
+ }
+ }
var cleanupErrs []error
if err := a.Scheduler.Cancel(username, rec.AutoUnit); err != nil {
cleanupErrs = append(cleanupErrs, err)
@@ -163,7 +223,7 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
a.errorf("%s: %v", a.P.M("账号虽不存在,但残留授权或任务未清除;保留登记", "the account is absent, but grants or schedules remain; keeping the registry record"), err)
return 1
}
- if err := a.Registry.Remove(username); err != nil {
+ if err := a.releaseRegistryAfterCleanup(username); err != nil {
a.errorf("%s: %v", a.P.M("清理登记失败", "registry cleanup failed"), err)
return 1
}
@@ -176,17 +236,14 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
return 1
}
- // A pending row was written before useradd and never completed with a durable
- // UID. The current account may be the half-created account, or an unrelated
- // account that later reused the name; neither can be proved. Strip any
- // name-scoped leftovers, but never turn this incomplete intent into authority to
- // delete a live account and its home directory.
+ // A pending row was written before useradd and never became an active account
+ // record, so it cannot authorize deletion. Failed-invite rollback converts the
+ // row to a non-pending UID-only deletion witness immediately before userdel; if
+ // that attempt crashes while the account is still live, recovery deliberately
+ // comes back through the interactive --force path below rather than this branch.
if registered && rec.Pending {
- cleanupErr := errors.Join(
- a.removeSudoGrant(username),
- a.removeSSHDException(username),
- a.Scheduler.Cancel(username, rec.AutoUnit),
- )
+ grantCleanupErr := errors.Join(a.removeSudoGrant(username), a.removeSSHDException(username))
+ cleanupErr := errors.Join(grantCleanupErr, a.Scheduler.Cancel(username, rec.AutoUnit))
a.errorf("%s", a.P.M(
"该登记仍处于创建中的 pending 状态,无法证明当前同名账号的身份;已保留账号和登记,请人工核查后处理。",
"the registry row is still a pending creation intent, so the current account's identity cannot be proved; the account and registry record were retained for manual recovery."))
@@ -209,20 +266,34 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
// Released v2 rows used one fixed GECOS marker, so username+UID+marker still
// cannot distinguish the original account from a same-name/same-UID replacement.
- // Only a direct operator invocation with --force and an explicit full-name
- // confirmation may recover such an account. Scheduled and uninstall-internal
- // invocations never receive this exception even though they carry --force.
- allowLegacy := registered && !rec.IdentityBound && opts.manualInvocation && opts.force &&
- opts.generation == "" && opts.expectedUID == 0 &&
- ((!opts.yes && opts.liveConfirmed) || (opts.yes && opts.confirmForce == username))
+ // Only a direct interactive operator invocation with --force and an explicit
+ // full-name confirmation may recover such an account. Historical unattended
+ // timers used the same --yes --force --confirm-force argv that an operator could
+ // type, so no non-interactive invocation receives this exception. Scheduled and
+ // uninstall-internal invocations remain blocked even though they carry --force.
+ stdinTTY := a.StdinIsTTY != nil && a.StdinIsTTY()
+ allowLegacy := legacyRecoveryAuthorized(rec.IdentityBound, opts, stdinTTY)
protected := user.IsProtectedRevokeEntry(username, pw, true, registered, rec.UID, rec.Generation, allowLegacy)
+ manualOnlyRecovery := registered && rec.DeletionStarted &&
+ (!rec.IdentityBound || !deletionRecordMatchesPasswd(rec, pw))
+ if registered && rec.DeletionStarted && rec.IdentityBound && !deletionRecordMatchesPasswd(rec, pw) {
+ protected = true
+ }
+ if registered && rec.DeletionStarted && !rec.IdentityBound && allowLegacy {
+ protected = !uidOnlyDeletionCandidateMatches(rec, pw)
+ }
if protected {
a.errorf("%s", a.P.M("拒绝删除受保护或系统用户:"+username,
"refusing to delete a protected or system user: "+username))
- if registered && !rec.IdentityBound && user.IsLegacyManagedEntry(pw) {
+ if !rec.IdentityBound && user.IsLegacyManagedEntry(pw) {
a.errorf("%s", a.P.M(
- "该账号使用旧版固定身份标记,无法证明它仍是原账号。请人工核查后直接运行 revoke --user "+username+" --force,并输入完整用户名确认;非交互调用还必须传入 --yes --confirm-force "+username+"。",
- "this account uses a legacy fixed identity marker and cannot be proved to be the original account. After manual inspection, invoke revoke --user "+username+" --force directly and type the full username; a non-interactive invocation must also pass --yes --confirm-force "+username+"."))
+ "该账号使用旧版固定身份标记,无法证明它仍是原账号。请人工核查后在交互终端直接运行 revoke --user "+username+" --force,并输入完整用户名确认;为避免旧版自动任务获得删除授权,非交互调用始终拒绝删除此类账号。",
+ "this account uses a legacy fixed identity marker and cannot be proved to be the original account. After manual inspection, invoke revoke --user "+username+" --force directly in an interactive terminal and type the full username; non-interactive deletion is always refused so a historical automatic task cannot gain this authority."))
+ }
+ if rec.DeletionStarted && !rec.IdentityBound {
+ a.errorf("%s", a.P.M(
+ "该账号只有 UID 删除恢复见证,不能证明当前同名账号就是原删除目标。自动任务、--yes 和卸载批量删除始终拒绝;请人工核查后在交互终端直接运行 revoke --user "+username+" --force,并输入完整用户名。",
+ "this account has only a UID-bound deletion-recovery witness, which cannot prove that the current same-name account is the original deletion target. Automatic jobs, --yes, and uninstall bulk deletion always refuse it; after manual inspection, invoke revoke --user "+username+" --force directly in an interactive terminal and type the full username."))
}
// Name the tamper if that is why: an account that rewrote its own UID (most
// dangerously to 0) is now protected by the very check meant to shield real
@@ -235,8 +306,20 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
if grantErr != nil {
a.errorf("%s: %v", a.P.M("账号受保护且授权未完全移除", "the account is protected and its grants were not fully removed"), grantErr)
}
- a.warnf("%s", a.P.M("自动删除任务保留;systemd 任务会按策略重试,at 和旧的一次性任务需人工核查。",
- "the auto-delete task is retained; systemd jobs retry by policy, while at and legacy one-shot jobs require manual inspection."))
+ if manualOnlyRecovery {
+ if a.Scheduler != nil {
+ if err := a.Scheduler.Cancel(username, rec.AutoUnit); err != nil {
+ a.errorf("%s: %v", a.P.M("该恢复状态只允许人工处理,但自动删除任务未能完整解除;登记已保留,请立即人工清理任务",
+ "this recovery state is manual-only, but its auto-delete task could not be fully cancelled; the registry witness was retained; remove the task manually"), err)
+ } else {
+ a.warnf("%s", a.P.M("该恢复状态只允许人工处理;自动删除任务已解除,登记已保留。",
+ "this recovery state is manual-only; its auto-delete task was cancelled and the registry witness was retained."))
+ }
+ }
+ } else {
+ a.warnf("%s", a.P.M("自动删除任务保留;systemd 任务会按策略重试,at 和旧的一次性任务需人工核查。",
+ "the auto-delete task is retained; systemd jobs retry by policy, while at and legacy one-shot jobs require manual inspection."))
+ }
a.audit("account.delete", username, "fail", "protected target; grants stripped", nil)
return 1
}
@@ -257,25 +340,41 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
}
pw = current
- if grantErr != nil {
- // Do not free the username while a name-scoped privilege file survives.
+ var artifactErr error
+ expectedHome, homeErr := user.DefaultHome(username)
+ switch {
+ case homeErr != nil:
+ artifactErr = fmt.Errorf("determine managed home: %w", homeErr)
+ case pw.Home != expectedHome:
+ artifactErr = fmt.Errorf("account home %q differs from managed path %q", pw.Home, expectedHome)
+ }
+
+ preDeleteErr := errors.Join(grantErr, artifactErr)
+ if preDeleteErr != nil {
+ // Do not free the username while a name-scoped privilege file survives or
+ // while an account artifact cannot be removed under its captured identity.
+ // The complete passwd entry was just re-checked, so disable this exact
+ // identity before retaining it for operator recovery.
disableErr := a.Users.DisableLogin(username)
if disableErr == nil {
identityErr := a.accountStillMatches(username, pw)
if identityErr != nil {
- a.errorf("%s: %v", a.P.M("授权未完全移除;账号身份在禁用登录后发生变化,拒绝按旧 UID 终止进程",
- "grants were not fully removed; the account identity changed after login disable, so processes were not terminated under the old UID"), errors.Join(grantErr, identityErr))
+ combined := errors.Join(preDeleteErr, identityErr)
+ a.errorf("%s: %v", a.P.M("删除前的授权或账号文件无法安全清理;账号身份在禁用登录后发生变化,拒绝按旧身份清理 cron/at 任务或终止进程",
+ "a grant or account artifact could not be safely cleaned before deletion; the account identity changed after login disable, so cron/at cleanup and process termination under the old identity were refused"), combined)
+ a.audit("account.delete", username, "fail", "pre-delete cleanup unsafe and identity changed after login disable: "+combined.Error(), nil)
return 1
}
- terminateErr := a.terminateProcesses(pw.UID)
- if terminateErr == nil {
- terminateErr = a.accountStillMatches(username, pw)
- }
- a.errorf("%s: %v", a.P.M("授权未完全移除;账号已禁用但不会删除,以免残留授权在用户名复用时重新生效",
- "grants were not fully removed; the account was disabled but not deleted so a surviving name-scoped grant cannot re-arm on reuse"), errors.Join(grantErr, terminateErr))
+ quiesceErr := a.quiesceScheduledAccount(username, pw)
+ combined := errors.Join(preDeleteErr, quiesceErr)
+ a.errorf("%s: %v", a.P.M("删除前的授权或账号文件无法安全清理;账号已禁用但不会删除,账号和登记已保留供人工恢复",
+ "a grant or account artifact could not be safely cleaned before deletion; the account was disabled but not deleted, and the account and registry witness were retained for manual recovery"), combined)
+ a.audit("account.delete", username, "fail", "pre-delete cleanup unsafe; account disabled and retained: "+combined.Error(), nil)
} else {
- a.errorf("%s: %v", a.P.M("授权未完全移除,且禁用登录也失败;账号和登记均已保留,请立即人工处理",
- "grants were not fully removed and disabling login also failed; the account and registry were retained for immediate manual recovery"), errors.Join(grantErr, disableErr))
+ combined := errors.Join(preDeleteErr, disableErr)
+ a.errorf("%s: %v", a.P.M("删除前的授权或账号文件无法安全清理,且禁用登录也失败;账号和登记均已保留,请立即人工处理",
+ "a grant or account artifact could not be safely cleaned before deletion, and disabling login also failed; the account and registry were retained for immediate manual recovery"), combined)
+ a.audit("account.delete", username, "fail", "pre-delete cleanup unsafe and login disable incomplete: "+combined.Error(), nil)
}
return 1
}
@@ -284,17 +383,18 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
// locking land, the account may still be SSH-reachable: in particular, a failed
// chage leaves public-key login open even when usermod -L succeeded. Never create
// a scan-then-delete race by continuing from a partial disable.
- stage, teardownErr := a.teardownLocalAccount(username, pw)
+ persistDeletion := func() error { return a.persistDeletionStarted(rec, registered, pw) }
+ stage, teardownErr := a.teardownLocalAccount(username, pw, persistDeletion)
switch stage {
case revokeDisableLogin:
a.errorf("%s: %v", a.P.M("无法完整禁用登录;保留账号、登记和自动删除任务,未终止进程或删除账号,请立即人工处理",
"could not fully disable the login; the account, registry record, and auto-delete task were retained, and no processes were terminated or account deleted; inspect immediately"), teardownErr)
a.audit("account.delete", username, "fail", "disable login incomplete: "+teardownErr.Error(), nil)
return 1
- case revokeTerminateProcesses:
- a.errorf("%s: %v", a.P.M("无法确认该 UID 的所有进程已终止;账号已禁用,保留账号、登记和自动删除任务,避免 UID 复用继承残留进程",
- "could not confirm that every process for this UID was terminated; the account is disabled and its account, registry record, and auto-delete task were retained to prevent residual processes crossing a UID reuse"), teardownErr)
- a.audit("account.delete", username, "fail", "process termination incomplete: "+teardownErr.Error(), nil)
+ case revokeQuiesceAccount:
+ a.errorf("%s: %v", a.P.M("无法确认该账号的 cron/at 任务及 UID 进程均已清空;账号已禁用,保留账号、登记和自动删除任务,避免任务或进程跨越身份复用",
+ "could not confirm that the account's cron/at jobs and UID processes were empty; the account is disabled and its account, registry record, and auto-delete task were retained to prevent deferred work or processes crossing an identity reuse"), teardownErr)
+ a.audit("account.delete", username, "fail", "account quiescence incomplete: "+teardownErr.Error(), nil)
return 1
case revokeDeleteAccount:
a.errorf("%s: %v", a.P.M("删除用户失败", "delete user failed"), teardownErr)
@@ -314,7 +414,7 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int {
a.errorf("%s: %v", a.P.M("用户已删除,但自动删除任务清理失败;保留登记", "user deleted, but schedule cleanup failed; keeping the registry record"), err)
return 1
}
- if err := a.Registry.Remove(username); err != nil {
+ if err := a.releaseRegistryAfterCleanup(username); err != nil {
a.errorf("%s: %v", a.P.M("用户已删除,但清理登记失败", "user deleted, but registry cleanup failed"), err)
return 1
}
@@ -327,7 +427,7 @@ type revokeAccountStage uint8
const (
revokeDisableLogin revokeAccountStage = iota
- revokeTerminateProcesses
+ revokeQuiesceAccount
revokeDeleteAccount
revokeAccountRemoved
)
@@ -335,35 +435,250 @@ const (
// teardownLocalAccount preserves the ordering that makes UID reuse safe. A
// stage is returned with the error so revoke can explain precisely which recovery
// state was retained without repeating these security-sensitive calls.
-func (a *App) teardownLocalAccount(username string, expected user.Passwd) (revokeAccountStage, error) {
+func (a *App) teardownLocalAccount(username string, expected user.Passwd, persistDeletion func() error) (revokeAccountStage, error) {
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return revokeDisableLogin, err
+ }
if err := a.Users.DisableLogin(username); err != nil {
return revokeDisableLogin, err
}
if err := a.accountStillMatches(username, expected); err != nil {
- return revokeTerminateProcesses, err
+ return revokeQuiesceAccount, err
}
- if err := a.terminateProcesses(expected.UID); err != nil {
- return revokeTerminateProcesses, err
+ if err := a.quiesceScheduledAccount(username, expected); err != nil {
+ return revokeQuiesceAccount, err
}
if err := a.accountStillMatches(username, expected); err != nil {
return revokeDeleteAccount, err
}
- if err := a.Users.Delete(username); err != nil {
+ if err := a.Users.DeleteExpected(username, expected, func() error {
+ if err := a.finalScheduledAccountCheck(username, expected); err != nil {
+ return err
+ }
+ if persistDeletion == nil {
+ return fmt.Errorf("deletion recovery persistence is not configured")
+ }
+ if err := persistDeletion(); err != nil {
+ return fmt.Errorf("persist deletion-started recovery state: %w", err)
+ }
+ return nil
+ }); err != nil {
return revokeDeleteAccount, err
}
return revokeAccountRemoved, nil
}
-// accountStillMatches prevents a multi-stage teardown from carrying facts from
-// the invited account across an out-of-band delete/recreate. userdel itself is
-// name-based, so re-check immediately before it as well as before the UID sweep.
+// uidOnlyDeletionCandidateMatches is the final local-shape check before a
+// legacy, unregistered, or rollback-pending account receives a UID-only deletion
+// witness. It is intentionally not identity proof: UID and lifecycle markers can
+// be reproduced. The interactive --force/full-name gate supplies the authority
+// for a live recovery; this predicate only prevents that authority from reaching
+// a reserved/root identity, an unsafe Home, or an account with no tool marker.
+func uidOnlyDeletionCandidateMatches(rec registry.Record, pw user.Passwd) bool {
+ if rec.User != pw.Name || user.IsReservedName(pw.Name) || !validate.AccountID(pw.UID) ||
+ !validate.AccountID(pw.GID) || (rec.UID != 0 && rec.UID != pw.UID) ||
+ !validate.ManagedHome(pw.Name, pw.Home) || pw.Shell == "" {
+ return false
+ }
+ return user.HasLifecycleMarker(pw)
+}
+
+// deletionRecordMatchesPasswd is the exact identity predicate used before a
+// deletion phase is persisted or resumed. Pending and completed markers are
+// deliberately distinct; neither a bare managed-looking GECOS nor UID equality
+// on its own is sufficient.
+func deletionRecordMatchesPasswd(rec registry.Record, pw user.Passwd) bool {
+ if rec.User != pw.Name || user.IsReservedName(rec.User) || !rec.IdentityBound || !validate.AccountID(rec.UID) ||
+ rec.UID != pw.UID || !validate.AccountID(pw.GID) || !validate.Generation(rec.Generation) ||
+ !validate.ManagedHome(rec.User, pw.Home) || pw.Shell == "" {
+ return false
+ }
+ marker := pw.GECOS
+ if i := strings.IndexByte(marker, ','); i >= 0 {
+ marker = marker[:i]
+ }
+ wantPrefix := config.ManagedGenerationGECOSPrefix
+ if rec.Pending {
+ wantPrefix = config.PendingGenerationGECOSPrefix
+ }
+ return marker == wantPrefix+rec.Generation
+}
+
+// persistDeletionStarted writes the mandatory pre-userdel witness. Completed
+// generation-bound accounts retain that exact generation; legacy, unregistered,
+// and pending rollback paths deliberately receive only a UID witness. Any change
+// between the policy decision and this final transition stops before userdel can
+// release the name or UID.
+func (a *App) persistDeletionStarted(rec registry.Record, registered bool, expected user.Passwd) error {
+ if a.Registry == nil {
+ return fmt.Errorf("registry is not configured")
+ }
+ if rec.User == "" {
+ rec.User = expected.Name
+ }
+ if rec.User != expected.Name {
+ return fmt.Errorf("deletion target changed before persistence")
+ }
+ current, found, err := a.Registry.Lookup(expected.Name)
+ if err != nil {
+ return fmt.Errorf("read current registry identity: %w", err)
+ }
+ if found != registered {
+ return fmt.Errorf("registry identity changed before deletion")
+ }
+ generation := ""
+ if registered {
+ if current.User != rec.User || current.UID != rec.UID || current.Generation != rec.Generation ||
+ current.IdentityBound != rec.IdentityBound || current.Pending != rec.Pending ||
+ current.DeletionStarted != rec.DeletionStarted {
+ return fmt.Errorf("registry identity changed before deletion")
+ }
+ if current.IdentityBound {
+ check := current
+ if check.Pending && check.UID == 0 {
+ check.UID = expected.UID
+ }
+ if !deletionRecordMatchesPasswd(check, expected) {
+ return fmt.Errorf("account no longer matches the registry deletion identity")
+ }
+ if !current.Pending {
+ generation = current.Generation
+ }
+ } else if !uidOnlyDeletionCandidateMatches(current, expected) {
+ return fmt.Errorf("account no longer matches the UID-only deletion candidate")
+ }
+ } else {
+ candidate := registry.Record{User: expected.Name, UID: expected.UID}
+ if !uidOnlyDeletionCandidateMatches(candidate, expected) {
+ return fmt.Errorf("account no longer matches the unregistered deletion candidate")
+ }
+ }
+ if err := a.Registry.BeginDeletion(expected.Name, expected.UID, generation); err != nil {
+ return fmt.Errorf("write deletion-started registry state: %w", err)
+ }
+ return nil
+}
+
+// releaseRegistryAfterCleanup removes an ordinary stale row by name, but an
+// in-progress deletion only through the exact identity transition. Looking up
+// again also lets invite rollback use the durable phase written inside userdel's
+// pre-delete callback rather than a stale in-memory copy of the record.
+func (a *App) releaseRegistryAfterCleanup(username string) error {
+ if a.Registry == nil {
+ return fmt.Errorf("registry is not configured")
+ }
+ rec, found, err := a.Registry.Lookup(username)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return nil
+ }
+ if rec.DeletionStarted {
+ return a.Registry.FinishDeletionRecovery(rec.User, rec.UID, rec.Generation)
+ }
+ return a.Registry.Remove(username)
+}
+
+// reconcileDeletionStarted performs the only artifact cleanup authorized after
+// the passwd entry is gone: an owner-checked same-name mail spool sweep. Home is
+// intentionally excluded. Ordinary absent rows never call this function.
+func (a *App) reconcileDeletionStarted(rec registry.Record) error {
+ if !rec.DeletionStarted || rec.Pending || !validate.AccountID(rec.UID) ||
+ (rec.IdentityBound != validate.Generation(rec.Generation)) {
+ return fmt.Errorf("incomplete deletion-started registry state")
+ }
+ if a.Users == nil {
+ return fmt.Errorf("account manager is not configured")
+ }
+ return a.Users.ReconcileManagedMailAfterDeletion(rec.User, rec.UID)
+}
+
+// quiesceScheduledAccount reaches a fixed point before the numeric UID and
+// username can be released. Each pass terminates processes before clearing jobs:
+// otherwise a process can enqueue work after the inventory and before it dies,
+// leaving that work behind as the last action of the pass. The drain interval
+// lets cron/at daemons finish a due job they had already read, and the second pass
+// terminates anything the daemon started before performing the authoritative job
+// inventory. Transient first-pass failures are contained by the still-live,
+// disabled account and must be resolved by the final verification.
+func (a *App) quiesceScheduledAccount(username string, expected user.Passwd) error {
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return err
+ }
+ var firstPass []error
+ if err := a.terminateProcesses(expected.UID); err != nil {
+ firstPass = append(firstPass, fmt.Errorf("initial process termination: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return errors.Join(errors.Join(firstPass...), err)
+ }
+ if err := a.clearScheduledJobs(username, expected.UID); err != nil {
+ firstPass = append(firstPass, fmt.Errorf("initial scheduled-job cleanup: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return errors.Join(errors.Join(firstPass...), err)
+ }
+ if err := a.drainScheduledJobs(); err != nil {
+ return errors.Join(errors.Join(firstPass...), fmt.Errorf("drain deferred jobs: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return errors.Join(errors.Join(firstPass...), err)
+ }
+ var finalPass []error
+ if err := a.terminateProcesses(expected.UID); err != nil {
+ finalPass = append(finalPass, fmt.Errorf("final process termination: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return errors.Join(errors.Join(firstPass...), errors.Join(finalPass...), err)
+ }
+ if err := a.clearScheduledJobs(username, expected.UID); err != nil {
+ finalPass = append(finalPass, fmt.Errorf("final scheduled-job cleanup: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return errors.Join(errors.Join(firstPass...), errors.Join(finalPass...), err)
+ }
+ if err := errors.Join(finalPass...); err != nil {
+ return errors.Join(errors.Join(firstPass...), err)
+ }
+ return nil
+}
+
+// finalScheduledAccountCheck runs after controlled Home/mail cleanup and just
+// before userdel. The earlier drain already waited out daemon-side cached work;
+// this last pass terminates processes first, then closes jobs raced in during
+// filesystem cleanup without imposing a second polling-cycle delay.
+func (a *App) finalScheduledAccountCheck(username string, expected user.Passwd) error {
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return err
+ }
+ var errs []error
+ if err := a.terminateProcesses(expected.UID); err != nil {
+ errs = append(errs, fmt.Errorf("process termination: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ return errors.Join(errors.Join(errs...), err)
+ }
+ if err := a.clearScheduledJobs(username, expected.UID); err != nil {
+ errs = append(errs, fmt.Errorf("scheduled-job cleanup: %w", err))
+ }
+ if err := a.accountStillMatches(username, expected); err != nil {
+ errs = append(errs, err)
+ }
+ return errors.Join(errs...)
+}
+
+// accountStillMatches prevents a multi-stage operation from carrying facts from
+// the invited account across an out-of-band delete/recreate. System account
+// helpers remain name-based, so callers re-check around each security-sensitive
+// stage even though those checks cannot make the helper an atomic compare-and-swap.
func (a *App) accountStillMatches(username string, expected user.Passwd) error {
current, exists, err := a.lookupUser(username)
if err != nil {
return fmt.Errorf("re-read account identity: %w", err)
}
if !exists || current != expected {
- return fmt.Errorf("account identity changed during teardown")
+ return fmt.Errorf("account identity changed during the operation")
}
return nil
}
diff --git a/internal/cli/revoke_process.go b/internal/cli/revoke_process.go
new file mode 100644
index 0000000..5ad2048
--- /dev/null
+++ b/internal/cli/revoke_process.go
@@ -0,0 +1,179 @@
+package cli
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/xxvcc/linux-temp-admin/internal/validate"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ procReadBatch = 256
+ procCmdlineMaxBytes = int64(128 << 10)
+ procStatusMaxBytes = int64(64 << 10)
+)
+
+var errProcFileTooLarge = errors.New("process file exceeds read limit")
+
+// runningLegacyRevokeProcess finds root-owned revoke processes emitted by
+// releases that did not bind the command to an account generation. It is a
+// migration guard, not deletion authority: a match only makes invite refuse to
+// reuse the name until the old command has finished.
+func runningLegacyRevokeProcess(procRoot, installPath, username string) (bool, error) {
+ if procRoot == "" || installPath == "" {
+ return false, fmt.Errorf("process inventory is not configured")
+ }
+ if !validate.Username(username) {
+ return false, fmt.Errorf("invalid username %q", username)
+ }
+ proc, err := os.Open(procRoot)
+ if err != nil {
+ return false, fmt.Errorf("open process inventory: %w", err)
+ }
+ defer proc.Close()
+
+ for {
+ entries, readErr := proc.Readdirnames(procReadBatch)
+ for _, entry := range entries {
+ pid, parseErr := strconv.ParseUint(entry, 10, 31)
+ if parseErr != nil || pid == 0 {
+ continue
+ }
+ pidDir := filepath.Join(procRoot, entry)
+ cmdline, cmdErr := readProcFile(filepath.Join(pidDir, "cmdline"), procCmdlineMaxBytes)
+ if cmdErr != nil {
+ if errors.Is(cmdErr, os.ErrNotExist) {
+ continue
+ }
+ if !errors.Is(cmdErr, errProcFileTooLarge) {
+ return false, fmt.Errorf("read process %s command line: %w", entry, cmdErr)
+ }
+ }
+ argv := procArgv(cmdline)
+ // A released direct revoke has five or eight short arguments, so an
+ // oversized cmdline cannot be that exact argv. A shell invocation may
+ // carry unrelated trailing arguments, however; its complete -c command
+ // appears near the front and remains recognizable in the bounded prefix.
+ directMatch := cmdErr == nil && legacyRevokeArgv(argv, installPath, username)
+ if !directMatch && !legacyRevokeShellArgv(argv, installPath, username) {
+ continue
+ }
+ status, statusErr := readProcFile(filepath.Join(pidDir, "status"), procStatusMaxBytes)
+ if statusErr != nil {
+ if errors.Is(statusErr, os.ErrNotExist) {
+ continue
+ }
+ return false, fmt.Errorf("read process %s credentials: %w", entry, statusErr)
+ }
+ euid, uidErr := effectiveUID(status)
+ if uidErr != nil {
+ return false, fmt.Errorf("parse process %s credentials: %w", entry, uidErr)
+ }
+ if euid == 0 {
+ return true, nil
+ }
+ }
+ if errors.Is(readErr, io.EOF) {
+ return false, nil
+ }
+ if readErr != nil {
+ return false, fmt.Errorf("read process inventory: %w", readErr)
+ }
+ }
+}
+
+func readProcFile(path string, limit int64) ([]byte, error) {
+ f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ b, err := io.ReadAll(io.LimitReader(f, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(b)) > limit {
+ return b[:limit], fmt.Errorf("%w: %s exceeds %d-byte limit", errProcFileTooLarge, path, limit)
+ }
+ return b, nil
+}
+
+func procArgv(cmdline []byte) []string {
+ if len(cmdline) == 0 {
+ return nil
+ }
+ parts := bytes.Split(cmdline, []byte{0})
+ if len(parts) > 0 && len(parts[len(parts)-1]) == 0 {
+ parts = parts[:len(parts)-1]
+ }
+ argv := make([]string, len(parts))
+ for i := range parts {
+ argv[i] = string(parts[i])
+ }
+ return argv
+}
+
+func legacyRevokeArgv(argv []string, installPath, username string) bool {
+ if len(argv) != 5 && len(argv) != 8 {
+ return false
+ }
+ if argv[0] != installPath || argv[1] != "revoke" || argv[2] != "--user" ||
+ argv[3] != username || argv[4] != "--yes" {
+ return false
+ }
+ return len(argv) == 5 ||
+ (argv[5] == "--force" && argv[6] == "--confirm-force" && argv[7] == username)
+}
+
+func legacyRevokeShellArgv(argv []string, installPath, username string) bool {
+ if len(argv) < 3 {
+ return false
+ }
+ switch filepath.Base(argv[0]) {
+ case "sh", "ash", "bash", "dash":
+ default:
+ return false
+ }
+ for i := 1; i+1 < len(argv); i++ {
+ if argv[i] != "-c" {
+ continue
+ }
+ command := strings.TrimSpace(argv[i+1])
+ fields := strings.Fields(command)
+ if command == strings.Join(fields, " ") && legacyRevokeArgv(fields, installPath, username) {
+ return true
+ }
+ }
+ return false
+}
+
+func effectiveUID(status []byte) (uint32, error) {
+ found := false
+ var euid uint32
+ for _, line := range strings.Split(string(status), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 0 || fields[0] != "Uid:" {
+ continue
+ }
+ if found || len(fields) != 5 {
+ return 0, fmt.Errorf("invalid Uid field")
+ }
+ id, err := strconv.ParseUint(fields[2], 10, 32)
+ if err != nil {
+ return 0, fmt.Errorf("invalid effective UID %q", fields[2])
+ }
+ euid = uint32(id)
+ found = true
+ }
+ if !found {
+ return 0, fmt.Errorf("missing Uid field")
+ }
+ return euid, nil
+}
diff --git a/internal/cli/revoke_process_test.go b/internal/cli/revoke_process_test.go
new file mode 100644
index 0000000..5d49500
--- /dev/null
+++ b/internal/cli/revoke_process_test.go
@@ -0,0 +1,114 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestEffectiveUIDAcceptsFullLinuxUIDRange(t *testing.T) {
+ got, err := effectiveUID([]byte("Name:\ttest\nUid:\t4294967295\t4294967295\t4294967295\t4294967295\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != ^uint32(0) {
+ t.Fatalf("effective UID = %d, want %d", got, ^uint32(0))
+ }
+}
+
+const testInstallPath = "/usr/local/sbin/linux-temp-admin"
+
+func writeProcProcess(t *testing.T, root, pid string, uid int, argv ...string) {
+ t.Helper()
+ dir := filepath.Join(root, pid)
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ var cmdline []byte
+ for _, arg := range argv {
+ cmdline = append(cmdline, []byte(arg)...)
+ cmdline = append(cmdline, 0)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "cmdline"), cmdline, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ status := fmt.Sprintf("Name:\ttest\nUid:\t%d\t%d\t%d\t%d\n", uid, uid, uid, uid)
+ if err := os.WriteFile(filepath.Join(dir, "status"), []byte(status), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRunningLegacyRevokeProcessRecognizesKnownRootCommands(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ argv []string
+ }{
+ {name: "five argument release", argv: []string{testInstallPath, "revoke", "--user", "xxvcc-a1", "--yes"}},
+ {name: "eight argument release", argv: []string{testInstallPath, "revoke", "--user", "xxvcc-a1", "--yes", "--force", "--confirm-force", "xxvcc-a1"}},
+ {name: "at shell", argv: []string{"/bin/sh", "-c", testInstallPath + " revoke --user xxvcc-a1 --yes"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ root := t.TempDir()
+ writeProcProcess(t, root, "123", 0, tc.argv...)
+ found, err := runningLegacyRevokeProcess(root, testInstallPath, "xxvcc-a1")
+ if err != nil || !found {
+ t.Fatalf("runningLegacyRevokeProcess = (%v, %v), want (true, nil)", found, err)
+ }
+ })
+ }
+}
+
+func TestRunningLegacyRevokeProcessRejectsLookalikesAndBoundCommands(t *testing.T) {
+ root := t.TempDir()
+ writeProcProcess(t, root, "101", 1001, testInstallPath, "revoke", "--user", "xxvcc-a1", "--yes")
+ writeProcProcess(t, root, "102", 0, testInstallPath+"-helper", "revoke", "--user", "xxvcc-a1", "--yes")
+ writeProcProcess(t, root, "103", 0, testInstallPath, "revoke", "--user", "someone-else", "--yes")
+ writeProcProcess(t, root, "104", 0, testInstallPath, "revoke", "--user", "xxvcc-a1", "--yes", "--force", "--confirm-force", "xxvcc-a1", "--expected-uid", "1001", "--generation", "0123456789abcdef0123456789abcdef")
+
+ found, err := runningLegacyRevokeProcess(root, testInstallPath, "xxvcc-a1")
+ if err != nil || found {
+ t.Fatalf("runningLegacyRevokeProcess = (%v, %v), want (false, nil)", found, err)
+ }
+}
+
+func TestRunningLegacyRevokeProcessFailsClosedOnMatchingMalformedIdentity(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "123")
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cmdline := testInstallPath + "\x00revoke\x00--user\x00xxvcc-a1\x00--yes\x00"
+ if err := os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "status"), []byte("Name:\ttest\nUid:\tbroken\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runningLegacyRevokeProcess(root, testInstallPath, "xxvcc-a1"); err == nil {
+ t.Fatal("matching process with malformed credentials was ignored")
+ }
+}
+
+func TestRunningLegacyRevokeProcessIgnoresOversizedUnrelatedCmdline(t *testing.T) {
+ root := t.TempDir()
+ writeProcProcess(t, root, "123", 1001, "/usr/bin/unrelated", strings.Repeat("x", int(procCmdlineMaxBytes)))
+
+ found, err := runningLegacyRevokeProcess(root, testInstallPath, "xxvcc-a1")
+ if err != nil || found {
+ t.Fatalf("runningLegacyRevokeProcess = (%v, %v), want (false, nil)", found, err)
+ }
+}
+
+func TestRunningLegacyRevokeProcessRecognizesOversizedMatchingShellPrefix(t *testing.T) {
+ root := t.TempDir()
+ writeProcProcess(t, root, "123", 0,
+ "/bin/sh", "-c", testInstallPath+" revoke --user xxvcc-a1 --yes",
+ strings.Repeat("x", int(procCmdlineMaxBytes)))
+
+ found, err := runningLegacyRevokeProcess(root, testInstallPath, "xxvcc-a1")
+ if err != nil || !found {
+ t.Fatalf("runningLegacyRevokeProcess = (%v, %v), want (true, nil)", found, err)
+ }
+}
diff --git a/internal/cli/revoke_test.go b/internal/cli/revoke_test.go
new file mode 100644
index 0000000..cb43204
--- /dev/null
+++ b/internal/cli/revoke_test.go
@@ -0,0 +1,867 @@
+package cli
+
+import (
+ "errors"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/xxvcc/linux-temp-admin/internal/config"
+ "github.com/xxvcc/linux-temp-admin/internal/registry"
+ "github.com/xxvcc/linux-temp-admin/internal/schedule"
+ "github.com/xxvcc/linux-temp-admin/internal/sudoers"
+ "github.com/xxvcc/linux-temp-admin/internal/user"
+)
+
+type orderedTeardownRunner struct {
+ events *[]string
+ present *bool
+ beforeUserdel func()
+}
+
+type revokeTestScheduleSystem struct {
+ removeAtCalls *int
+}
+
+func (revokeTestScheduleSystem) HasSystemctl() bool { return false }
+func (revokeTestScheduleSystem) Systemctl(...string) error { return nil }
+func (revokeTestScheduleSystem) HasAt() bool { return true }
+func (revokeTestScheduleSystem) ScheduleAt(string, time.Time) (string, error) { return "1", nil }
+func (s revokeTestScheduleSystem) RemoveAtJobsFor(string) error {
+ if s.removeAtCalls != nil {
+ *s.removeAtCalls++
+ }
+ return nil
+}
+func (revokeTestScheduleSystem) AtrmJob(string) error { return nil }
+func (revokeTestScheduleSystem) AtJobs() ([]schedule.AtJob, error) { return nil, nil }
+
+func (r *orderedTeardownRunner) Run(name string, _ ...string) error {
+ *r.events = append(*r.events, name)
+ if name == "userdel" {
+ if r.beforeUserdel != nil {
+ r.beforeUserdel()
+ }
+ *r.present = false
+ }
+ return nil
+}
+
+func (r *orderedTeardownRunner) RunInput(_ string, name string, args ...string) error {
+ return r.Run(name, args...)
+}
+
+func (*orderedTeardownRunner) Look(name string) bool { return name == "userdel" }
+
+func newOrderedTeardownApp(t *testing.T, pw user.Passwd, failClearCall int, clearErr error) (*App, *[]string, *bool) {
+ t.Helper()
+ events := []string{}
+ present := true
+ lookup := func(name string) (user.Passwd, bool, error) {
+ if name != pw.Name {
+ t.Fatalf("LookupUser name = %q, want %q", name, pw.Name)
+ }
+ if !present {
+ return user.Passwd{}, false, nil
+ }
+ return pw, true, nil
+ }
+ appendArtifact := func(event string) func(user.Passwd) error {
+ return func(got user.Passwd) error {
+ if got != pw {
+ t.Fatalf("%s cleanup identity = %+v, want %+v", event, got, pw)
+ }
+ events = append(events, event)
+ return nil
+ }
+ }
+ clearCalls := 0
+ a := &App{
+ Users: &user.Manager{
+ Runner: &orderedTeardownRunner{events: &events, present: &present},
+ LookupUser: lookup,
+ RemoveManagedMail: appendArtifact("mail"),
+ RemoveManagedHome: appendArtifact("home"),
+ },
+ LookupUser: lookup,
+ ClearScheduledJobs: func(name string, uid int) error {
+ if name != pw.Name || uid != pw.UID {
+ t.Fatalf("ClearScheduledJobs(%q, %d), want (%q, %d)", name, uid, pw.Name, pw.UID)
+ }
+ clearCalls++
+ events = append(events, "clear")
+ if clearCalls == failClearCall {
+ return clearErr
+ }
+ return nil
+ },
+ DrainScheduledJobs: func() error {
+ events = append(events, "drain")
+ return nil
+ },
+ TerminateProcesses: func(uid int) error {
+ if uid != pw.UID {
+ t.Fatalf("TerminateProcesses UID = %d, want %d", uid, pw.UID)
+ }
+ events = append(events, "kill")
+ return nil
+ },
+ }
+ return a, &events, &present
+}
+
+func requireTeardownEvents(t *testing.T, got []string, want ...string) {
+ t.Helper()
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("teardown events = %v, want %v", got, want)
+ }
+}
+
+func TestLegacyRecoveryAuthorizationRequiresInteractiveConfirmation(t *testing.T) {
+ base := revokeOptions{
+ username: "xxvcc-a1",
+ force: true,
+ manualInvocation: true,
+ liveConfirmed: true,
+ }
+ tests := []struct {
+ name string
+ registered bool
+ identityBound bool
+ stdinTTY bool
+ mutate func(*revokeOptions)
+ wantAuthorized bool
+ }{
+ {name: "interactive direct recovery", registered: true, stdinTTY: true, wantAuthorized: true},
+ {name: "piped full-name confirmation", registered: true},
+ {name: "historical eight argument timer", registered: true, mutate: func(o *revokeOptions) {
+ o.yes = true
+ o.confirmForce = o.username
+ }},
+ {name: "uninstall internal", registered: true, stdinTTY: true, mutate: func(o *revokeOptions) { o.manualInvocation = false }},
+ {name: "no full-name confirmation", registered: true, stdinTTY: true, mutate: func(o *revokeOptions) { o.liveConfirmed = false }},
+ {name: "generation-bound", registered: true, stdinTTY: true, mutate: func(o *revokeOptions) {
+ o.expectedUID = 1001
+ o.generation = "0123456789abcdef0123456789abcdef"
+ }},
+ {name: "current identity", registered: true, identityBound: true, stdinTTY: true},
+ {name: "unregistered interactive recovery", stdinTTY: true, wantAuthorized: true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ opts := base
+ if tc.mutate != nil {
+ tc.mutate(&opts)
+ }
+ if got := legacyRecoveryAuthorized(tc.identityBound, opts, tc.stdinTTY); got != tc.wantAuthorized {
+ t.Fatalf("legacyRecoveryAuthorized = %v, want %v", got, tc.wantAuthorized)
+ }
+ })
+ }
+}
+
+func TestInteractiveRevokeBindsConfirmationToAccountGeneration(t *testing.T) {
+ const (
+ username = "xxvcc-confirm1"
+ oldGeneration = "0123456789abcdef0123456789abcdef"
+ newGeneration = "fedcba9876543210fedcba9876543210"
+ )
+ oldRecord := registry.Record{
+ User: username, UID: 1001, Generation: oldGeneration,
+ IdentityBound: true, Port: 22,
+ }
+ newRecord := registry.Record{
+ User: username, UID: 1002, Generation: newGeneration,
+ IdentityBound: true, Port: 22,
+ }
+ oldPasswd := user.Passwd{
+ Name: username, UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + oldGeneration,
+ Home: "/home/" + username, Shell: "/bin/sh",
+ }
+ newPasswd := user.Passwd{
+ Name: username, UID: 1002, GID: 1002,
+ GECOS: config.ManagedGenerationGECOSPrefix + newGeneration,
+ Home: "/home/" + username, Shell: "/bin/sh",
+ }
+
+ a, _, errb := newTestApp(t, username+"\n")
+ setTestRegistryRecord(t, a, oldRecord)
+ runner := &revokeRunner{}
+ a.Users = &user.Manager{Runner: runner}
+ lookupCalls := 0
+ a.LookupUser = func(name string) (user.Passwd, bool, error) {
+ if name != username {
+ t.Fatalf("LookupUser name = %q, want %q", name, username)
+ }
+ lookupCalls++
+ if lookupCalls == 1 {
+ // Model a complete same-name replacement while the operator is at the
+ // confirmation boundary and before revoke acquires its account lock.
+ if err := a.Registry.Record(newRecord); err != nil {
+ t.Fatal(err)
+ }
+ return oldPasswd, true, nil
+ }
+ return newPasswd, true, nil
+ }
+
+ if rc := a.revoke([]string{"--user", username}); rc != 1 {
+ t.Fatalf("revoke rc = %d, want changed-generation refusal", rc)
+ }
+ if len(runner.calls) != 0 {
+ t.Fatalf("revoke mutated the replacement account after stale confirmation: %v", runner.calls)
+ }
+ if got := errb.String(); !strings.Contains(got, "identity changed after confirmation") {
+ t.Fatalf("revoke did not explain the stale confirmation: %q", got)
+ }
+ stored, found, err := a.Registry.Lookup(username)
+ if err != nil || !found || stored != newRecord {
+ t.Fatalf("replacement registry identity changed: found=%v record=%+v err=%v", found, stored, err)
+ }
+}
+
+func TestInteractiveLegacyAndUnregisteredDeletionPersistUIDWitnessBeforeUserdel(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ for _, tc := range []struct {
+ name string
+ registered bool
+ marker string
+ }{
+ {name: "registered legacy", registered: true, marker: config.ManagedGECOS},
+ {name: "unregistered generation marker", marker: config.ManagedGenerationGECOSPrefix + generation},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ const username = "xxvcc-recovery1"
+ pw := user.Passwd{
+ Name: username, UID: 1001, GID: 1001, GECOS: tc.marker,
+ Home: "/home/" + username, Shell: "/bin/sh",
+ }
+ a, _, _ := newTestApp(t, "")
+ a.StdinIsTTY = func() bool { return true }
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ if tc.registered {
+ if err := a.Registry.Record(registry.Record{
+ User: username, Port: 22, UID: pw.UID, Generation: generation,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ present := true
+ events := []string{}
+ lookup := func(string) (user.Passwd, bool, error) {
+ if !present {
+ return user.Passwd{}, false, nil
+ }
+ return pw, true, nil
+ }
+ runner := &orderedTeardownRunner{events: &events, present: &present}
+ runner.beforeUserdel = func() {
+ got, found, err := a.Registry.Lookup(username)
+ if err != nil || !found || !got.DeletionStarted || got.UID != pw.UID ||
+ got.IdentityBound || got.Generation != "" || got.Pending {
+ t.Fatalf("pre-userdel UID witness: found=%v rec=%+v err=%v", found, got, err)
+ }
+ }
+ a.Users = &user.Manager{
+ Runner: runner, LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { return nil },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ }
+ a.LookupUser = lookup
+ a.TerminateProcesses = func(int) error { return nil }
+ a.ClearScheduledJobs = func(string, int) error { return nil }
+ a.DrainScheduledJobs = func() error { return nil }
+ a.Scheduler = &schedule.Scheduler{
+ SystemdDir: t.TempDir(), InstallPath: t.TempDir() + "/linux-temp-admin",
+ UnitPrefix: config.AutoRevokeUnitPrefix, Sys: revokeTestScheduleSystem{},
+ }
+
+ if rc := a.revokeOptionsLocked(revokeOptions{
+ username: username, force: true, manualInvocation: true, liveConfirmed: true,
+ }); rc != 0 {
+ t.Fatalf("interactive recovery rc = %d", rc)
+ }
+ if present || !strings.Contains(strings.Join(events, ","), "userdel") {
+ t.Fatalf("account deletion state: present=%v events=%v", present, events)
+ }
+ if found, err := a.Registry.Contains(username); err != nil || found {
+ t.Fatalf("completed recovery witness: found=%v err=%v", found, err)
+ }
+ })
+ }
+}
+
+func TestUnregisteredDeletionWitnessWriteFailureBlocksUserdel(t *testing.T) {
+ const username = "xxvcc-recovery2"
+ pw := user.Passwd{
+ Name: username, UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + "0123456789abcdef0123456789abcdef",
+ Home: "/home/" + username, Shell: "/bin/sh",
+ }
+ a, _, _ := newTestApp(t, "")
+ a.StdinIsTTY = func() bool { return true }
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ present := true
+ events := []string{}
+ lookup := func(string) (user.Passwd, bool, error) { return pw, present, nil }
+ a.Users = &user.Manager{
+ Runner: &orderedTeardownRunner{events: &events, present: &present}, LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { return nil },
+ RemoveManagedHome: func(user.Passwd) error { return nil },
+ }
+ a.LookupUser = lookup
+ a.TerminateProcesses = func(int) error { return nil }
+ a.ClearScheduledJobs = func(string, int) error { return nil }
+ a.DrainScheduledJobs = func() error { return nil }
+ a.Registry.Lock = filepath.Join(t.TempDir(), "missing", "registry.lock")
+
+ if rc := a.revokeOptionsLocked(revokeOptions{
+ username: username, force: true, manualInvocation: true, liveConfirmed: true,
+ }); rc != 1 {
+ t.Fatalf("revoke rc = %d, want persistence refusal", rc)
+ }
+ if !present || strings.Contains(strings.Join(events, ","), "userdel") {
+ t.Fatalf("witness failure reached userdel: present=%v events=%v", present, events)
+ }
+}
+
+func TestRevokeUnsafeHomeAndGrantFailureStillDisableAndRetainAccount(t *testing.T) {
+ const (
+ name = "xxvcc-a1"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ pw := user.Passwd{
+ Name: name, UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/srv/changed-home", Shell: "/bin/sh",
+ }
+ a, _, errb := newTestApp(t, "")
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ rec := registry.Record{
+ User: name, UID: pw.UID, Generation: generation, IdentityBound: true,
+ Port: 22,
+ }
+ if err := a.Registry.Record(rec); err != nil {
+ t.Fatal(err)
+ }
+
+ grantErr := errors.New("sudo drop-in unlink failed")
+ a.Sudoers = &sudoers.Manager{
+ Dir: t.TempDir(),
+ RemoveFile: func(string) error { return grantErr },
+ }
+ runner := &revokeRunner{}
+ a.Users = &user.Manager{Runner: runner}
+ a.LookupUser = func(string) (user.Passwd, bool, error) { return pw, true, nil }
+ terminated := 0
+ a.TerminateProcesses = func(uid int) error {
+ if uid != pw.UID {
+ t.Fatalf("TerminateProcesses UID = %d, want %d", uid, pw.UID)
+ }
+ terminated++
+ return nil
+ }
+
+ if rc := a.revokeOptionsLocked(revokeOptions{username: name, yes: true}); rc != 1 {
+ t.Fatalf("revoke rc = %d, want retained-account failure", rc)
+ }
+ if got := strings.Join(runner.calls, ","); got != "chage,usermod" {
+ t.Fatalf("account commands = %q, want login disable without userdel", got)
+ }
+ if terminated != 2 {
+ t.Fatalf("TerminateProcesses called %d times, want two-pass quiescence", terminated)
+ }
+ if _, found, err := a.Registry.Lookup(name); err != nil || !found {
+ t.Fatalf("registry witness was not retained: found=%v err=%v", found, err)
+ }
+ gotErr := errb.String()
+ for _, want := range []string{grantErr.Error(), "differs from managed path", "disabled but not deleted"} {
+ if !strings.Contains(gotErr, want) {
+ t.Fatalf("stderr missing %q: %q", want, gotErr)
+ }
+ }
+}
+
+func TestTeardownLocalAccountOrdersFinalCleanupBeforeUserdel(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{
+ Name: "xxvcc-a1", UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-a1", Shell: "/bin/sh",
+ }
+ a, events, present := newOrderedTeardownApp(t, pw, 0, nil)
+
+ stage, err := a.teardownLocalAccount(pw.Name, pw, func() error {
+ *events = append(*events, "persist")
+ return nil
+ })
+ if err != nil || stage != revokeAccountRemoved {
+ t.Fatalf("teardownLocalAccount = stage %v, err %v; want account removed", stage, err)
+ }
+ if *present {
+ t.Fatal("account still present after successful userdel")
+ }
+ requireTeardownEvents(t, *events,
+ "chage", "usermod",
+ "kill", "clear", "drain", "kill", "clear",
+ "mail", "home",
+ "kill", "clear", "persist",
+ "userdel", "mail",
+ )
+}
+
+func TestRollbackInviteAccountUsesOrderedTeardown(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{
+ Name: "xxvcc-a1", UID: 1001, GID: 1001,
+ GECOS: config.PendingGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-a1", Shell: "/bin/sh",
+ }
+ rec := registry.Record{
+ User: pw.Name, UID: pw.UID, Generation: generation,
+ IdentityBound: true, Pending: true,
+ }
+ a, events, present := newOrderedTeardownApp(t, pw, 0, nil)
+ setTestRegistryRecord(t, a, rec)
+
+ if err := a.rollbackInviteAccount(pw.Name, rec, pw, true); err != nil {
+ t.Fatal(err)
+ }
+ if *present {
+ t.Fatal("pending account still present after successful rollback")
+ }
+ requireTeardownEvents(t, *events,
+ "chage", "usermod",
+ "kill", "clear", "drain", "kill", "clear",
+ "mail", "home",
+ "kill", "clear",
+ "userdel", "mail",
+ )
+}
+
+func TestTeardownLocalAccountFinalScheduledCleanupFailureBlocksUserdel(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{
+ Name: "xxvcc-a1", UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-a1", Shell: "/bin/sh",
+ }
+ wantErr := errors.New("final scheduled cleanup failed")
+ a, events, present := newOrderedTeardownApp(t, pw, 3, wantErr)
+
+ stage, err := a.teardownLocalAccount(pw.Name, pw, func() error { return nil })
+ if stage != revokeDeleteAccount || !errors.Is(err, wantErr) {
+ t.Fatalf("teardownLocalAccount = stage %v, err %v; want delete-stage %v", stage, err, wantErr)
+ }
+ if !*present {
+ t.Fatal("userdel ran after the final scheduled-job cleanup failed")
+ }
+ requireTeardownEvents(t, *events,
+ "chage", "usermod",
+ "kill", "clear", "drain", "kill", "clear",
+ "mail", "home", "kill", "clear",
+ )
+}
+
+func TestTeardownLocalAccountPersistsDeletionPhaseBeforeUserdel(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{
+ Name: "xxvcc-a1", UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-a1", Shell: "/bin/sh",
+ }
+ rec := registry.Record{
+ User: pw.Name, UID: pw.UID, Generation: generation, IdentityBound: true, Port: 22,
+ }
+ a, events, present := newOrderedTeardownApp(t, pw, 0, nil)
+ setTestRegistryRecord(t, a, rec)
+
+ stage, err := a.teardownLocalAccount(pw.Name, pw, func() error {
+ if err := a.persistDeletionStarted(rec, true, pw); err != nil {
+ return err
+ }
+ *events = append(*events, "persist")
+ return nil
+ })
+ if err != nil || stage != revokeAccountRemoved {
+ t.Fatalf("teardownLocalAccount = stage %v, err %v; want account removed", stage, err)
+ }
+ if *present {
+ t.Fatal("account still present after successful userdel")
+ }
+ stored, found, err := a.Registry.Lookup(pw.Name)
+ if err != nil || !found || !stored.DeletionStarted {
+ t.Fatalf("durable deletion phase = found %v record %+v err %v", found, stored, err)
+ }
+ requireTeardownEvents(t, *events,
+ "chage", "usermod",
+ "kill", "clear", "drain", "kill", "clear",
+ "mail", "home", "kill", "clear", "persist", "userdel", "mail",
+ )
+}
+
+func TestDeletionPhaseRegistryWriteFailureBlocksUserdel(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{
+ Name: "xxvcc-a1", UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-a1", Shell: "/bin/sh",
+ }
+ rec := registry.Record{
+ User: pw.Name, UID: pw.UID, Generation: generation, IdentityBound: true, Port: 22,
+ }
+ a, events, present := newOrderedTeardownApp(t, pw, 0, nil)
+ setTestRegistryRecord(t, a, rec)
+ workingLock := a.Registry.Lock
+ a.Registry.Lock = t.TempDir() + "/missing/registry.lock"
+
+ stage, err := a.teardownLocalAccount(pw.Name, pw, func() error {
+ return a.persistDeletionStarted(rec, true, pw)
+ })
+ if err == nil || stage != revokeDeleteAccount || !strings.Contains(err.Error(), "deletion-started") {
+ t.Fatalf("teardownLocalAccount = stage %v, err %v; want durable-state failure", stage, err)
+ }
+ if !*present {
+ t.Fatal("userdel ran after deletion-phase persistence failed")
+ }
+ for _, event := range *events {
+ if event == "userdel" {
+ t.Fatalf("userdel event survived persistence failure: %v", *events)
+ }
+ }
+ a.Registry.Lock = workingLock
+ stored, found, lookupErr := a.Registry.Lookup(pw.Name)
+ if lookupErr != nil || !found || stored.DeletionStarted {
+ t.Fatalf("failed write changed recovery record: found=%v record=%+v err=%v", found, stored, lookupErr)
+ }
+}
+
+func TestRevokeRetriesPostDeletionMailAndKeepsOrdinaryAbsentRowsNarrow(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ newAbsentApp := func(t *testing.T, rec registry.Record, removeMail func(user.Passwd) error) *App {
+ t.Helper()
+ a, _, _ := newTestApp(t, "")
+ setTestRegistryRecord(t, a, rec)
+ lookup := func(string) (user.Passwd, bool, error) { return user.Passwd{}, false, nil }
+ a.LookupUser = lookup
+ a.Users = &user.Manager{LookupUser: lookup, RemoveManagedMail: removeMail}
+ a.Scheduler = &schedule.Scheduler{
+ SystemdDir: t.TempDir(), InstallPath: t.TempDir() + "/linux-temp-admin",
+ UnitPrefix: config.AutoRevokeUnitPrefix, Sys: revokeTestScheduleSystem{},
+ }
+ return a
+ }
+
+ t.Run("deletion-started row retries then releases witness", func(t *testing.T) {
+ rec := registry.Record{
+ User: "xxvcc-mail1", UID: 1001, Generation: generation, IdentityBound: true,
+ DeletionStarted: true, Port: 22,
+ }
+ wantErr := errors.New("mail spool still busy")
+ mailCalls := 0
+ a := newAbsentApp(t, rec, func(got user.Passwd) error {
+ mailCalls++
+ if got.Name != rec.User || got.UID != rec.UID {
+ t.Fatalf("mail recovery identity = %+v, want %s/%d", got, rec.User, rec.UID)
+ }
+ if mailCalls == 1 {
+ return wantErr
+ }
+ return nil
+ })
+ if rc := a.revokeOptionsLocked(revokeOptions{username: rec.User, yes: true}); rc != 1 {
+ t.Fatalf("first recovery revoke rc = %d, want retained failure", rc)
+ }
+ if present, err := a.Registry.Contains(rec.User); err != nil || !present {
+ t.Fatalf("failed mail retry lost registry witness: present=%v err=%v", present, err)
+ }
+ if rc := a.revokeOptionsLocked(revokeOptions{username: rec.User, yes: true}); rc != 0 {
+ t.Fatalf("second recovery revoke rc = %d, want success", rc)
+ }
+ if present, err := a.Registry.Contains(rec.User); err != nil || present {
+ t.Fatalf("successful mail retry retained registry witness: present=%v err=%v", present, err)
+ }
+ if mailCalls != 2 {
+ t.Fatalf("mail recovery calls = %d, want 2", mailCalls)
+ }
+ })
+
+ t.Run("UID-only row authorizes only absent mail recovery", func(t *testing.T) {
+ rec := registry.Record{
+ User: "xxvcc-mail2", UID: 1002, DeletionStarted: true, Port: 22,
+ }
+ mailCalls := 0
+ a := newAbsentApp(t, rec, func(got user.Passwd) error {
+ mailCalls++
+ if got.Name != rec.User || got.UID != rec.UID || got.Home != "" {
+ t.Fatalf("UID-only mail recovery identity = %+v", got)
+ }
+ return nil
+ })
+ if rc := a.revokeOptionsLocked(revokeOptions{username: rec.User, yes: true}); rc != 0 {
+ t.Fatalf("UID-only absent recovery rc = %d, want success", rc)
+ }
+ if mailCalls != 1 {
+ t.Fatalf("UID-only mail recovery calls = %d, want 1", mailCalls)
+ }
+ if found, err := a.Registry.Contains(rec.User); err != nil || found {
+ t.Fatalf("UID-only witness retained after recovery: found=%v err=%v", found, err)
+ }
+ })
+
+ t.Run("ordinary absent row never authorizes mail deletion", func(t *testing.T) {
+ rec := registry.Record{
+ User: "xxvcc-stale1", UID: 1001, Generation: generation, IdentityBound: true, Port: 22,
+ }
+ mailCalls := 0
+ a := newAbsentApp(t, rec, func(user.Passwd) error { mailCalls++; return nil })
+ if rc := a.revokeOptionsLocked(revokeOptions{username: rec.User, yes: true}); rc != 0 {
+ t.Fatalf("ordinary stale-row cleanup rc = %d, want success", rc)
+ }
+ if mailCalls != 0 {
+ t.Fatalf("ordinary absent row authorized %d mail cleanup call(s)", mailCalls)
+ }
+ })
+}
+
+func TestUIDOnlyPendingRollbackRequiresInteractiveRecovery(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ pw := user.Passwd{
+ Name: "xxvcc-pending1", UID: 1001, GID: 1001,
+ GECOS: config.PendingGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-pending1", Shell: "/bin/sh",
+ }
+ rec := registry.Record{
+ User: pw.Name, UID: pw.UID, Generation: generation, IdentityBound: true,
+ Pending: true, DeletionStarted: true, Port: 22,
+ }
+ events := []string{}
+ present := true
+ lookup := func(string) (user.Passwd, bool, error) {
+ if !present {
+ return user.Passwd{}, false, nil
+ }
+ return pw, true, nil
+ }
+ a, _, _ := newTestApp(t, "")
+ a.Users = &user.Manager{
+ Runner: &orderedTeardownRunner{events: &events, present: &present},
+ LookupUser: lookup,
+ RemoveManagedMail: func(user.Passwd) error { events = append(events, "mail"); return nil },
+ RemoveManagedHome: func(user.Passwd) error { events = append(events, "home"); return nil },
+ ValidateManagedHome: func(user.Passwd) error { return nil },
+ }
+ a.LookupUser = lookup
+ a.TerminateProcesses = func(int) error { events = append(events, "kill"); return nil }
+ a.ClearScheduledJobs = func(string, int) error { events = append(events, "clear"); return nil }
+ a.DrainScheduledJobs = func() error { events = append(events, "drain"); return nil }
+ cancelCalls := 0
+ a.Scheduler = &schedule.Scheduler{
+ SystemdDir: t.TempDir(), InstallPath: t.TempDir() + "/linux-temp-admin",
+ UnitPrefix: config.AutoRevokeUnitPrefix, Sys: revokeTestScheduleSystem{removeAtCalls: &cancelCalls},
+ }
+ setTestRegistryRecord(t, a, rec)
+
+ if rc := a.revokeOptionsLocked(revokeOptions{username: rec.User, yes: true, force: true}); rc != 1 {
+ t.Fatalf("unattended pending rollback recovery rc = %d, want refusal", rc)
+ }
+ if !present {
+ t.Fatal("unattended recovery deleted a live UID-only account")
+ }
+ if strings.Contains(strings.Join(events, ","), "userdel") {
+ t.Fatalf("unattended recovery reached userdel: %v", events)
+ }
+ if cancelCalls != 1 {
+ t.Fatalf("unattended UID-only recovery cancelled %d auto-delete tasks, want 1", cancelCalls)
+ }
+ if rc := a.revokeOptionsLocked(revokeOptions{
+ username: rec.User, force: true, manualInvocation: true, liveConfirmed: true,
+ }); rc != 1 {
+ t.Fatalf("non-TTY full-name recovery rc = %d, want refusal", rc)
+ }
+ if !present || strings.Contains(strings.Join(events, ","), "userdel") {
+ t.Fatalf("non-TTY recovery reached userdel: present=%v events=%v", present, events)
+ }
+
+ a.StdinIsTTY = func() bool { return true }
+ if rc := a.revokeOptionsLocked(revokeOptions{
+ username: rec.User, force: true, manualInvocation: true, liveConfirmed: true,
+ }); rc != 0 {
+ t.Fatalf("interactive pending rollback recovery rc = %d, want success", rc)
+ }
+ if present {
+ t.Fatal("pending account survived deletion-started rollback recovery")
+ }
+ if found, err := a.Registry.Contains(rec.User); err != nil || found {
+ t.Fatalf("pending recovery registry state: found=%v err=%v", found, err)
+ }
+ if !strings.Contains(strings.Join(events, ","), "userdel") {
+ t.Fatalf("pending recovery never reached userdel: %v", events)
+ }
+}
+
+func TestInteractiveUIDOnlyRecoveryRejectsSameUIDPasswdReplacement(t *testing.T) {
+ const (
+ name = "xxvcc-replaced1"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ original := user.Passwd{
+ Name: name, UID: 1001, GID: 1001,
+ GECOS: config.PendingGenerationGECOSPrefix + generation,
+ Home: "/home/" + name, Shell: "/bin/sh",
+ }
+ replacement := original
+ replacement.GECOS = config.ManagedGenerationGECOSPrefix + generation
+ replacement.Shell = "/bin/bash"
+
+ a, _, _ := newTestApp(t, "")
+ a.StdinIsTTY = func() bool { return true }
+ setTestRegistryRecord(t, a, registry.Record{
+ User: name, UID: original.UID, DeletionStarted: true, Port: 22,
+ })
+ lookups := 0
+ a.LookupUser = func(string) (user.Passwd, bool, error) {
+ lookups++
+ if lookups == 1 {
+ return original, true, nil
+ }
+ return replacement, true, nil
+ }
+ runner := &revokeRunner{}
+ a.Users = &user.Manager{Runner: runner}
+ a.TerminateProcesses = func(int) error {
+ t.Fatal("same-UID replacement reached process termination")
+ return nil
+ }
+
+ if rc := a.revokeOptionsLocked(revokeOptions{
+ username: name, force: true, manualInvocation: true, liveConfirmed: true,
+ }); rc != 1 {
+ t.Fatalf("same-UID replacement revoke rc = %d, want refusal", rc)
+ }
+ if len(runner.calls) != 0 {
+ t.Fatalf("same-UID replacement reached account helpers: %v", runner.calls)
+ }
+ if found, err := a.Registry.Contains(name); err != nil || !found {
+ t.Fatalf("same-UID replacement lost recovery witness: found=%v err=%v", found, err)
+ }
+}
+
+func TestCompactPreservesDeletionStartedRecoveryRow(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ rec := registry.Record{
+ User: "xxvcc-compact1", UID: 1001, Generation: generation, IdentityBound: true,
+ DeletionStarted: true, Port: 22,
+ }
+ a, _, _ := newTestApp(t, "")
+ setTestRegistryRecord(t, a, rec)
+ if rc := a.compactLocked(); rc != 0 {
+ t.Fatalf("compact rc = %d, want success with retained recovery row", rc)
+ }
+ stored, found, err := a.Registry.Lookup(rec.User)
+ if err != nil || !found || !stored.DeletionStarted {
+ t.Fatalf("compact lost deletion recovery row: found=%v record=%+v err=%v", found, stored, err)
+ }
+}
+
+func TestAutoRevokeRetentionSeparatesRetryableAndManualRecovery(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ boundPW := user.Passwd{
+ Name: "xxvcc-timer1", UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-timer1", Shell: "/bin/sh",
+ }
+ legacyPW := boundPW
+ legacyPW.GECOS = config.ManagedGECOS
+ for _, tc := range []struct {
+ name string
+ rec registry.Record
+ pw user.Passwd
+ exists bool
+ want bool
+ }{
+ {
+ name: "legacy identity cancels unattended timer",
+ rec: registry.Record{
+ User: boundPW.Name, UID: boundPW.UID, AutoRevoke: true,
+ AutoUnit: "linux-temp-admin-revoke-xxvcc-timer1", Port: 22,
+ },
+ pw: legacyPW, exists: true, want: false,
+ },
+ {
+ name: "absent UID-only witness keeps retry timer",
+ rec: registry.Record{User: boundPW.Name, UID: boundPW.UID, DeletionStarted: true, Port: 22},
+ want: true,
+ },
+ {
+ name: "live UID-only witness cancels unattended timer",
+ rec: registry.Record{User: boundPW.Name, UID: boundPW.UID, DeletionStarted: true, Port: 22},
+ pw: boundPW, exists: true, want: false,
+ },
+ {
+ name: "live exact bound witness keeps retry timer",
+ rec: registry.Record{
+ User: boundPW.Name, UID: boundPW.UID, Generation: generation,
+ IdentityBound: true, DeletionStarted: true, Port: 22,
+ },
+ pw: boundPW, exists: true, want: true,
+ },
+ {
+ name: "live mismatched bound witness cancels unattended timer",
+ rec: registry.Record{
+ User: boundPW.Name, UID: boundPW.UID, Generation: generation,
+ IdentityBound: true, DeletionStarted: true, Port: 22,
+ },
+ pw: func() user.Passwd {
+ pw := boundPW
+ pw.GECOS = config.ManagedGenerationGECOSPrefix + "fedcba9876543210fedcba9876543210"
+ return pw
+ }(),
+ exists: true, want: false,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ setTestRegistryRecord(t, a, tc.rec)
+ a.LookupUser = func(string) (user.Passwd, bool, error) { return tc.pw, tc.exists, nil }
+ got, err := a.accountNeedsAutoRevoke(tc.rec.User)
+ if err != nil || got != tc.want {
+ t.Fatalf("accountNeedsAutoRevoke = %v, err=%v, want %v", got, err, tc.want)
+ }
+ })
+ }
+}
+
+func TestFinalScheduledAccountCheckClearsWorkQueuedBeforeTerminationCompletes(t *testing.T) {
+ pw := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, Home: "/home/xxvcc-a1", Shell: "/bin/sh"}
+ queued := false
+ a := &App{
+ LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil },
+ TerminateProcesses: func(int) error {
+ queued = true
+ return nil
+ },
+ ClearScheduledJobs: func(string, int) error {
+ if !queued {
+ t.Fatal("scheduled-job inventory ran before process termination completed")
+ }
+ queued = false
+ return nil
+ },
+ }
+
+ if err := a.finalScheduledAccountCheck(pw.Name, pw); err != nil {
+ t.Fatal(err)
+ }
+ if queued {
+ t.Fatal("work queued by a dying process survived the final inventory")
+ }
+}
diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go
index 679b942..007338d 100644
--- a/internal/cli/uninstall.go
+++ b/internal/cli/uninstall.go
@@ -9,11 +9,12 @@ import (
"os"
"path/filepath"
"sort"
- "strconv"
"strings"
"github.com/xxvcc/linux-temp-admin/internal/config"
"github.com/xxvcc/linux-temp-admin/internal/fsutil"
+ "github.com/xxvcc/linux-temp-admin/internal/mountinfo"
+ "github.com/xxvcc/linux-temp-admin/internal/registry"
"github.com/xxvcc/linux-temp-admin/internal/table"
"github.com/xxvcc/linux-temp-admin/internal/user"
"github.com/xxvcc/linux-temp-admin/internal/validate"
@@ -30,10 +31,13 @@ import (
// namespaced files. An account can be hidden from the registry; it cannot be
// hidden from the sudo grant that is the whole reason it is worth hiding.
//
-// The managed GECOS marker is deliberately NOT a witness. It is the one signal an
-// account can write to itself: `usermod -c 'linux-temp-admin temporary admin'
-// realadmin` would enlist a real administrator's account — and their home
-// directory — into a teardown. It is reported (see gecosOnly) and never acted on.
+// A passwd GECOS marker is deliberately only a block-only witness. It is the one
+// signal an account can write to itself: `usermod -c 'linux-temp-admin temporary
+// admin' realadmin` must never enlist that account or its home for deletion. But
+// ignoring the marker entirely can strand a permanent no-sudo/no-timer account if
+// its registry row is lost. The marker therefore keeps the command and state in
+// place for manual recovery; only a completed registry+UID+generation+passwd
+// identity can authorize automatic deletion.
type witness string
const (
@@ -42,8 +46,18 @@ const (
witnessSudoers witness = "sudo-grant"
witnessSSHD witness = "sshd-exception"
witnessUnit witness = "auto-delete-task"
+ witnessMarker witness = "passwd-marker-block-only"
)
+func hasRegistryWitness(acc teardownAccount) bool {
+ for _, w := range acc.witnesses {
+ if w == witnessRegistry {
+ return true
+ }
+ }
+ return false
+}
+
// hasArtifactWitness reports whether the account is named by a filesystem
// artifact that carries privilege — a sudo grant, an sshd exception, or an
// auto-delete unit — as opposed to only a registry row. A row is a record; an
@@ -61,11 +75,24 @@ func hasArtifactWitness(acc teardownAccount) bool {
// teardownAccount is one account the uninstall has to get rid of, and why it
// thinks so.
type teardownAccount struct {
- name string
- exists bool
- witnesses []witness
+ name string
+ exists bool
+ witnesses []witness
+ recovery deletionRecoveryState
+ registryFound bool
+ registryRecord registry.Record
+ passwd user.Passwd
}
+type deletionRecoveryState uint8
+
+const (
+ noDeletionRecovery deletionRecoveryState = iota
+ absentDeletionRecovery
+ boundDeletionRecovery
+ manualDeletionRecovery
+)
+
// teardownPlan is what an uninstall would do, gathered before anything is
// touched. It is built first and shown first: everything it reports is something
// the operator can act on BEFORE it is too late to act on it.
@@ -101,6 +128,7 @@ func (p teardownPlan) names() []string {
// teardownPlan gathers every account any witness names, plus the footprint.
func (a *App) teardownPlan(purgeAudit, force bool) teardownPlan {
found := map[string][]witness{}
+ records := map[string]registry.Record{}
add := func(name string, w witness) {
if name == "" || !validate.Username(name) {
return
@@ -120,6 +148,7 @@ func (a *App) teardownPlan(purgeAudit, force bool) teardownPlan {
} else {
for _, r := range recs {
add(r.User, witnessRegistry)
+ records[r.User] = r
}
}
if users, err := a.v1RegistryUsers(); err != nil {
@@ -156,6 +185,13 @@ func (a *App) teardownPlan(purgeAudit, force bool) teardownPlan {
}
}
}
+ if users, err := a.listMarkerAccounts(); err != nil {
+ addInventoryErr(fmt.Errorf("%s: %w", a.P.M("扫描账号生命周期标记失败", "scanning account lifecycle markers failed"), err))
+ } else {
+ for _, u := range users {
+ add(u, witnessMarker)
+ }
+ }
names := make([]string, 0, len(found))
for n := range found {
@@ -172,11 +208,26 @@ func (a *App) teardownPlan(purgeAudit, force bool) teardownPlan {
for _, n := range names {
ws := found[n]
sort.Slice(ws, func(i, j int) bool { return ws[i] < ws[j] })
- exists, err := user.Exists(n)
+ pw, exists, err := a.lookupUser(n)
if err != nil {
addInventoryErr(fmt.Errorf("%s %s: %w", a.P.M("读取账号失败", "reading account"), n, err))
}
- plan.accounts = append(plan.accounts, teardownAccount{name: n, exists: exists, witnesses: ws})
+ rec, registered := records[n]
+ recovery := noDeletionRecovery
+ if registered && rec.DeletionStarted && err == nil {
+ switch {
+ case !exists:
+ recovery = absentDeletionRecovery
+ case rec.IdentityBound && deletionRecordMatchesPasswd(rec, pw):
+ recovery = boundDeletionRecovery
+ default:
+ recovery = manualDeletionRecovery
+ }
+ }
+ plan.accounts = append(plan.accounts, teardownAccount{
+ name: n, exists: exists, witnesses: ws, recovery: recovery,
+ registryFound: registered, registryRecord: rec, passwd: pw,
+ })
}
plan.binaryBlocker = a.binaryBlocker(force)
plan.inventoryErr = inventoryErr
@@ -266,11 +317,14 @@ func (a *App) v1RegistryUsers() ([]string, error) {
lineNo := 0
for sc.Scan() {
lineNo++
- line := strings.TrimSpace(sc.Text())
+ line := sc.Text()
if line == "" || strings.HasPrefix(line, "#") {
continue
}
- name, _, _ := strings.Cut(line, "\t")
+ name, _, tabSeparated := strings.Cut(line, "\t")
+ if !tabSeparated {
+ return nil, fmt.Errorf("v1 registry line %d is not tab-separated", lineNo)
+ }
if !validate.Username(name) {
return nil, fmt.Errorf("v1 registry line %d has invalid username %q", lineNo, name)
}
@@ -301,8 +355,17 @@ func (a *App) printTeardownPlan(p teardownPlan) {
)
for _, acc := range p.accounts {
state := a.P.M("缺失(仅剩痕迹)", "gone (leftovers only)")
- if acc.exists {
- state = a.P.M("在册(连同家目录删除)", "live (deleted with its home)")
+ switch acc.recovery {
+ case absentDeletionRecovery:
+ state = a.P.M("缺失(删除恢复待完成)", "gone (deletion recovery pending)")
+ case boundDeletionRecovery:
+ state = a.P.M("存在(删除世代已绑定)", "live (deletion generation bound)")
+ case manualDeletionRecovery:
+ state = a.P.M("存在(删除恢复需人工)", "live (manual deletion recovery required)")
+ case noDeletionRecovery:
+ if acc.exists {
+ state = a.P.M("存在(身份核验后尝试撤销)", "live (revoke after identity checks)")
+ }
}
ws := make([]string, 0, len(acc.witnesses))
for _, w := range acc.witnesses {
@@ -319,8 +382,8 @@ func (a *App) printTeardownPlan(p teardownPlan) {
a.warnf("%s %s(%s)", a.P.M("无法移除:", "cannot be removed:"), p.binaryPath, p.binaryBlocker)
}
if p.auditKept {
- a.info(fmt.Sprintf(a.P.M("审计日志保留在 %s —— 它记录了谁开过、谁删过 root 级账号,卸载不会替你抹掉它。要一并删除请加 --purge-audit。",
- "the audit log is KEPT at %s — it records who opened and closed root-capable accounts, and an uninstall does not erase that for you. Pass --purge-audit to remove it too."), p.auditPath))
+ a.info(fmt.Sprintf(a.P.M("审计日志保留在 %s —— 其中保留了成功写入的 root 级账号操作记录,卸载不会替你抹掉它。要一并删除请加 --purge-audit。",
+ "the audit log is KEPT at %s — it retains successfully written records of root-capable account operations, and an uninstall does not erase them for you. Pass --purge-audit to remove it too."), p.auditPath))
} else {
a.warnf("%s %s", a.P.M("审计日志将被删除:", "the audit log will be DELETED:"), p.auditPath)
}
@@ -401,8 +464,8 @@ func (a *App) authorizeUninstall(plan teardownPlan, yes, removeUsers bool) bool
a.errorf("%s: %v", a.P.M("无法确定这台机器上有哪些账号,拒绝卸载",
"cannot determine which accounts are on this host; refusing to uninstall"), plan.inventoryErr)
a.warnf("%s", a.P.M(
- "清单不全就卸载,会删掉命令、留下它没看见的账号——而它们的自动删除任务执行的正是这个命令。请先修好上面的问题再重试。",
- "uninstalling on a partial inventory removes the command and leaves behind accounts it never saw. Repair the account database or managed state before retrying."))
+ "清单不全就卸载,会删掉命令、留下它没看见的账号或授权,并使已有自动删除任务无法执行。请先修好上面的问题再重试。",
+ "uninstalling on a partial inventory can leave unseen accounts or grants behind and prevent any existing auto-delete task from running. Repair the account database or managed state before retrying."))
return false
}
@@ -424,6 +487,37 @@ func (a *App) authorizeUninstall(plan teardownPlan, yes, removeUsers bool) bool
return false
}
+ // A live UID-only (or mismatched) deletion witness proves only that a prior
+ // operator reached the userdel boundary; it does not prove that today's
+ // same-name account is the one they approved. Bulk uninstall is unattended per
+ // account, so refuse the whole operation before touching any host state. The
+ // operator must recover it through an interactive revoke --force confirmation.
+ for _, acc := range plan.accounts {
+ if acc.recovery != manualDeletionRecovery {
+ continue
+ }
+ a.errorf("%s %s", a.P.M(
+ "拒绝卸载:活账号的删除恢复见证未绑定当前世代;已保留账号、命令和状态。请先人工核查并交互执行 revoke --force:",
+ "refusing to uninstall: a live account has a deletion-recovery witness that is not bound to its current generation; the account, command, and state were kept. Inspect it and complete an interactive revoke --force first:"), acc.name)
+ return false
+ }
+
+ // Validate every other live account from the displayed, immutable snapshot
+ // before the lifecycle lock is entered and before teardown can revoke the first
+ // account. Without this whole-plan preflight, a valid alphabetically earlier
+ // account could be deleted before a later marker-only, pending, legacy, or
+ // identity-mismatched account made the same bulk operation fail. The plan is
+ // rebuilt and compared under the lock before teardown, and revoke still repeats
+ // its identity checks immediately before each mutation.
+ for _, acc := range plan.accounts {
+ if !acc.exists || liveTeardownAccountAuthorized(acc) {
+ continue
+ }
+ a.errorf("%s %s", a.P.M("拒绝卸载:无法在缺少当前世代绑定身份登记时自动删除活账号;在删除任何账号前已停止:",
+ "refusing to uninstall: cannot auto-delete a live account without a current generation-bound identity record; stopped before deleting any account:"), acc.name)
+ return false
+ }
+
// Refuse before anything is touched, not partway through.
if who := callerAccount(); who != "" {
for _, acc := range plan.accounts {
@@ -447,14 +541,22 @@ func (a *App) authorizeUninstall(plan teardownPlan, yes, removeUsers bool) bool
a.errorf("%s", a.P.M(
fmt.Sprintf("非交互模式不会删除账号。这台机器上有 %d 个由本工具管理的账号,卸载必须先删除它们;确认请加 --remove-users。", len(plan.accounts)),
fmt.Sprintf("a non-interactive run will not delete accounts. This host has %d managed by this tool, and the uninstall must remove them first; pass --remove-users to say so.", len(plan.accounts))))
- a.warnf("%s", a.P.M("(不能只卸载命令、留下账号:它们的自动删除任务执行的就是这个命令,删掉命令它们就再也不会过期。)",
- "(uninstalling the command and keeping the accounts is not an option: their auto-delete tasks invoke this very command, so removing it means they never expire.)"))
+ a.warnf("%s", a.P.M("(不能只卸载命令、留下受管账号:这会让工具失去撤销这些账号、清理授权和执行已有自动删除任务的能力。)",
+ "(uninstalling the command while keeping managed accounts is not an option: it removes the ability to revoke those accounts, clean their grants, and run any auto-delete tasks already scheduled.)"))
return false
}
}
return true
}
+func liveTeardownAccountAuthorized(acc teardownAccount) bool {
+ if !acc.exists || !acc.registryFound || !hasRegistryWitness(acc) {
+ return false
+ }
+ state := classifyRegisteredAccount(acc.registryRecord, acc.passwd, true, nil)
+ return state == registeredActive || state == registeredRecoveryBound
+}
+
func sameTeardownPlan(a, b teardownPlan) bool {
if a.stateDir != b.stateDir || a.auditPath != b.auditPath || a.auditKept != b.auditKept ||
a.binaryPath != b.binaryPath || a.binaryBlocker != b.binaryBlocker || len(a.accounts) != len(b.accounts) {
@@ -462,7 +564,9 @@ func sameTeardownPlan(a, b teardownPlan) bool {
}
for i := range a.accounts {
left, right := a.accounts[i], b.accounts[i]
- if left.name != right.name || left.exists != right.exists || len(left.witnesses) != len(right.witnesses) {
+ if left.name != right.name || left.exists != right.exists || left.recovery != right.recovery ||
+ left.registryFound != right.registryFound || left.registryRecord != right.registryRecord ||
+ left.passwd != right.passwd || len(left.witnesses) != len(right.witnesses) {
return false
}
for j := range left.witnesses {
@@ -499,6 +603,17 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int {
// survivor check below is for.
var failedRevokes []string
for _, acc := range plan.accounts {
+ // A passwd marker, v1 row, or name-scoped artifact can make a live account
+ // block uninstall, but none can authorize its deletion. Require the current
+ // registry witness before even entering the destructive revoke path; the
+ // completedAccountIdentity check below then binds UID, generation, home, and
+ // the exact marker on one passwd snapshot.
+ if acc.exists && !hasRegistryWitness(acc) {
+ a.errorf("%s %s", a.P.M("缺少当前世代绑定身份登记,拒绝自动删除活账号:",
+ "refusing to auto-delete a live account without a current generation-bound identity record:"), acc.name)
+ failedRevokes = append(failedRevokes, acc.name)
+ continue
+ }
ours, live, identityErr := a.completedAccountIdentity(acc.name)
if identityErr != nil {
a.errorf("%s %s: %v", a.P.M("无法重新验证活账号身份,拒绝自动删除:",
@@ -645,15 +760,24 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int {
// removeStateDir deletes everything this tool kept under /var/lib, v1's files
// included. It is only ever reached once no managed account survives.
//
-// The symlink check is the same discipline the rest of the tool writes with: the
-// directory is root-owned by construction, so anything else standing at that path
-// is not ours to delete recursively.
+// Ancestor symlinks are always refused so the mount inventory and removal name the
+// same tree. Without --force, the managed leaf must also be a root-safe directory;
+// force relaxes only that leaf check (for example, to unlink a symlink at the
+// managed name), never the ancestor or mount boundaries.
func (a *App) removeStateDir(force bool) error {
if err := safeRecursiveRemovalPath(a.StateDir); err != nil {
return fmt.Errorf("unsafe state directory: %w", err)
}
+ if err := refuseSymlinkedRemovalParent(a.StateDir); err != nil {
+ return fmt.Errorf("unsafe state directory: %w", err)
+ }
if _, err := os.Lstat(a.StateDir); os.IsNotExist(err) {
- return nil
+ // A previous recursive removal can have made the name disappear and then
+ // failed to sync its parent. Route the retry through removeAll so it
+ // finishes that durability step instead of treating visibility as durable.
+ return a.removeAll(a.StateDir)
+ } else if err != nil {
+ return fmt.Errorf("inspect state directory: %w", err)
}
if err := refuseMountedRemoval(a.StateDir); err != nil {
return err
@@ -670,8 +794,13 @@ func (a *App) removeAuditDir(force bool) error {
if err := safeRecursiveRemovalPath(a.AuditLogDir); err != nil {
return fmt.Errorf("unsafe audit directory: %w", err)
}
+ if err := refuseSymlinkedRemovalParent(a.AuditLogDir); err != nil {
+ return fmt.Errorf("unsafe audit directory: %w", err)
+ }
if _, err := os.Lstat(a.AuditLogDir); os.IsNotExist(err) {
- return nil
+ return a.removeAll(a.AuditLogDir)
+ } else if err != nil {
+ return fmt.Errorf("inspect audit directory: %w", err)
}
if err := refuseMountedRemoval(a.AuditLogDir); err != nil {
return err
@@ -685,9 +814,34 @@ func (a *App) removeAuditDir(force bool) error {
}
func safeRecursiveRemovalPath(path string) error {
- if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) == string(filepath.Separator) {
+ clean := filepath.Clean(path)
+ if path == "" || !filepath.IsAbs(path) || clean != path || clean == string(filepath.Separator) {
return fmt.Errorf("refusing recursive removal of %q", path)
}
+ parts := strings.Split(strings.TrimPrefix(clean, string(filepath.Separator)), string(filepath.Separator))
+ if len(parts) < 3 {
+ return fmt.Errorf("refusing recursive removal of broad path %q", path)
+ }
+ return nil
+}
+
+// refuseSymlinkedRemovalParent keeps the lexical path checked against mountinfo
+// identical to the path os.RemoveAll will traverse. A symlink in an ancestor
+// could otherwise redirect removal to a different tree whose mounts were never
+// inspected. The final entry is intentionally not resolved: --force may safely
+// unlink a symlink at the managed name because RemoveAll does not follow it.
+func refuseSymlinkedRemovalParent(path string) error {
+ parent := filepath.Dir(path)
+ resolved, err := filepath.EvalSymlinks(parent)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("resolve recursive-removal parent %s: %w", parent, err)
+ }
+ if resolved != parent {
+ return fmt.Errorf("refusing recursive removal through symlinked parent %s (resolves to %s)", parent, resolved)
+ }
return nil
}
@@ -696,59 +850,16 @@ func safeRecursiveRemovalPath(path string) error {
// dedicated tool directory can be used as a mountpoint for unrelated data. This
// check is intentionally not bypassed by --force.
func refuseMountedRemoval(path string) error {
- f, err := os.Open("/proc/self/mountinfo")
- if err != nil {
- return fmt.Errorf("cannot inspect mount boundaries: %w", err)
- }
- defer f.Close()
- return rejectMountsUnder(f, filepath.Clean(path))
+ return mountinfo.RefuseUnder(filepath.Clean(path))
}
func rejectMountsUnder(r io.Reader, root string) error {
- sc := bufio.NewScanner(r)
- sc.Buffer(make([]byte, 4096), 1024*1024)
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- if len(fields) < 5 {
- return fmt.Errorf("malformed mountinfo line")
- }
- mountpoint, err := unescapeMountInfoPath(fields[4])
- if err != nil {
- return err
- }
- rel, err := filepath.Rel(root, filepath.Clean(mountpoint))
- if err != nil {
- return fmt.Errorf("compare mountpoint %q: %w", mountpoint, err)
- }
- if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
- return fmt.Errorf("refusing recursive removal across mountpoint %s", mountpoint)
- }
- }
- if err := sc.Err(); err != nil {
- return fmt.Errorf("read mount boundaries: %w", err)
- }
- return nil
+ return mountinfo.RejectUnder(r, root)
}
-func unescapeMountInfoPath(value string) (string, error) {
- var out strings.Builder
- for i := 0; i < len(value); i++ {
- if value[i] != '\\' {
- out.WriteByte(value[i])
- continue
- }
- if i+3 >= len(value) {
- return "", fmt.Errorf("malformed mountinfo escape in %q", value)
- }
- n, err := strconv.ParseUint(value[i+1:i+4], 8, 8)
- if err != nil {
- return "", fmt.Errorf("malformed mountinfo escape in %q", value)
- }
- out.WriteByte(byte(n))
- i += 3
- }
- return out.String(), nil
-}
+// syncRecursiveRemovalParent is indirected so tests can exercise a retry after
+// the recursive removal became visible but the parent fsync failed.
+var syncRecursiveRemovalParent = func(parent *os.File) error { return parent.Sync() }
func (a *App) removeAll(path string) error {
var err error
@@ -766,12 +877,17 @@ func (a *App) removeAll(path string) error {
}
return fmt.Errorf("verify recursive removal of %s: %w", path, err)
}
- parent, err := os.Open(filepath.Dir(path))
+ parent, err := os.OpenFile(filepath.Dir(path), os.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
+ // If another actor removed the whole parent tree, there is no remaining
+ // directory entry for this function to make durable.
+ if os.IsNotExist(err) {
+ return nil
+ }
return fmt.Errorf("open recursive-removal parent: %w", err)
}
defer parent.Close()
- if err := parent.Sync(); err != nil {
+ if err := syncRecursiveRemovalParent(parent); err != nil {
return &fsutil.DurabilityError{Operation: "recursive removal", Err: err}
}
return nil
diff --git a/internal/cli/uninstall_root_test.go b/internal/cli/uninstall_root_test.go
index 965bc01..382bbfb 100644
--- a/internal/cli/uninstall_root_test.go
+++ b/internal/cli/uninstall_root_test.go
@@ -32,6 +32,7 @@ import (
func uninstallApp(t *testing.T, in string, users ...string) (*App, *strings.Builder, *strings.Builder) {
t.Helper()
a, _, _ := newManageApp(t, in, users...)
+ a.ListMarkerAccounts = user.LifecycleMarkerAccounts
root := t.TempDir()
mk := func(name string, mode os.FileMode) string {
@@ -211,39 +212,60 @@ func TestInventoryUnionsEveryWitness(t *testing.T) {
}
}
-// TestInventoryIgnoresTheGECOSMarker pins the one signal deliberately left out.
-// The marker is writable by anything with sudo — which every --sudo invitee has —
-// so `usermod -c 'linux-temp-admin temporary admin' realadmin` would otherwise
-// enlist a real administrator's account, and their home directory, into a
-// teardown. root is the stand-in here for "an account this tool did not create";
-// nothing deletes it, the inventory is a read.
-func TestInventoryIgnoresTheGECOSMarker(t *testing.T) {
- const name = "ltagecos1"
- a, _, _ := uninstallApp(t, "")
+// TestInventoryTreatsTheGECOSMarkerAsBlockOnly pins both sides of the weak
+// witness contract. A current permanent invite can have no sudo grant, sshd
+// exception, or auto-revoke task, so a lost registry must not make it invisible
+// and let uninstall strand it. But the user-writable marker can never authorize
+// deletion without a completed generation-bound registry identity.
+func TestInventoryTreatsTheGECOSMarkerAsBlockOnly(t *testing.T) {
+ const (
+ name = "ltagecos1"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ a, _, errb := uninstallApp(t, "")
a.Users = user.New()
- // A REAL account carrying the managed marker and nothing else — no registry
- // row, no sudo grant, no unit. This is what `usermod -c 'linux-temp-admin
- // temporary admin' realadmin` produces, and the whole point is that the marker
- // must not be enough to enlist it. An empty inventory would pass this test
- // whether or not the marker were trusted, so the account has to exist for the
- // assertion to mean anything.
+ // A live account carrying the current managed marker and nothing else: exactly
+ // the footprint of --no-sudo --no-auto-revoke after its registry row is lost.
rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() }
rm()
t.Cleanup(rm)
- if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", "linux-temp-admin temporary admin", name).CombinedOutput(); err != nil {
+ if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGenerationGECOSPrefix+generation, name).CombinedOutput(); err != nil {
t.Fatalf("useradd: %v: %s", err, out)
}
if !mustUserManaged(t, name) {
t.Fatalf("%s should carry the managed marker; the fixture is wrong", name)
}
- plan := a.teardownPlan(false, false)
+ plan := a.teardownPlan(false, true)
+ found := false
for _, acc := range plan.accounts {
if acc.name == name {
- t.Fatal("the GECOS marker enlisted an account no tool-owned FILE names — a real admin could be deleted by writing that marker to their own account")
+ found = true
+ if !acc.exists || len(acc.witnesses) != 1 || acc.witnesses[0] != witnessMarker {
+ t.Fatalf("marker-only account inventory = %+v, want one live block-only witness", acc)
+ }
}
}
+ if !found {
+ t.Fatal("the GECOS marker did not block uninstall after the registry and all privilege/task artifacts were lost")
+ }
+
+ if rc := a.uninstall([]string{"--yes", "--remove-users", "--force"}); rc != 1 {
+ t.Fatalf("uninstall rc=%d, want marker-only identity refusal", rc)
+ }
+ if !mustUserExists(t, name) {
+ t.Fatal("a block-only GECOS marker authorized account deletion")
+ }
+ if _, err := os.Stat(a.InstallPath); err != nil {
+ t.Fatal("the command was removed while a marker-only permanent account remained")
+ }
+ if _, err := os.Stat(a.StateDir); err != nil {
+ t.Fatal("state was removed while a marker-only permanent account remained")
+ }
+ if !strings.Contains(errb.String(), "without a current generation-bound identity record") {
+ t.Fatalf("marker-only refusal did not explain the missing identity record: %q", errb.String())
+ }
}
// TestUninstallRefusesWhenTheInventoryIsBlind: an inventory that under-reports is
@@ -282,6 +304,9 @@ func TestUninstallWithAccountsRefusesNonInteractivelyWithoutTheFlag(t *testing.T
if !strings.Contains(errb.String(), "--remove-users") {
t.Errorf("the refusal must name the flag that unblocks it; got %q", errb.String())
}
+ if strings.Contains(errb.String(), "their auto-delete tasks") || !strings.Contains(errb.String(), "any auto-delete tasks already scheduled") {
+ t.Errorf("the refusal must cover permanent accounts without claiming every account has a task; got %q", errb.String())
+ }
if _, err := os.Stat(a.InstallPath); err != nil {
t.Error("the binary was removed despite the refusal")
}
@@ -453,8 +478,8 @@ func TestUninstallRefusesIfInventoryChangesAfterConfirmation(t *testing.T) {
// TestUninstallKeepsTheBinaryWhenAnAccountSurvives is the invariant the whole
// design rests on: never remove the binary while a managed account it could not
// remove is still there. Leaving a sudo-capable account behind while deleting the
-// only thing that manages it is worse than not uninstalling — its auto-delete
-// task's ExecStart IS that binary, so removing it means the account never expires.
+// only thing that can revoke it or clean its grants is worse than not uninstalling;
+// if an auto-delete task exists, its ExecStart also names that binary.
//
// The survivor is manufactured the way the tool itself would refuse one: a real
// account whose recorded UID contradicts its current one is not provably the
@@ -482,7 +507,7 @@ func TestUninstallKeepsTheBinaryWhenAnAccountSurvives(t *testing.T) {
t.Fatal("the survivor was deleted; this test proves nothing")
}
if _, err := os.Stat(a.InstallPath); err != nil {
- t.Error("THE BINARY WAS REMOVED while a managed account survived — its auto-delete task can now never run")
+ t.Error("THE BINARY WAS REMOVED while a managed account survived, so it can no longer be revoked or have its grants cleaned")
}
if _, err := os.Stat(a.StateDir); err != nil {
t.Error("the state directory was removed while an account survived: its row is the only record of what it was")
@@ -989,7 +1014,7 @@ func TestDoctorReportsUntrustedRegistryIdentities(t *testing.T) {
t.Fatalf("doctor rc=%d, want 1 for untrusted registry identities", rc)
}
got := errb.String()
- for _, want := range []string{pendingName, markerName, legacyName, "pending creation", "managed identity marker", "no trusted UID"} {
+ for _, want := range []string{pendingName, markerName, legacyName, "pending creation", "managed identity marker", "no safe non-root UID/GID"} {
if !strings.Contains(got, want) {
t.Errorf("doctor output missing %q: %q", want, got)
}
diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go
new file mode 100644
index 0000000..20aeea6
--- /dev/null
+++ b/internal/cli/uninstall_test.go
@@ -0,0 +1,290 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/xxvcc/linux-temp-admin/internal/config"
+ "github.com/xxvcc/linux-temp-admin/internal/registry"
+ "github.com/xxvcc/linux-temp-admin/internal/user"
+)
+
+func TestUninstallMarkerOnlyPermanentAccountBlocksWithoutDeleteAuthority(t *testing.T) {
+ const (
+ name = "lta-marker-only"
+ generation = "0123456789abcdef0123456789abcdef"
+ )
+ a, out, errb := newTestApp(t, "")
+ root := t.TempDir()
+ a.StateDir = filepath.Join(root, "state")
+ a.AuditLogDir = filepath.Join(root, "audit")
+ registryDir := filepath.Join(a.StateDir, "v2")
+ if err := os.MkdirAll(registryDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ a.Registry = ®istry.Store{
+ Dir: registryDir,
+ File: filepath.Join(registryDir, "registry.tsv"),
+ Lock: filepath.Join(registryDir, "registry.lock"),
+ }
+ binDir := filepath.Join(root, "bin")
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ a.InstallPath = filepath.Join(binDir, "linux-temp-admin")
+ if err := os.WriteFile(a.InstallPath, []byte("unchanged"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ pw := user.Passwd{
+ Name: name, UID: 2001, GID: 2001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/" + name, Shell: "/bin/sh",
+ }
+ a.ListMarkerAccounts = func() ([]string, error) { return []string{name}, nil }
+ a.LookupUser = func(got string) (user.Passwd, bool, error) {
+ if got != name {
+ t.Fatalf("passwd lookup = %q, want %q", got, name)
+ }
+ return pw, true, nil
+ }
+
+ result := a.uninstallResult([]string{"--yes", "--remove-users", "--force"})
+ if result.status != 1 || result.applied {
+ t.Fatalf("uninstall result = %+v, want an unapplied marker-only refusal", result)
+ }
+ if _, err := os.Stat(a.InstallPath); err != nil {
+ t.Fatalf("installed command changed despite the block-only witness: %v", err)
+ }
+ if _, err := os.Stat(a.StateDir); err != nil {
+ t.Fatalf("state changed despite the block-only witness: %v", err)
+ }
+ if found, err := a.Registry.Contains(name); err != nil || found {
+ t.Fatalf("fixture unexpectedly gained deletion authority: found=%v err=%v", found, err)
+ }
+ if got := out.String(); !strings.Contains(got, string(witnessMarker)) {
+ t.Fatalf("teardown plan did not identify the block-only marker: %q", got)
+ }
+ if got := errb.String(); !strings.Contains(got, "without a current generation-bound identity record") {
+ t.Fatalf("refusal did not explain the missing strong identity: %q", got)
+ }
+}
+
+func TestUninstallRefusesLiveUIDOnlyRecoveryBeforeAnyMutation(t *testing.T) {
+ const name = "xxvcc-live-recovery"
+ a, _, errb := newTestApp(t, "")
+ root := t.TempDir()
+ a.StateDir = filepath.Join(root, "state")
+ a.AuditLogDir = filepath.Join(root, "audit")
+ if err := os.MkdirAll(a.StateDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ a.Registry = ®istry.Store{
+ Dir: filepath.Join(a.StateDir, "v2"),
+ File: filepath.Join(a.StateDir, "v2", "registry.tsv"),
+ Lock: filepath.Join(a.StateDir, "v2", "registry.lock"),
+ }
+ if err := a.Registry.Init(); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Registry.BeginDeletion(name, 2001, ""); err != nil {
+ t.Fatal(err)
+ }
+ binDir := filepath.Join(root, "bin")
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ a.InstallPath = filepath.Join(binDir, "linux-temp-admin")
+ if err := os.WriteFile(a.InstallPath, []byte("unchanged"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ pw := user.Passwd{
+ Name: name, UID: 2001, GID: 2001,
+ GECOS: config.ManagedGenerationGECOSPrefix + "0123456789abcdef0123456789abcdef",
+ Home: "/home/" + name, Shell: "/bin/sh",
+ }
+ a.LookupUser = func(string) (user.Passwd, bool, error) { return pw, true, nil }
+
+ result := a.uninstallResult([]string{"--yes", "--remove-users", "--force"})
+ if result.status != 1 || result.applied {
+ t.Fatalf("uninstall result = %+v, want pre-mutation recovery refusal", result)
+ }
+ if _, err := os.Stat(a.InstallPath); err != nil {
+ t.Fatalf("installed command changed despite UID-only recovery: %v", err)
+ }
+ if _, err := os.Stat(a.StateDir); err != nil {
+ t.Fatalf("state changed despite UID-only recovery: %v", err)
+ }
+ if rec, found, err := a.Registry.Lookup(name); err != nil || !found || !rec.DeletionStarted {
+ t.Fatalf("UID-only witness changed: found=%v rec=%+v err=%v", found, rec, err)
+ }
+ if got := errb.String(); !strings.Contains(got, "not bound to its current generation") {
+ t.Fatalf("recovery refusal was not explained: %q", got)
+ }
+}
+
+func TestAuthorizeUninstallRejectsLaterUnverifiedAccountBeforeTeardown(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ validName := "lta-a-valid"
+ blockedName := "lta-z-marker-only"
+ validRecord := registry.Record{
+ User: validName, UID: 2001, Generation: generation, IdentityBound: true, Port: 22,
+ }
+ validPasswd := user.Passwd{
+ Name: validName, UID: 2001, GID: 2001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/" + validName, Shell: "/bin/sh",
+ }
+ blockedPasswd := user.Passwd{
+ Name: blockedName, UID: 2002, GID: 2002,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/" + blockedName, Shell: "/bin/sh",
+ }
+ plan := teardownPlan{accounts: []teardownAccount{
+ {
+ name: validName, exists: true, witnesses: []witness{witnessRegistry},
+ registryFound: true, registryRecord: validRecord, passwd: validPasswd,
+ },
+ {
+ name: blockedName, exists: true, witnesses: []witness{witnessMarker},
+ passwd: blockedPasswd,
+ },
+ }}
+
+ a, _, errb := newTestApp(t, "")
+ if a.authorizeUninstall(plan, true, true) {
+ t.Fatal("mixed uninstall was authorized even though a later live account lacks deletion authority")
+ }
+ if got := errb.String(); !strings.Contains(got, blockedName) ||
+ !strings.Contains(got, "before deleting any account") {
+ t.Fatalf("whole-plan refusal did not identify the later blocker: %q", got)
+ }
+}
+
+func TestUninstallRefusesWhenMarkerInventoryCannotBeRead(t *testing.T) {
+ a, _, errb := newTestApp(t, "")
+ a.StateDir = t.TempDir()
+ a.ListMarkerAccounts = func() ([]string, error) {
+ return nil, os.ErrPermission
+ }
+ plan := a.teardownPlan(false, false)
+ if plan.inventoryErr == nil {
+ t.Fatal("marker scan failure was not included in the fail-closed inventory error")
+ }
+ if a.authorizeUninstall(plan, true, true) {
+ t.Fatal("uninstall was authorized with an unreadable marker inventory")
+ }
+ if got := errb.String(); !strings.Contains(got, "scanning account lifecycle markers failed") {
+ t.Fatalf("marker inventory refusal was not explained: %q", got)
+ }
+}
+
+func TestSameTeardownPlanBindsRegistryAndPasswdIdentity(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ base := teardownPlan{accounts: []teardownAccount{{
+ name: "xxvcc-plan1", exists: true, witnesses: []witness{witnessRegistry},
+ registryFound: true,
+ registryRecord: registry.Record{
+ User: "xxvcc-plan1", UID: 1001, Generation: generation,
+ IdentityBound: true, Port: 22,
+ },
+ passwd: user.Passwd{
+ Name: "xxvcc-plan1", UID: 1001, GID: 1001,
+ GECOS: config.ManagedGenerationGECOSPrefix + generation,
+ Home: "/home/xxvcc-plan1", Shell: "/bin/sh",
+ },
+ }}}
+ if !sameTeardownPlan(base, base) {
+ t.Fatal("an unchanged teardown identity did not compare equal")
+ }
+
+ for _, tc := range []struct {
+ name string
+ mutate func(*teardownAccount)
+ }{
+ {
+ name: "registry generation changed",
+ mutate: func(acc *teardownAccount) {
+ acc.registryRecord.Generation = "fedcba9876543210fedcba9876543210"
+ },
+ },
+ {
+ name: "passwd UID changed",
+ mutate: func(acc *teardownAccount) {
+ acc.passwd.UID = 2002
+ },
+ },
+ {
+ name: "registry disappeared",
+ mutate: func(acc *teardownAccount) {
+ acc.registryFound = false
+ acc.registryRecord = registry.Record{}
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ changed := base
+ changed.accounts = append([]teardownAccount(nil), base.accounts...)
+ tc.mutate(&changed.accounts[0])
+ if sameTeardownPlan(base, changed) {
+ t.Fatal("teardown plan ignored an account identity change")
+ }
+ })
+ }
+}
+
+func TestV1RegistryUsersRejectsNonCanonicalRows(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ content string
+ wantErr string
+ }{
+ {
+ name: "leading whitespace before username",
+ content: " xxvcc-a1\t2026-07-31\n",
+ wantErr: "invalid username",
+ },
+ {
+ name: "valid username without tab-separated fields",
+ content: "xxvcc-a1\n",
+ wantErr: "not tab-separated",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.StateDir = t.TempDir()
+ path := filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile))
+ if err := os.WriteFile(path, []byte(tc.content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if users, err := a.v1RegistryUsers(); err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("v1RegistryUsers = %v, %v; want error containing %q", users, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestV1RegistryUsersAcceptsHistoricalTabSeparatedRows(t *testing.T) {
+ a, _, _ := newTestApp(t, "")
+ a.StateDir = t.TempDir()
+ path := filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile))
+ content := strings.Join([]string{
+ "",
+ "# retained operator note",
+ "xxvcc-v1early\tcreated\texpires\tyes\tno\thost\t22\tfingerprint",
+ "xxvcc-v1late\tcreated\texpires\tyes\tno\thost\t22\tfingerprint\tyes\tunit",
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ users, err := a.v1RegistryUsers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"xxvcc-v1early", "xxvcc-v1late"}
+ if len(users) != len(want) || users[0] != want[0] || users[1] != want[1] {
+ t.Fatalf("v1RegistryUsers = %v, want %v", users, want)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index e815bed..18d64d8 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -72,9 +72,9 @@ const (
// expected to hand-edit or ship in a config-management repo.
PrefsFile = RegistryDir + "/prefs"
// RegistrySchema is written as the registry header's version marker.
- RegistrySchema = 3
+ RegistrySchema = 4
- // AuditLogDir holds the append-only operation audit log (root:root, 0700).
+ // AuditLogDir holds the best-effort JSONL operation audit log (root:root, 0700).
AuditLogDir = "/var/log/" + ManagedTag
// AuditLogFile is the audit log; one JSON object per line.
AuditLogFile = AuditLogDir + "/audit.log"
diff --git a/internal/executil/executil.go b/internal/executil/executil.go
index 9a88e56..6c938ee 100644
--- a/internal/executil/executil.go
+++ b/internal/executil/executil.go
@@ -22,9 +22,10 @@ const (
defaultWaitDelay = time.Second
)
-// Options controls one helper invocation. Privileged helpers receive a fixed,
-// minimal environment rather than caller-controlled values preserved by
-// `sudo -E`; ExtraEnv adds purpose-specific values such as a stable locale.
+// Options controls one helper invocation. Helpers receive a minimal environment
+// rather than caller-controlled values preserved by `sudo -E`; ExtraEnv adds
+// purpose-specific values such as a stable locale. PATH is the process PATH: the
+// production CLI pins it to trusted system directories before any root dispatch.
type Options struct {
Context context.Context
Timeout time.Duration
diff --git a/internal/expiry/expiry.go b/internal/expiry/expiry.go
index 286c900..ac171fb 100644
--- a/internal/expiry/expiry.go
+++ b/internal/expiry/expiry.go
@@ -2,20 +2,31 @@
//
// chage -E is day-granular and locks the account at 00:00 UTC of the given date.
// To keep an account usable for at least the requested window on every timezone
-// and creation time — and never lock it prematurely — the expiry date is
-// anchored to the first midnight strictly after now+hours (the date of
-// now+hours, plus one day). When an auto-delete timer is set it fires precisely
-// at now+hours; chage only backstops it and must not lock before it.
+// and creation time, the revoke deadline is rounded up to a whole minute and the
+// chage date is anchored to the first UTC midnight strictly after it. Scheduler
+// downtime and retries can delay deletion; chage only backstops it and must not
+// lock before the deadline.
package expiry
import "time"
const dateLayout = "2006-01-02"
-// Date returns the chage -E expiry date (YYYY-MM-DD, UTC) for an account created
-// at now with the given lifetime in hours.
-func Date(now time.Time, hours int) string {
- return now.UTC().Add(time.Duration(hours)*time.Hour).AddDate(0, 0, 1).Format(dateLayout)
+// Deadline returns the single absolute revoke deadline shared by display,
+// chage, and every scheduler backend. Rounding up accommodates at(1)'s
+// minute-granular absolute format without ever shortening the requested window.
+func Deadline(now time.Time, hours int) time.Time {
+ target := now.Add(time.Duration(hours) * time.Hour)
+ minute := target.Truncate(time.Minute)
+ if target.Equal(minute) {
+ return minute
+ }
+ return minute.Add(time.Minute)
+}
+
+// Date returns the chage -E backstop date (YYYY-MM-DD, UTC) for deadline.
+func Date(deadline time.Time) string {
+ return deadline.UTC().AddDate(0, 0, 1).Format(dateLayout)
}
// LockInstant returns the UTC instant at which chage disables the account for a
@@ -24,8 +35,8 @@ func LockInstant(date string) (time.Time, error) {
return time.ParseInLocation(dateLayout, date, time.UTC)
}
-// DisplayLocal returns the exact scheduled-revoke deadline for the invite output
-// (now + hours). Date supplies only the later day-granularity lockout backstop.
-func DisplayLocal(now time.Time, hours int) string {
- return now.Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05 MST")
+// DisplayLocal formats the shared revoke deadline for the invite output. Date
+// supplies only the later day-granularity lockout backstop.
+func DisplayLocal(deadline time.Time) string {
+ return deadline.Format("2006-01-02 15:04:05 MST")
}
diff --git a/internal/expiry/expiry_test.go b/internal/expiry/expiry_test.go
index fe40df8..9f39c8d 100644
--- a/internal/expiry/expiry_test.go
+++ b/internal/expiry/expiry_test.go
@@ -5,42 +5,112 @@ import (
"time"
)
-// The expiry date must never lock the account before the requested window
-// (usable for at least `hours`), yet stay within ~1 extra day — across every
-// hour-of-day a creation might happen at.
+func TestDeadlineRoundsUpWithoutShorteningRequestedWindow(t *testing.T) {
+ loc := time.FixedZone("test", 8*60*60)
+ for _, tc := range []struct {
+ now time.Time
+ want time.Time
+ }{
+ {
+ now: time.Date(2026, 7, 7, 12, 34, 0, 0, loc),
+ want: time.Date(2026, 7, 8, 12, 34, 0, 0, loc),
+ },
+ {
+ now: time.Date(2026, 7, 7, 12, 34, 0, 1, loc),
+ want: time.Date(2026, 7, 8, 12, 35, 0, 0, loc),
+ },
+ {
+ now: time.Date(2026, 7, 7, 12, 34, 59, 999999999, loc),
+ want: time.Date(2026, 7, 8, 12, 35, 0, 0, loc),
+ },
+ } {
+ got := Deadline(tc.now, 24)
+ if !got.Equal(tc.want) || got.Location() != loc {
+ t.Errorf("Deadline(%s) = %s (%s), want %s (%s)", tc.now, got, got.Location(), tc.want, loc)
+ }
+ requested := tc.now.Add(24 * time.Hour)
+ if got.Before(requested) || got.Sub(requested) >= time.Minute {
+ t.Errorf("deadline offset from requested target = %s, want [0, 1m)", got.Sub(requested))
+ }
+ }
+}
+
+func TestDeadlineUsesElapsedHoursAcrossDST(t *testing.T) {
+ loc, err := time.LoadLocation("America/New_York")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ name string
+ now time.Time
+ hours int
+ want time.Time
+ }{
+ {
+ name: "spring forward full day",
+ now: time.Date(2026, 3, 7, 12, 34, 59, 0, loc),
+ hours: 24,
+ want: time.Date(2026, 3, 8, 13, 35, 0, 0, loc),
+ },
+ {
+ name: "fall back full day",
+ now: time.Date(2026, 10, 31, 12, 34, 59, 0, loc),
+ hours: 24,
+ want: time.Date(2026, 11, 1, 11, 35, 0, 0, loc),
+ },
+ {
+ name: "spring gap one hour",
+ now: time.Date(2026, 3, 8, 1, 30, 59, 0, loc),
+ hours: 1,
+ want: time.Date(2026, 3, 8, 3, 31, 0, 0, loc),
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := Deadline(tc.now, tc.hours); !got.Equal(tc.want) {
+ t.Fatalf("Deadline(%s, %d) = %s, want %s", tc.now, tc.hours, got, tc.want)
+ }
+ })
+ }
+}
+
+// The expiry date must never lock the account before the shared deadline, yet
+// stay within one extra day across every hour-of-day a creation might happen at.
func TestNeverPrematureWithinOneDay(t *testing.T) {
base := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC)
for _, hours := range []int{1, 6, 12, 23, 24, 25, 48, 168, 8760} {
for hod := 0; hod < 24; hod++ { // creation at each hour of the day
for _, min := range []int{0, 11, 59} {
now := base.Add(time.Duration(hod)*time.Hour + time.Duration(min)*time.Minute)
- date := Date(now, hours)
+ deadline := Deadline(now, hours)
+ date := Date(deadline)
lock, err := LockInstant(date)
if err != nil {
t.Fatalf("LockInstant(%q): %v", date, err)
}
- window := now.UTC().Add(time.Duration(hours) * time.Hour)
- if lock.Before(window) {
- t.Errorf("hours=%d now=%s: lock %s is before now+hours %s (premature)",
- hours, now.Format(time.RFC3339), lock.Format(time.RFC3339), window.Format(time.RFC3339))
+ if lock.Before(deadline) {
+ t.Errorf("hours=%d now=%s: lock %s is before deadline %s (premature)",
+ hours, now.Format(time.RFC3339), lock.Format(time.RFC3339), deadline.Format(time.RFC3339))
}
- if lock.After(window.Add(24 * time.Hour)) {
- t.Errorf("hours=%d now=%s: lock %s is more than 1 day past the window %s",
- hours, now.Format(time.RFC3339), lock.Format(time.RFC3339), window.Format(time.RFC3339))
+ if lock.After(deadline.Add(24 * time.Hour)) {
+ t.Errorf("hours=%d now=%s: lock %s is more than 1 day past deadline %s",
+ hours, now.Format(time.RFC3339), lock.Format(time.RFC3339), deadline.Format(time.RFC3339))
}
}
}
}
}
-func TestDateIsPlusHoursPlusOneDay(t *testing.T) {
- now := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
- // 24h + 1 day = +2 days exactly (independent of hour-of-day for multiples of 24)
- if got, want := Date(now, 24), "2026-07-09"; got != want {
- t.Errorf("Date(+24h) = %q, want %q", got, want)
- }
- // 6h from 12:00 -> now+6h = 18:00 same day -> +1 day -> next day
- if got, want := Date(now, 6), "2026-07-08"; got != want {
- t.Errorf("Date(+6h) = %q, want %q", got, want)
+func TestDateIsFirstUTCMidnightAfterDeadline(t *testing.T) {
+ for _, tc := range []struct {
+ deadline time.Time
+ want string
+ }{
+ {time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC), "2026-07-09"},
+ {time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC), "2026-07-09"},
+ {time.Date(2026, 7, 8, 23, 59, 0, 0, time.UTC), "2026-07-09"},
+ } {
+ if got := Date(tc.deadline); got != tc.want {
+ t.Errorf("Date(%s) = %q, want %q", tc.deadline, got, tc.want)
+ }
}
}
diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go
index 68593de..29434ec 100644
--- a/internal/fsutil/fsutil.go
+++ b/internal/fsutil/fsutil.go
@@ -90,13 +90,24 @@ func checkRootOwnedNotWritable(path string, fi os.FileInfo) error {
return nil
}
-// EnsureDir creates path (and parents) component by component, refusing symlinks
-// anywhere in the path. A newly created directory is synced before its parent
-// directory entry, and the leaf is synced after ownership/mode repair.
+// EnsureDir creates an absolute path (and parents) component by component,
+// refusing traversal components and symlinks anywhere in the path. Every
+// component created by this call receives the exact mode; new parents retain
+// the creating process's ownership, existing ancestors are left unchanged, and
+// the leaf is always repaired to the requested owner and mode. A newly created
+// directory is synced before its parent directory entry.
func EnsureDir(path string, mode os.FileMode, uid, gid int) error {
if path == "" {
return fmt.Errorf("empty directory path")
}
+ if !filepath.IsAbs(path) {
+ return fmt.Errorf("directory path must be absolute: %q", path)
+ }
+ for _, part := range strings.Split(path, string(filepath.Separator)) {
+ if part == "." || part == ".." {
+ return fmt.Errorf("unsafe directory component %q in %s", part, path)
+ }
+ }
if !validate.KernelID(uid) || !validate.KernelID(gid) {
return fmt.Errorf("invalid directory owner %d:%d", uid, gid)
}
@@ -111,27 +122,49 @@ func EnsureDir(path string, mode os.FileMode, uid, gid int) error {
}
}
- start := "."
- if filepath.IsAbs(clean) {
- start = string(filepath.Separator)
- }
+ start := string(filepath.Separator)
rootFD, err := unix.Open(start, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return fmt.Errorf("open directory traversal root %s: %w", start, err)
}
parent := os.NewFile(uintptr(rootFD), start)
- defer func() { _ = parent.Close() }()
+ var grandparent *os.File
+ defer func() {
+ if parent != nil {
+ _ = parent.Close()
+ }
+ if grandparent != nil {
+ _ = grandparent.Close()
+ }
+ }()
+ creatingSuffix := false
for i, part := range parts {
- created := false
+ entryAppeared := false
+ createdByUs := false
childFD, openErr := unix.Openat(int(parent.Fd()), part,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if openErr == unix.ENOENT {
- if mkdirErr := unix.Mkdirat(int(parent.Fd()), part, uint32(mode.Perm())); mkdirErr == nil {
- created = true
- } else if mkdirErr != unix.EEXIST {
+ // A previous call can have made parent visible and then failed to
+ // fsync either the new directory inode or its parent entry. Before
+ // extending the first missing suffix, finish those durability steps in
+ // child-before-parent order. Components created below it in this call
+ // are already covered by the normal syncs.
+ if !creatingSuffix && grandparent != nil {
+ if err := syncDirectory(parent); err != nil {
+ return &DurabilityError{Operation: "directory metadata update", Err: err}
+ }
+ if err := syncDirectory(grandparent); err != nil {
+ return &DurabilityError{Operation: "mkdir", Err: err}
+ }
+ }
+ creatingSuffix = true
+ entryAppeared = true
+ mkdirErr := unix.Mkdirat(int(parent.Fd()), part, uint32(mode.Perm()))
+ if mkdirErr != nil && mkdirErr != unix.EEXIST {
return fmt.Errorf("create directory component %s: %w", part, mkdirErr)
}
+ createdByUs = mkdirErr == nil
childFD, openErr = unix.Openat(int(parent.Fd()), part,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
}
@@ -143,31 +176,46 @@ func EnsureDir(path string, mode os.FileMode, uid, gid int) error {
if last {
if err := child.Chown(uid, gid); err != nil {
_ = child.Close()
- return fmt.Errorf("set directory owner for %s: %w", path, err)
+ return fmt.Errorf("set directory owner for %s: %w", child.Name(), err)
}
+ }
+ if createdByUs || last {
if err := child.Chmod(mode); err != nil {
_ = child.Close()
- return fmt.Errorf("set directory mode for %s: %w", path, err)
+ return fmt.Errorf("set directory mode for %s: %w", child.Name(), err)
}
}
- if created || last {
+ if entryAppeared || last {
if err := syncDirectory(child); err != nil {
_ = child.Close()
return &DurabilityError{Operation: "directory metadata update", Err: err}
}
}
- if created {
+ // Sync an existing leaf's parent as well: it may be the visible result
+ // of an earlier mkdir whose parent sync failed.
+ if entryAppeared || last {
if err := syncDirectory(parent); err != nil {
_ = child.Close()
return &DurabilityError{Operation: "mkdir", Err: err}
}
}
- if err := parent.Close(); err != nil {
- _ = child.Close()
- return fmt.Errorf("close directory component: %w", err)
+ if grandparent != nil {
+ if err := grandparent.Close(); err != nil {
+ grandparent = nil
+ _ = child.Close()
+ return fmt.Errorf("close directory component: %w", err)
+ }
}
+ grandparent = parent
parent = child
}
+ if grandparent != nil {
+ if err := grandparent.Close(); err != nil {
+ grandparent = nil
+ return fmt.Errorf("close directory component: %w", err)
+ }
+ grandparent = nil
+ }
return nil
}
@@ -301,10 +349,14 @@ func AtomicWriteFileAt(dir *os.File, name string, content []byte, mode os.FileMo
return nil
}
+// unlinkFileAt is indirected so a unit test can force the stat/unlink race.
+var unlinkFileAt = unix.Unlinkat
+
// RemoveFile unlinks one non-directory entry relative to a pinned parent
// directory and syncs that directory before returning success. It never follows
-// a symlink at either the parent or target. An absent target is already removed
-// and is therefore success.
+// a symlink at either the parent or target. When the target is already absent,
+// it still syncs an existing parent so a retry can finish an earlier unlink
+// whose directory sync failed.
func RemoveFile(path string) error {
dirPath := filepath.Dir(path)
name := filepath.Base(path)
@@ -319,27 +371,30 @@ func RemoveFile(path string) error {
return fmt.Errorf("open parent directory %s: %w", dirPath, err)
}
defer dir.Close()
+ syncParent := func() error {
+ if err := syncDirectory(dir); err != nil {
+ return &DurabilityError{Operation: "unlink", Err: err}
+ }
+ return nil
+ }
var st unix.Stat_t
if err := unix.Fstatat(int(dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
if err == unix.ENOENT {
- return nil
+ return syncParent()
}
return fmt.Errorf("stat removal target %s: %w", path, err)
}
if st.Mode&unix.S_IFMT == unix.S_IFDIR {
return fmt.Errorf("refusing to unlink directory %s", path)
}
- if err := unix.Unlinkat(int(dir.Fd()), name, 0); err != nil {
+ if err := unlinkFileAt(int(dir.Fd()), name, 0); err != nil {
if err == unix.ENOENT {
- return nil
+ return syncParent()
}
return fmt.Errorf("unlink %s: %w", path, err)
}
- if err := syncDirectory(dir); err != nil {
- return &DurabilityError{Operation: "unlink", Err: err}
- }
- return nil
+ return syncParent()
}
func createTempAt(dir *os.File, target string) (string, *os.File, error) {
diff --git a/internal/fsutil/fsutil_test.go b/internal/fsutil/fsutil_test.go
index a97540a..af5997b 100644
--- a/internal/fsutil/fsutil_test.go
+++ b/internal/fsutil/fsutil_test.go
@@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"testing"
+
+ "golang.org/x/sys/unix"
)
func TestAtomicWriteFileAs(t *testing.T) {
@@ -34,7 +36,8 @@ func TestOwnershipMutationsRejectChownSentinel(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot represent the reserved uint32 chown sentinel")
}
- reserved := int(uint64(^uint32(0)))
+ reservedKernelID := uint64(^uint32(0))
+ reserved := int(reservedKernelID)
dir := t.TempDir()
if err := EnsureDir(filepath.Join(dir, "child"), 0o700, reserved, 1); err == nil {
t.Fatal("EnsureDir accepted chown's all-ones uid sentinel")
@@ -117,7 +120,7 @@ func TestRemoveFileSyncsParentAndDoesNotFollowSymlink(t *testing.T) {
}
}
-func TestRemoveFileReportsCommittedSyncFailure(t *testing.T) {
+func TestRemoveFileRetrySyncsParentAfterCommittedSyncFailure(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.WriteFile(target, []byte("x"), 0o600); err != nil {
@@ -125,7 +128,14 @@ func TestRemoveFileReportsCommittedSyncFailure(t *testing.T) {
}
wantErr := errors.New("forced unlink sync failure")
old := syncDirectory
- syncDirectory = func(*os.File) error { return wantErr }
+ syncs := 0
+ syncDirectory = func(*os.File) error {
+ syncs++
+ if syncs == 1 {
+ return wantErr
+ }
+ return nil
+ }
t.Cleanup(func() { syncDirectory = old })
err := RemoveFile(target)
@@ -136,6 +146,60 @@ func TestRemoveFileReportsCommittedSyncFailure(t *testing.T) {
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("unlink was not committed: %v", err)
}
+ if err := RemoveFile(target); err != nil {
+ t.Fatalf("RemoveFile retry after visible unlink: %v", err)
+ }
+ if syncs != 2 {
+ t.Fatalf("parent directory sync calls = %d, want failed unlink sync plus absent-target retry sync", syncs)
+ }
+}
+
+func TestRemoveFileAbsentTargetReportsParentSyncFailure(t *testing.T) {
+ dir := t.TempDir()
+ wantErr := errors.New("forced absent-target sync failure")
+ old := syncDirectory
+ syncDirectory = func(*os.File) error { return wantErr }
+ t.Cleanup(func() { syncDirectory = old })
+
+ err := RemoveFile(filepath.Join(dir, "absent"))
+ var durability *DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "unlink" {
+ t.Fatalf("RemoveFile absent-target error = %v, want unlink DurabilityError", err)
+ }
+}
+
+func TestRemoveFileSyncsParentWhenTargetDisappearsBeforeUnlink(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target")
+ if err := os.WriteFile(target, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ oldUnlinkFileAt := unlinkFileAt
+ unlinkFileAt = func(dirfd int, path string, flags int) error {
+ if err := oldUnlinkFileAt(dirfd, path, flags); err != nil {
+ return err
+ }
+ return unix.ENOENT
+ }
+ t.Cleanup(func() { unlinkFileAt = oldUnlinkFileAt })
+
+ oldSync := syncDirectory
+ syncs := 0
+ syncDirectory = func(*os.File) error {
+ syncs++
+ return nil
+ }
+ t.Cleanup(func() { syncDirectory = oldSync })
+
+ if err := RemoveFile(target); err != nil {
+ t.Fatalf("RemoveFile after concurrent disappearance: %v", err)
+ }
+ if _, err := os.Lstat(target); !os.IsNotExist(err) {
+ t.Fatalf("target still exists after simulated unlink race: %v", err)
+ }
+ if syncs != 1 {
+ t.Fatalf("parent directory sync calls = %d, want 1 after unlink race", syncs)
+ }
}
func TestRemoveFileRefusesDirectory(t *testing.T) {
@@ -261,8 +325,8 @@ func TestEnsureDir(t *testing.T) {
if err := EnsureDir(p, 0o700, os.Getuid(), os.Getgid()); err != nil {
t.Fatal(err)
}
- if syncs != 4 {
- t.Fatalf("new nested directory sync calls=%d, want child+parent for both components", syncs)
+ if syncs != 6 {
+ t.Fatalf("new nested directory sync calls=%d, want existing-prefix inode+parent repair plus child+parent for both components", syncs)
}
fi, err := os.Lstat(p)
if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o700 {
@@ -272,8 +336,67 @@ func TestEnsureDir(t *testing.T) {
if err := EnsureDir(p, 0o700, os.Getuid(), os.Getgid()); err != nil {
t.Fatalf("second EnsureDir: %v", err)
}
- if syncs != 5 {
- t.Fatalf("existing leaf metadata sync calls=%d, want one additional call", syncs)
+ if syncs != 8 {
+ t.Fatalf("existing leaf durability sync calls=%d, want leaf and parent added on retry", syncs)
+ }
+}
+
+func TestEnsureDirRepairsEveryNewSuffixDespiteRestrictiveUmask(t *testing.T) {
+ base := t.TempDir()
+ existing := filepath.Join(base, "existing")
+ if err := os.Mkdir(existing, 0o711); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(existing, 0o711); err != nil {
+ t.Fatal(err)
+ }
+ var existingBefore unix.Stat_t
+ if err := unix.Stat(existing, &existingBefore); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(existing, "middle", "leaf")
+ wantUID, wantGID := os.Getuid(), os.Getgid()
+ if os.Geteuid() == 0 {
+ wantUID, wantGID = 12345, 12346
+ }
+
+ oldUmask := unix.Umask(0o077)
+ t.Cleanup(func() { unix.Umask(oldUmask) })
+ if err := EnsureDir(target, 0o755, wantUID, wantGID); err != nil {
+ t.Fatal(err)
+ }
+
+ existingInfo, err := os.Lstat(existing)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var existingAfter unix.Stat_t
+ if err := unix.Stat(existing, &existingAfter); err != nil {
+ t.Fatal(err)
+ }
+ if existingInfo.Mode().Perm() != 0o711 || existingAfter.Uid != existingBefore.Uid || existingAfter.Gid != existingBefore.Gid {
+ t.Fatalf("existing prefix metadata changed: mode=%o owner %d:%d, want 711 %d:%d",
+ existingInfo.Mode().Perm(), existingAfter.Uid, existingAfter.Gid, existingBefore.Uid, existingBefore.Gid)
+ }
+ for _, want := range []struct {
+ path string
+ uid, gid int
+ }{
+ {path: filepath.Join(existing, "middle"), uid: os.Geteuid(), gid: os.Getegid()},
+ {path: target, uid: wantUID, gid: wantGID},
+ } {
+ fi, err := os.Lstat(want.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st unix.Stat_t
+ if err := unix.Stat(want.path, &st); err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o755 || int(st.Uid) != want.uid || int(st.Gid) != want.gid {
+ t.Fatalf("new directory %s metadata = mode %o owner %d:%d, want 755 %d:%d",
+ want.path, fi.Mode().Perm(), st.Uid, st.Gid, want.uid, want.gid)
+ }
}
}
@@ -310,11 +433,39 @@ func TestEnsureDirRefusesSymlinkIntermediateComponent(t *testing.T) {
}
}
+func TestEnsureDirRejectsRelativeAndTraversalPathsBeforeMutation(t *testing.T) {
+ working := t.TempDir()
+ t.Chdir(working)
+ if err := EnsureDir("relative/child", 0o700, os.Getuid(), os.Getgid()); err == nil {
+ t.Fatal("EnsureDir accepted a relative path")
+ }
+ if _, err := os.Lstat(filepath.Join(working, "relative")); !os.IsNotExist(err) {
+ t.Fatalf("relative path was mutated before refusal: %v", err)
+ }
+
+ dir := t.TempDir()
+ escaped := filepath.Join(dir, "escaped")
+ path := dir + string(filepath.Separator) + "missing" + string(filepath.Separator) + ".." + string(filepath.Separator) + "escaped"
+ if err := EnsureDir(path, 0o700, os.Getuid(), os.Getgid()); err == nil {
+ t.Fatal("EnsureDir accepted an absolute path containing ..")
+ }
+ if _, err := os.Lstat(escaped); !os.IsNotExist(err) {
+ t.Fatalf("traversal path was mutated before refusal: %v", err)
+ }
+}
+
func TestEnsureDirReportsVisibleDirectoryOnSyncFailure(t *testing.T) {
p := filepath.Join(t.TempDir(), "new")
wantErr := errors.New("forced directory sync failure")
old := syncDirectory
- syncDirectory = func(*os.File) error { return wantErr }
+ syncs := 0
+ syncDirectory = func(*os.File) error {
+ syncs++
+ if syncs == 3 {
+ return wantErr
+ }
+ return nil
+ }
t.Cleanup(func() { syncDirectory = old })
err := EnsureDir(p, 0o700, os.Getuid(), os.Getgid())
@@ -326,3 +477,91 @@ func TestEnsureDirReportsVisibleDirectoryOnSyncFailure(t *testing.T) {
t.Fatalf("created directory is not visible: info=%v err=%v", fi, statErr)
}
}
+
+func TestEnsureDirRetrySyncsVisibleIntermediateParentEntry(t *testing.T) {
+ base := t.TempDir()
+ visible := filepath.Join(base, "visible")
+ target := filepath.Join(visible, "leaf")
+ wantErr := errors.New("forced visible-mkdir parent sync failure")
+ old := syncDirectory
+ failed := false
+ baseSyncs := 0
+ var retrySyncs []string
+ inRetry := false
+ syncDirectory = func(dir *os.File) error {
+ if inRetry {
+ retrySyncs = append(retrySyncs, dir.Name())
+ }
+ if dir.Name() == base {
+ baseSyncs++
+ if !failed && baseSyncs == 2 {
+ failed = true
+ return wantErr
+ }
+ }
+ return nil
+ }
+ t.Cleanup(func() { syncDirectory = old })
+
+ err := EnsureDir(target, 0o700, os.Getuid(), os.Getgid())
+ var durability *DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "mkdir" {
+ t.Fatalf("first EnsureDir error = %v, want mkdir DurabilityError", err)
+ }
+ if fi, statErr := os.Lstat(visible); statErr != nil || !fi.IsDir() {
+ t.Fatalf("intermediate directory is not visible after failed parent sync: info=%v err=%v", fi, statErr)
+ }
+ if _, statErr := os.Lstat(target); !os.IsNotExist(statErr) {
+ t.Fatalf("first EnsureDir continued after failed parent sync: %v", statErr)
+ }
+
+ inRetry = true
+ if err := EnsureDir(target, 0o700, os.Getuid(), os.Getgid()); err != nil {
+ t.Fatalf("EnsureDir retry: %v", err)
+ }
+ if len(retrySyncs) < 2 || retrySyncs[0] != visible || retrySyncs[1] != base {
+ t.Fatalf("retry syncs = %v, want visible inode %s then parent %s before extending it", retrySyncs, visible, base)
+ }
+}
+
+func TestEnsureDirRetryAfterVisibleDirectorySyncFailurePreservesSyncOrder(t *testing.T) {
+ base := t.TempDir()
+ visible := filepath.Join(base, "visible")
+ target := filepath.Join(visible, "leaf")
+ wantErr := errors.New("forced visible-directory sync failure")
+ old := syncDirectory
+ failed := false
+ inRetry := false
+ var retrySyncs []string
+ syncDirectory = func(dir *os.File) error {
+ if inRetry {
+ retrySyncs = append(retrySyncs, dir.Name())
+ }
+ if !failed && dir.Name() == visible {
+ failed = true
+ return wantErr
+ }
+ return nil
+ }
+ t.Cleanup(func() { syncDirectory = old })
+
+ err := EnsureDir(target, 0o700, os.Getuid(), os.Getgid())
+ var durability *DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "directory metadata update" {
+ t.Fatalf("first EnsureDir error = %v, want directory metadata DurabilityError", err)
+ }
+ if fi, statErr := os.Lstat(visible); statErr != nil || !fi.IsDir() {
+ t.Fatalf("intermediate directory is not visible after its failed sync: info=%v err=%v", fi, statErr)
+ }
+ if _, statErr := os.Lstat(target); !os.IsNotExist(statErr) {
+ t.Fatalf("first EnsureDir continued after child sync failure: %v", statErr)
+ }
+
+ inRetry = true
+ if err := EnsureDir(target, 0o700, os.Getuid(), os.Getgid()); err != nil {
+ t.Fatalf("EnsureDir retry: %v", err)
+ }
+ if len(retrySyncs) < 2 || retrySyncs[0] != visible || retrySyncs[1] != base {
+ t.Fatalf("retry syncs = %v, want failed visible inode %s before parent %s", retrySyncs, visible, base)
+ }
+}
diff --git a/internal/lifecycle/lock.go b/internal/lifecycle/lock.go
index fc81e8e..21639a4 100644
--- a/internal/lifecycle/lock.go
+++ b/internal/lifecycle/lock.go
@@ -3,6 +3,7 @@ package lifecycle
import (
"bytes"
+ "errors"
"fmt"
"io"
"os"
@@ -11,6 +12,11 @@ import (
"github.com/xxvcc/linux-temp-admin/internal/fsutil"
)
+// ErrBusy is returned by TryAcquire when another process owns the lifecycle
+// lock. Callers that cannot safely queue behind an in-flight mutation can use it
+// to abandon that operation without changing the host.
+var ErrBusy = errors.New("lifecycle lock is busy")
+
// Lock is an advisory process lock. Path must live outside removable application
// state so uninstall cannot unlink a held lock and let another process lock a new
// inode at the same pathname.
@@ -28,6 +34,29 @@ func (l *Lock) tombstonePath() string { return l.Path + ".uninstalled" }
// Acquire blocks until the lifecycle lock is held. The returned release function
// must be called exactly once.
func (l *Lock) Acquire() (func() error, error) {
+ return l.acquire(syscall.LOCK_EX)
+}
+
+// AcquireShared blocks until a shared lifecycle lock is held. It is used by
+// operations that may run one at a time under a separate global lock but must all
+// finish before an exclusive same-object replacement begins.
+func (l *Lock) AcquireShared() (func() error, error) {
+ return l.acquire(syscall.LOCK_SH)
+}
+
+// TryAcquire acquires the lifecycle lock without waiting. It returns ErrBusy
+// when another process owns the lock; every other validation and I/O error is
+// reported exactly as Acquire reports it.
+func (l *Lock) TryAcquire() (func() error, error) {
+ return l.acquire(syscall.LOCK_EX | syscall.LOCK_NB)
+}
+
+// TryAcquireShared acquires a shared lifecycle lock without waiting.
+func (l *Lock) TryAcquireShared() (func() error, error) {
+ return l.acquire(syscall.LOCK_SH | syscall.LOCK_NB)
+}
+
+func (l *Lock) acquire(operation int) (func() error, error) {
if l == nil || l.Path == "" {
return func() error { return nil }, nil
}
@@ -53,7 +82,10 @@ func (l *Lock) Acquire() (func() error, error) {
if fi.Mode().Perm()&0o077 != 0 {
return fail(fmt.Errorf("lifecycle lock %s is group/world accessible (mode %o)", l.Path, fi.Mode().Perm()))
}
- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
+ if err := syscall.Flock(int(f.Fd()), operation); err != nil {
+ if operation&syscall.LOCK_NB != 0 && (errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN)) {
+ return fail(ErrBusy)
+ }
return fail(fmt.Errorf("flock lifecycle: %w", err))
}
released := false
diff --git a/internal/lifecycle/lock_test.go b/internal/lifecycle/lock_test.go
index b018a6b..e401f45 100644
--- a/internal/lifecycle/lock_test.go
+++ b/internal/lifecycle/lock_test.go
@@ -1,6 +1,7 @@
package lifecycle
import (
+ "errors"
"os"
"path/filepath"
"strings"
@@ -8,6 +9,64 @@ import (
"time"
)
+func TestTryAcquireReportsBusyWithoutWaiting(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "lifecycle.lock")
+ first, err := New(path).Acquire()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := first(); err != nil {
+ t.Error(err)
+ }
+ }()
+
+ started := time.Now()
+ if release, err := New(path).TryAcquire(); !errors.Is(err, ErrBusy) || release != nil {
+ t.Fatalf("TryAcquire while held returned release=%t, err=%v; want release=false, ErrBusy", release != nil, err)
+ }
+ if elapsed := time.Since(started); elapsed > time.Second {
+ t.Fatalf("TryAcquire blocked for %s", elapsed)
+ }
+}
+
+func TestSharedLocksDistinguishReadersFromExclusiveReplacement(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "lifecycle.lock")
+ firstReader, err := New(path).AcquireShared()
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondReader, err := New(path).TryAcquireShared()
+ if err != nil {
+ _ = firstReader()
+ t.Fatalf("second shared acquisition failed: %v", err)
+ }
+ if release, err := New(path).TryAcquire(); !errors.Is(err, ErrBusy) || release != nil {
+ _ = secondReader()
+ _ = firstReader()
+ t.Fatalf("exclusive acquisition with readers returned release=%t, err=%v; want busy", release != nil, err)
+ }
+ if err := secondReader(); err != nil {
+ _ = firstReader()
+ t.Fatal(err)
+ }
+ if err := firstReader(); err != nil {
+ t.Fatal(err)
+ }
+
+ exclusive, err := New(path).Acquire()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if release, err := New(path).TryAcquireShared(); !errors.Is(err, ErrBusy) || release != nil {
+ _ = exclusive()
+ t.Fatalf("shared acquisition with writer returned release=%t, err=%v; want busy", release != nil, err)
+ }
+ if err := exclusive(); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestLockSerializesIndependentCallers(t *testing.T) {
path := filepath.Join(t.TempDir(), "lifecycle.lock")
first, err := New(path).Acquire()
diff --git a/internal/mountinfo/mountinfo.go b/internal/mountinfo/mountinfo.go
new file mode 100644
index 0000000..cc41f2f
--- /dev/null
+++ b/internal/mountinfo/mountinfo.go
@@ -0,0 +1,123 @@
+// Package mountinfo checks whether a recursive removal would cross a mount
+// boundary described by Linux /proc mountinfo.
+package mountinfo
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// RefuseUnder rejects root when it is itself a mountpoint or contains one.
+// Recursive deletion must not cross into an independently mounted filesystem.
+func RefuseUnder(root string) error {
+ clean := filepath.Clean(root)
+ if root == "" || !filepath.IsAbs(root) || clean != root {
+ return fmt.Errorf("invalid recursive-removal root %q", root)
+ }
+ f, err := os.Open("/proc/self/mountinfo")
+ if err != nil {
+ return fmt.Errorf("cannot inspect mount boundaries: %w", err)
+ }
+ defer f.Close()
+ return RejectUnder(f, clean)
+}
+
+// RejectUnder applies the mount-boundary check to a supplied mountinfo stream.
+func RejectUnder(r io.Reader, root string) error {
+ cleanRoot := filepath.Clean(root)
+ if root == "" || !filepath.IsAbs(root) || cleanRoot != root || cleanRoot == string(filepath.Separator) {
+ return fmt.Errorf("invalid recursive-removal root %q", root)
+ }
+ if r == nil {
+ return fmt.Errorf("nil mountinfo reader")
+ }
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 4096), 1024*1024)
+ seen := false
+ for sc.Scan() {
+ seen = true
+ fields := strings.Fields(sc.Text())
+ if len(fields) < 10 {
+ return fmt.Errorf("malformed mountinfo line")
+ }
+ if _, err := strconv.ParseUint(fields[0], 10, 64); err != nil {
+ return fmt.Errorf("malformed mountinfo mount id %q", fields[0])
+ }
+ if _, err := strconv.ParseUint(fields[1], 10, 64); err != nil {
+ return fmt.Errorf("malformed mountinfo parent id %q", fields[1])
+ }
+ device := strings.Split(fields[2], ":")
+ if len(device) != 2 {
+ return fmt.Errorf("malformed mountinfo device %q", fields[2])
+ }
+ for _, number := range device {
+ if _, err := strconv.ParseUint(number, 10, 32); err != nil {
+ return fmt.Errorf("malformed mountinfo device %q", fields[2])
+ }
+ }
+ separator := -1
+ for i := 6; i < len(fields); i++ {
+ if fields[i] == "-" {
+ separator = i
+ break
+ }
+ }
+ if separator < 6 || separator+3 >= len(fields) {
+ return fmt.Errorf("malformed mountinfo separator")
+ }
+ if _, err := unescapePath(fields[3]); err != nil {
+ return fmt.Errorf("malformed mountinfo root %q", fields[3])
+ }
+ mountpoint, err := unescapePath(fields[4])
+ if err != nil || !canonicalAbsolute(mountpoint) {
+ return fmt.Errorf("malformed mountinfo mountpoint %q", fields[4])
+ }
+ rel, err := filepath.Rel(cleanRoot, mountpoint)
+ if err != nil {
+ return fmt.Errorf("compare mountpoint %q: %w", mountpoint, err)
+ }
+ if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
+ return fmt.Errorf("refusing recursive removal across mountpoint %s", mountpoint)
+ }
+ }
+ if err := sc.Err(); err != nil {
+ return fmt.Errorf("read mount boundaries: %w", err)
+ }
+ if !seen {
+ return fmt.Errorf("empty mountinfo stream")
+ }
+ return nil
+}
+
+func canonicalAbsolute(path string) bool {
+ return path != "" && filepath.IsAbs(path) && filepath.Clean(path) == path
+}
+
+func unescapePath(value string) (string, error) {
+ var out strings.Builder
+ for i := 0; i < len(value); i++ {
+ if value[i] != '\\' {
+ out.WriteByte(value[i])
+ continue
+ }
+ if i+3 >= len(value) {
+ return "", fmt.Errorf("malformed mountinfo escape in %q", value)
+ }
+ escape := value[i+1 : i+4]
+ if escape != "040" && escape != "011" && escape != "012" && escape != "134" {
+ return "", fmt.Errorf("malformed mountinfo escape in %q", value)
+ }
+ n, err := strconv.ParseUint(escape, 8, 8)
+ if err != nil {
+ return "", fmt.Errorf("malformed mountinfo escape in %q", value)
+ }
+ out.WriteByte(byte(n))
+ i += 3
+ }
+ return out.String(), nil
+}
diff --git a/internal/mountinfo/mountinfo_test.go b/internal/mountinfo/mountinfo_test.go
new file mode 100644
index 0000000..e445ab1
--- /dev/null
+++ b/internal/mountinfo/mountinfo_test.go
@@ -0,0 +1,55 @@
+package mountinfo
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRejectUnder(t *testing.T) {
+ base := "28 1 254:4 / / rw,relatime - ext4 /dev/root rw\n"
+ for _, line := range []string{
+ "40 28 0:40 / /home/xxvcc-u rw - tmpfs tmpfs rw\n",
+ "41 28 0:41 / /home/xxvcc-u/nested rw - tmpfs tmpfs rw\n",
+ "42 28 0:42 / /home/xxvcc-u/with\\040space rw - tmpfs tmpfs rw\n",
+ } {
+ if err := RejectUnder(strings.NewReader(base+line), "/home/xxvcc-u"); err == nil {
+ t.Fatalf("mountinfo entry was accepted: %q", line)
+ }
+ }
+ outside := base + "43 28 0:43 / /home/xxvcc-u-old rw - tmpfs tmpfs rw\n"
+ if err := RejectUnder(strings.NewReader(outside), "/home/xxvcc-u"); err != nil {
+ t.Fatalf("outside mount rejected: %v", err)
+ }
+}
+
+func TestRejectUnderFailsClosedOnMalformedInput(t *testing.T) {
+ for _, input := range []string{
+ "",
+ "short\n",
+ "1 2 3 4 /bad\\zzzz rest\n",
+ "1 2 0:1 / /home/xxvcc-u rw ext4 /dev/root rw extra\n",
+ "1 2 0:1 / relative rw - ext4 /dev/root rw\n",
+ "1 2 0:1 / /home/../home/xxvcc-u rw - ext4 /dev/root rw\n",
+ "1 2 0:1 / /home/xxvcc-u\\057escape rw - ext4 /dev/root rw\n",
+ } {
+ if err := RejectUnder(strings.NewReader(input), "/home/xxvcc-u"); err == nil {
+ t.Fatalf("malformed mountinfo was accepted: %q", input)
+ }
+ }
+ validNSFS := "1 2 0:4 net:[4026532381] /run/netns/test rw - nsfs nsfs rw\n"
+ if err := RejectUnder(strings.NewReader(validNSFS), "/home/xxvcc-u"); err != nil {
+ t.Fatalf("valid non-path mount root rejected: %v", err)
+ }
+ if err := RejectUnder(nil, "/home/xxvcc-u"); err == nil {
+ t.Fatal("nil mountinfo reader was accepted")
+ }
+}
+
+func TestRejectUnderRejectsUnsafeRoot(t *testing.T) {
+ valid := "1 2 0:1 / / rw - ext4 /dev/root rw\n"
+ for _, root := range []string{"", "relative", "/", "/home/../home/xxvcc-u", "/home/xxvcc-u/"} {
+ if err := RejectUnder(strings.NewReader(valid), root); err == nil {
+ t.Fatalf("unsafe root %q was accepted", root)
+ }
+ }
+}
diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go
index 21f8de5..07bfc66 100644
--- a/internal/netdetect/netdetect.go
+++ b/internal/netdetect/netdetect.go
@@ -48,12 +48,11 @@ func New() *Detector {
// Never auto-follow redirects for a metadata/echo probe.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
- // Link-local literals only. A DNS-named endpoint (Tencent publishes
- // metadata.tencentyun.com for this) would emit a resolver query, which breaks
- // the promise that these probes never leave the host or its link — and hands
- // anyone who can answer that query the ability to seed the Host the invite
- // hands out. 169.254.169.254 is Tencent's documented address for the same
- // service, so nothing is lost by naming it numerically.
+ // Fixed numeric metadata endpoints only. This avoids DNS, redirects, and
+ // environment proxies, but it does not imply that every provider handles the
+ // request on the host's immediate link: 100.100.100.200 is shared address
+ // space and may traverse the local or cloud-provider network. Tencent also
+ // documents 169.254.169.254, so no DNS-named metadata endpoint is needed.
MetadataServices: []string{
"http://169.254.169.254/latest/meta-data/public-ipv4",
"http://100.100.100.200/latest/meta-data/eipv4",
@@ -88,10 +87,10 @@ func (d *Detector) fetch(ctx context.Context, url string) (string, error) {
return strings.TrimSpace(s), nil
}
-// LocalPublicIP tries the sources that never leave this host or its link — cloud
-// metadata (IPv4 only; see the Detector doc) then local interface addresses — and
-// returns the first routable public address found. perReq bounds each metadata
-// request.
+// LocalPublicIP tries fixed-address cloud metadata (IPv4 only; see the Detector
+// doc) and then local interface addresses. Metadata requests avoid DNS, redirects,
+// and environment proxies, but may traverse the local or cloud-provider network.
+// perReq bounds each metadata request.
//
// IPv4 is preferred over IPv6: a dual-stack box hands out its v4 address (the
// more universally reachable one), and a v6-only box, whose interfaces carry no
diff --git a/internal/registry/record.go b/internal/registry/record.go
index 8cb8cd3..487dc7a 100644
--- a/internal/registry/record.go
+++ b/internal/registry/record.go
@@ -14,16 +14,19 @@ import (
"github.com/xxvcc/linux-temp-admin/internal/validate"
)
-// Header is the current registry schema. v3 makes the identity and pending
-// columns mandatory so an older writer cannot silently discard them.
-const Header = "# linux-temp-admin registry v3"
+// Header is the current registry schema. v4 adds a durable deletion phase so a
+// failed post-userdel mail cleanup can be resumed without treating every stale
+// row as authority to remove name-scoped data.
+const Header = "# linux-temp-admin registry v4"
const legacyHeaderV2 = "# linux-temp-admin registry v2"
+const legacyHeaderV3 = "# linux-temp-admin registry v3"
const (
legacyFieldCount = 9
legacyMaxFieldCount = 11
- currentFieldCount = 13
+ legacyV3FieldCount = 13
+ currentFieldCount = 14
)
// Record is one managed temporary account.
@@ -53,9 +56,19 @@ type Record struct {
// a generation column: released v2 accounts used one shared fixed marker.
IdentityBound bool
// Pending marks a creation intent written before useradd. It is cleared only
- // after the new account's UID has been read and durably recorded. A live account
- // named by a pending row has no proven identity and must never be auto-deleted.
+ // after the new account's UID has been read and durably recorded. A pending row
+ // alone cannot prove the identity of a live same-name account and must never
+ // authorize unattended deletion; the creating process separately retains its
+ // complete passwd snapshot for an immediate rollback.
Pending bool
+ // DeletionStarted is written after the destructive identity policy and final
+ // quiescence checks, immediately before userdel. If the helper removes the
+ // account but the final mail-spool sweep fails, this root-owned phase witness
+ // authorizes a later retry of that narrow cleanup. A generation-bound row keeps
+ // its exact identity; legacy, unregistered, and rollback-pending paths become
+ // non-pending UID-only recovery rows. An ordinary stale row for an account
+ // removed outside the tool leaves this false.
+ DeletionStarted bool
}
var fieldSanitizer = strings.NewReplacer("\t", " ", "\r", " ", "\n", " ")
@@ -70,11 +83,12 @@ func boolYN(b bool) string {
return "no"
}
-// Column indexes are retained while migrating deployed v2 rows to v3.
+// Column indexes are retained while migrating deployed v2/v3 rows to v4.
const uidField = 9
const generationField = 10
const pendingField = 11
const identityBoundField = 12
+const deletionStartedField = 13
// TSV renders the record as one tab-separated line (no trailing newline).
func (r Record) TSV() string {
@@ -88,16 +102,17 @@ func (r Record) TSV() string {
sanitize(r.Fingerprint),
boolYN(r.AutoRevoke),
sanitize(r.AutoUnit),
- strconv.Itoa(r.UID), // appended; older builds ignore this trailing field
+ strconv.Itoa(r.UID), // mandatory since v3; legacy v2 rows are migrated on load
sanitize(r.Generation),
boolYN(r.Pending),
boolYN(r.IdentityBound),
+ boolYN(r.DeletionStarted),
}, "\t")
}
// ParseLine parses a current-schema registry line. It returns ok=false only for
// the exact current header and blank lines. Every other non-empty line, including
-// one beginning with '#', must be a valid 13-column record or is corruption.
+// one beginning with '#', must be a valid 14-column record or is corruption.
func ParseLine(line string) (Record, bool, error) {
if line == "" || line == Header {
return Record{}, false, nil
@@ -112,6 +127,13 @@ func parseLegacyV2Line(line string) (Record, bool, error) {
return parseFields(line, legacyFieldCount, legacyMaxFieldCount)
}
+func parseLegacyV3Line(line string) (Record, bool, error) {
+ if line == "" {
+ return Record{}, false, nil
+ }
+ return parseFields(line, legacyV3FieldCount, legacyV3FieldCount)
+}
+
func parseFields(line string, minFields, maxFields int) (Record, bool, error) {
f := strings.Split(line, "\t")
if len(f) < minFields || len(f) > maxFields {
@@ -124,7 +146,7 @@ func parseFields(line string, minFields, maxFields int) (Record, bool, error) {
return Record{}, false, fmt.Errorf("invalid username %q", f[0])
}
port, err := strconv.Atoi(f[5])
- if err != nil || !validate.Port(port) {
+ if err != nil {
return Record{}, false, fmt.Errorf("invalid port %q", f[5])
}
if (f[3] != "yes" && f[3] != "no") || (f[7] != "yes" && f[7] != "no") {
@@ -165,11 +187,32 @@ func parseFields(line string, minFields, maxFields int) (Record, bool, error) {
}
rec.IdentityBound = f[identityBoundField] == "yes"
}
+ if len(f) > deletionStartedField {
+ if f[deletionStartedField] != "yes" && f[deletionStartedField] != "no" {
+ return Record{}, false, fmt.Errorf("invalid deletion-started field %q", f[deletionStartedField])
+ }
+ rec.DeletionStarted = f[deletionStartedField] == "yes"
+ }
+ // UID-only recovery rows created for an unregistered account have no honest
+ // endpoint metadata to preserve. Port 0 is reserved for exactly that state;
+ // every ordinary and migrated account row still requires a real SSH port.
+ if !validate.Port(rec.Port) && !(rec.DeletionStarted && !rec.IdentityBound && rec.Port == 0) {
+ return Record{}, false, fmt.Errorf("invalid port %q", f[5])
+ }
if rec.IdentityBound && !validate.Generation(rec.Generation) {
return Record{}, false, fmt.Errorf("identity-bound record has no valid generation")
}
if rec.IdentityBound && !rec.Pending && !validate.AccountID(rec.UID) {
return Record{}, false, fmt.Errorf("completed identity-bound record has no valid uid")
}
+ if rec.DeletionStarted && !validate.AccountID(rec.UID) {
+ return Record{}, false, fmt.Errorf("deletion-started record has no valid uid")
+ }
+ if rec.DeletionStarted && rec.Pending {
+ return Record{}, false, fmt.Errorf("deletion-started record cannot remain pending")
+ }
+ if rec.DeletionStarted && !rec.IdentityBound && rec.Generation != "" {
+ return Record{}, false, fmt.Errorf("uid-only deletion-started record carries a generation")
+ }
return rec, true, nil
}
diff --git a/internal/registry/record_test.go b/internal/registry/record_test.go
index 6cec2f4..ed921ff 100644
--- a/internal/registry/record_test.go
+++ b/internal/registry/record_test.go
@@ -4,23 +4,33 @@ import (
"strconv"
"strings"
"testing"
+
+ "github.com/xxvcc/linux-temp-admin/internal/config"
)
+func TestHeaderMatchesConfiguredSchema(t *testing.T) {
+ want := "# linux-temp-admin registry v" + strconv.Itoa(config.RegistrySchema)
+ if Header != want {
+ t.Fatalf("registry header = %q, want %q", Header, want)
+ }
+}
+
func TestRoundTrip(t *testing.T) {
in := Record{
- User: "xxvcc-a1b2c3",
- Created: "2026-07-07 12:00:00 UTC",
- Expires: "2026-07-08 12:00:00 UTC",
- Sudo: true,
- Host: "server-1.example.com",
- Port: 22,
- Fingerprint: "SHA256:abcdef",
- AutoRevoke: true,
- AutoUnit: "linux-temp-admin-v2-revoke-xxvcc-a1b2c3",
- UID: 1001,
- Generation: "0123456789abcdef0123456789abcdef",
- IdentityBound: true,
- Pending: true,
+ User: "xxvcc-a1b2c3",
+ Created: "2026-07-07 12:00:00 UTC",
+ Expires: "2026-07-08 12:00:00 UTC",
+ Sudo: true,
+ Host: "server-1.example.com",
+ Port: 22,
+ Fingerprint: "SHA256:abcdef",
+ AutoRevoke: true,
+ AutoUnit: "linux-temp-admin-v2-revoke-xxvcc-a1b2c3",
+ UID: 1001,
+ Generation: "0123456789abcdef0123456789abcdef",
+ IdentityBound: true,
+ Pending: false,
+ DeletionStarted: true,
}
line := in.TSV()
if strings.Contains(line, "\n") {
@@ -65,7 +75,7 @@ func TestParseLineRejectsNonRecords(t *testing.T) {
t.Errorf("ParseLine(%q) = ok=%v err=%v, want ignored", line, ok, err)
}
}
- for _, line := range []string{"too\tfew\tfields", "# comment", legacyHeaderV2} {
+ for _, line := range []string{"too\tfew\tfields", "# comment", legacyHeaderV2, legacyHeaderV3} {
if _, _, err := ParseLine(line); err == nil {
t.Errorf("malformed/non-current line %q must return an error", line)
}
@@ -76,12 +86,13 @@ func TestParseLineRejectsCorruptFields(t *testing.T) {
valid := strings.Split(Record{User: "xxvcc-a1", Port: 22}.TSV(), "\t")
tests := map[string][]string{}
for name, mutate := range map[string]func([]string){
- "boolean": func(f []string) { f[3] = "maybe" },
- "port": func(f []string) { f[5] = "not-a-port" },
- "uid": func(f []string) { f[9] = "broken" },
- "generation": func(f []string) { f[10] = "too-short" },
- "pending": func(f []string) { f[11] = "maybe" },
- "identity bound": func(f []string) { f[12] = "maybe" },
+ "boolean": func(f []string) { f[3] = "maybe" },
+ "port": func(f []string) { f[5] = "not-a-port" },
+ "uid": func(f []string) { f[9] = "broken" },
+ "generation": func(f []string) { f[10] = "too-short" },
+ "pending": func(f []string) { f[11] = "maybe" },
+ "identity bound": func(f []string) { f[12] = "maybe" },
+ "deletion started": func(f []string) { f[13] = "maybe" },
} {
fields := append([]string(nil), valid...)
mutate(fields)
@@ -103,6 +114,36 @@ func TestParseLineRequiresGenerationForBoundIdentity(t *testing.T) {
}
}
+func TestParseLineRequiresUIDAndNonPendingStateForDeletionStarted(t *testing.T) {
+ for _, rec := range []Record{
+ {User: "xxvcc-a1", Port: 22, DeletionStarted: true},
+ {User: "xxvcc-a1", Port: 22, UID: 1001, Pending: true, DeletionStarted: true},
+ {User: "xxvcc-a1", Port: 22, UID: 1001, Generation: "0123456789abcdef0123456789abcdef", DeletionStarted: true},
+ } {
+ if _, _, err := ParseLine(rec.TSV()); err == nil || !strings.Contains(err.Error(), "deletion-started") {
+ t.Fatalf("ParseLine(%+v) error = %v, want incomplete deletion witness refusal", rec, err)
+ }
+ }
+ legacy := Record{User: "xxvcc-a1", Port: 22, UID: 1001, DeletionStarted: true}
+ if got, ok, err := ParseLine(legacy.TSV()); err != nil || !ok || got != legacy {
+ t.Fatalf("legacy deletion witness round trip = ok %v record %+v err %v", ok, got, err)
+ }
+ unregistered := Record{User: "xxvcc-a1", UID: 1001, DeletionStarted: true}
+ if got, ok, err := ParseLine(unregistered.TSV()); err != nil || !ok || got != unregistered {
+ t.Fatalf("unregistered deletion witness round trip = ok %v record %+v err %v", ok, got, err)
+ }
+ if _, _, err := ParseLine((Record{User: "xxvcc-a1", Port: 0}).TSV()); err == nil {
+ t.Fatal("ordinary record accepted recovery-only port 0")
+ }
+ boundWithoutPort := Record{
+ User: "xxvcc-a1", UID: 1001, Generation: "0123456789abcdef0123456789abcdef",
+ IdentityBound: true, DeletionStarted: true,
+ }
+ if _, _, err := ParseLine(boundWithoutPort.TSV()); err == nil || !strings.Contains(err.Error(), "invalid port") {
+ t.Fatalf("generation-bound recovery with port 0 error = %v, want invalid port refusal", err)
+ }
+}
+
func TestParseLineRejectsReservedLinuxUID(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot represent the reserved uint32 uid sentinel")
@@ -141,19 +182,42 @@ func TestParseLineAcceptsLegacyNineFieldRow(t *testing.T) {
}
}
-// TestV3SchemaStopsV2Writers pins the forward-compatibility boundary. v3 rows
-// carry fields a v2 writer would discard, so the header and exact row width must
-// make that writer fail closed instead of accepting and truncating them.
-func TestV3SchemaStopsV2Writers(t *testing.T) {
+func TestParseLegacyV3RowDefaultsDeletionPhase(t *testing.T) {
+ current := Record{
+ User: "xxvcc-a1", Port: 22, UID: 1001,
+ Generation: "0123456789abcdef0123456789abcdef", IdentityBound: true,
+ }
+ fields := strings.Split(current.TSV(), "\t")
+ legacy := strings.Join(fields[:legacyV3FieldCount], "\t")
+ got, ok, err := parseLegacyV3Line(legacy)
+ if err != nil || !ok {
+ t.Fatalf("parseLegacyV3Line ok=%v err=%v", ok, err)
+ }
+ if got.User != current.User || got.UID != current.UID || got.Generation != current.Generation ||
+ !got.IdentityBound || got.Pending || got.DeletionStarted {
+ t.Fatalf("legacy v3 row parsed incorrectly: %+v", got)
+ }
+ if _, _, err := ParseLine(legacy); err == nil {
+ t.Fatal("v4 parser accepted a released 13-column v3 row without migration")
+ }
+}
+
+// TestV4SchemaStopsOlderWriters pins the forward-compatibility boundary. v4
+// rows carry a field older writers would discard, so the header and exact width
+// make those writers fail closed instead of silently truncating recovery state.
+func TestV4SchemaStopsOlderWriters(t *testing.T) {
line := Record{User: "xxvcc-a1", Port: 22, UID: 1001, AutoUnit: "u.timer", Pending: true}.TSV()
f := strings.Split(line, "\t")
- if Header == legacyHeaderV2 {
+ if Header == legacyHeaderV2 || Header == legacyHeaderV3 {
t.Fatal("current and legacy registry headers must differ")
}
if len(f) != currentFieldCount {
- t.Fatalf("v3 row has %d fields, want %d", len(f), currentFieldCount)
+ t.Fatalf("v4 row has %d fields, want %d", len(f), currentFieldCount)
}
if _, _, err := parseLegacyV2Line(line); err == nil {
- t.Fatal("v2 parser accepted a v3 row and could silently discard pending state")
+ t.Fatal("v2 parser accepted a v4 row and could silently discard state")
+ }
+ if _, _, err := parseLegacyV3Line(line); err == nil {
+ t.Fatal("v3 parser accepted a v4 row and could silently discard deletion state")
}
}
diff --git a/internal/registry/store.go b/internal/registry/store.go
index 4348ac2..fba26a9 100644
--- a/internal/registry/store.go
+++ b/internal/registry/store.go
@@ -4,17 +4,21 @@ import (
"fmt"
"io"
"os"
+ "path/filepath"
"strings"
"syscall"
"github.com/xxvcc/linux-temp-admin/internal/config"
"github.com/xxvcc/linux-temp-admin/internal/fsutil"
+ "github.com/xxvcc/linux-temp-admin/internal/validate"
+ "golang.org/x/sys/unix"
)
const maxRegistryBytes = int64(16 << 20)
-// Store is the flock-guarded, root-owned registry of managed accounts. Paths are
-// fields so tests can point them at a temporary directory.
+// Store is the flock-guarded, root-owned registry of managed accounts. Dir must
+// be absolute; File and Lock must be distinct direct children. Paths are fields
+// so tests can point the complete layout at a temporary directory.
type Store struct {
Dir string
File string
@@ -29,6 +33,9 @@ func Default() *Store {
// Init creates the registry directory (0700 root), the registry file (with the
// schema header if new), and the lock file, refusing any symlinked component.
func (s *Store) Init() error {
+ if err := s.validateLayout(); err != nil {
+ return err
+ }
if fi, err := os.Lstat(s.Dir); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("registry dir %s is a symlink", s.Dir)
@@ -42,28 +49,88 @@ func (s *Store) Init() error {
if err := fsutil.EnsureDir(s.Dir, 0o700, 0, 0); err != nil {
return err
}
- if err := fsutil.RootSafeDir(s.Dir); err != nil {
+ dir, err := os.OpenFile(s.Dir, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open registry dir: %w", err)
+ }
+ defer dir.Close()
+ if err := requireRootDirFD(s.Dir, dir, 0o700); err != nil {
return fmt.Errorf("registry dir unsafe: %w", err)
}
+
+ // The lock must be created in place. An atomic-write helper would rename a
+ // different inode over the pathname, allowing concurrent first-time Init calls
+ // to lock different files and enter the critical section together.
+ lock, err := openOrCreateLockAt(dir, filepath.Base(s.Lock), s.Lock)
+ if err != nil {
+ return err
+ }
+ defer lock.Close()
+ if err := requireRegularFD(s.Lock, lock); err != nil {
+ return err
+ }
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
+ return fmt.Errorf("flock registry: %w", err)
+ }
+ defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
+ if err := repairRootFileFD(s.Lock, lock); err != nil {
+ return err
+ }
+ if err := dir.Sync(); err != nil {
+ return &fsutil.DurabilityError{Operation: "registry lock directory entry", Err: err}
+ }
+
+ // Upgrade deployed registries only while holding their lock. New writes use a
+ // v4 header that older binaries reject, preventing them from dropping the
+ // deletion recovery phase during a delayed rewrite.
if err := ensureFile(s.File, []byte(Header+"\n")); err != nil {
return err
}
- if err := ensureFile(s.Lock, nil); err != nil {
+ recs, header, err := s.readAllWithHeader()
+ if err != nil {
return err
}
- // Upgrade a deployed v2 registry only while holding its lock. New writes use a
- // v3 header that old binaries reject, preventing them from dropping UID,
- // generation, or pending state during a delayed rewrite.
- return s.withLock(func() error {
- recs, header, err := s.readAllWithHeader()
- if err != nil {
- return err
- }
- if header == legacyHeaderV2 {
- return s.writeAll(recs)
+ if header == legacyHeaderV2 || header == legacyHeaderV3 {
+ return s.writeAll(recs)
+ }
+ return nil
+}
+
+func (s *Store) validateLayout() error {
+ if s == nil {
+ return fmt.Errorf("nil registry store")
+ }
+ dir := filepath.Clean(s.Dir)
+ if s.Dir == "" || !filepath.IsAbs(s.Dir) || dir != s.Dir || dir == string(filepath.Separator) {
+ return fmt.Errorf("unsafe registry directory %q", s.Dir)
+ }
+ for label, path := range map[string]string{"file": s.File, "lock": s.Lock} {
+ clean := filepath.Clean(path)
+ if path == "" || !filepath.IsAbs(path) || clean != path || filepath.Dir(clean) != dir || clean == dir {
+ return fmt.Errorf("registry %s %q must be a direct child of %s", label, path, dir)
}
- return nil
- })
+ }
+ if s.File == s.Lock {
+ return fmt.Errorf("registry file and lock must be different paths")
+ }
+ return nil
+}
+
+func openOrCreateLockAt(dir *os.File, name, path string) (*os.File, error) {
+ if dir == nil || name == "" || filepath.Base(name) != name || name == "." || name == ".." {
+ return nil, fmt.Errorf("unsafe registry lock name %q", name)
+ }
+ fd, err := unix.Openat(int(dir.Fd()), name,
+ unix.O_RDWR|unix.O_CREAT|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("open or create registry lock: %w", err)
+ }
+ f := os.NewFile(uintptr(fd), path)
+ if f == nil {
+ _ = unix.Close(fd)
+ return nil, fmt.Errorf("open or create registry lock: invalid file descriptor")
+ }
+ return f, nil
}
func ensureFile(path string, initial []byte) error {
@@ -75,6 +142,10 @@ func ensureFile(path string, initial []byte) error {
return err
}
defer f.Close()
+ return repairRootFileFD(path, f)
+}
+
+func repairRootFileFD(path string, f *os.File) error {
if err := requireRegularFD(path, f); err != nil {
return err
}
@@ -136,7 +207,7 @@ func (s *Store) readAllWithHeader() ([]Record, string, error) {
return nil, "", fmt.Errorf("registry exceeds %d bytes", maxRegistryBytes)
}
lines := strings.Split(string(b), "\n")
- if len(lines) == 0 || (lines[0] != Header && lines[0] != legacyHeaderV2) {
+ if len(lines) == 0 || (lines[0] != Header && lines[0] != legacyHeaderV3 && lines[0] != legacyHeaderV2) {
return nil, "", fmt.Errorf("registry header is missing or unsupported")
}
header := lines[0]
@@ -144,15 +215,18 @@ func (s *Store) readAllWithHeader() ([]Record, string, error) {
seenUsers := make(map[string]int)
for i, line := range lines[1:] {
lineNumber := i + 2
- if line == Header || line == legacyHeaderV2 {
+ if line == Header || line == legacyHeaderV3 || line == legacyHeaderV2 {
return nil, "", fmt.Errorf("registry line %d: duplicate schema header", lineNumber)
}
var r Record
var ok bool
var err error
- if header == Header {
+ switch header {
+ case Header:
r, ok, err = ParseLine(line)
- } else {
+ case legacyHeaderV3:
+ r, ok, err = parseLegacyV3Line(line)
+ case legacyHeaderV2:
r, ok, err = parseLegacyV2Line(line)
}
if err != nil {
@@ -180,6 +254,25 @@ func requireRegularFD(path string, f *os.File) error {
return nil
}
+func requireRootDirFD(path string, f *os.File, mode os.FileMode) error {
+ fi, err := f.Stat()
+ if err != nil {
+ return fmt.Errorf("stat %s: %w", path, err)
+ }
+ if !fi.IsDir() {
+ return fmt.Errorf("%s is not a directory", path)
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok {
+ return fmt.Errorf("cannot determine owner of %s", path)
+ }
+ if st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != mode.Perm() {
+ return fmt.Errorf("%s metadata is unsafe: owner %d:%d mode %o, want root:root %o",
+ path, st.Uid, st.Gid, fi.Mode().Perm(), mode.Perm())
+ }
+ return nil
+}
+
func requireRootFileFD(path string, f *os.File, mode os.FileMode) error {
if err := requireRegularFD(path, f); err != nil {
return err
@@ -216,7 +309,10 @@ func (s *Store) writeAll(recs []Record) error {
return fsutil.WriteRootFile(s.File, []byte(b.String()), 0o600)
}
-// Record upserts rec (replacing any existing entry for the same user).
+// Record upserts an ordinary creation/active record. A deletion recovery row is
+// immutable through this general API: callers must use BeginDeletion and
+// FinishDeletionRecovery so a new invite or routine update cannot erase the only
+// durable authority for post-userdel cleanup.
func (s *Store) Record(rec Record) error {
if _, ok, err := ParseLine(rec.TSV()); err != nil || !ok {
if err == nil {
@@ -224,6 +320,9 @@ func (s *Store) Record(rec Record) error {
}
return fmt.Errorf("invalid registry record: %w", err)
}
+ if rec.DeletionStarted {
+ return fmt.Errorf("deletion-started state requires BeginDeletion")
+ }
return s.withLock(func() error {
recs, err := s.readAll()
if err != nil {
@@ -231,16 +330,168 @@ func (s *Store) Record(rec Record) error {
}
out := recs[:0:0]
for _, r := range recs {
- if r.User != rec.User {
- out = append(out, r)
+ if r.User == rec.User {
+ if r.DeletionStarted {
+ return fmt.Errorf("registry record for %s is in deletion recovery", rec.User)
+ }
+ continue
}
+ out = append(out, r)
}
out = append(out, rec)
return s.writeAll(out)
})
}
-// Remove deletes the entry for user (no error if absent).
+// BeginDeletion durably enters the phase immediately before userdel. A non-empty
+// generation can only mark an existing, completed identity-bound row with the
+// same user, UID, and generation. An empty generation requests a UID-only
+// recovery witness: an existing legacy row is converted, an unregistered name is
+// inserted, and a rollback-pending row is deliberately stripped of pending and
+// generation authority. Repeating the exact same transition is harmless.
+func (s *Store) BeginDeletion(user string, uid int, generation string) error {
+ if err := validateDeletionIdentity(user, uid, generation); err != nil {
+ return err
+ }
+ return s.withLock(func() error {
+ recs, err := s.readAll()
+ if err != nil {
+ return err
+ }
+ out, changed, err := beginDeletionRecords(recs, user, uid, generation)
+ if err != nil || !changed {
+ return err
+ }
+ return s.writeAll(out)
+ })
+}
+
+func validateDeletionIdentity(user string, uid int, generation string) error {
+ if !validate.Username(user) || !validate.AccountID(uid) ||
+ (generation != "" && !validate.Generation(generation)) {
+ return fmt.Errorf("invalid deletion identity")
+ }
+ return nil
+}
+
+// beginDeletionRecords contains the state transition independently of filesystem
+// ownership and locking. The Store method above supplies both; keeping the
+// transition pure makes every identity mismatch testable without root.
+func beginDeletionRecords(recs []Record, user string, uid int, generation string) ([]Record, bool, error) {
+ if err := validateDeletionIdentity(user, uid, generation); err != nil {
+ return nil, false, err
+ }
+ out := append([]Record(nil), recs...)
+ for i := range out {
+ if out[i].User != user {
+ continue
+ }
+ current := out[i]
+ if current.DeletionStarted {
+ boundMatches := generation != "" && current.IdentityBound && current.Generation == generation
+ uidOnlyMatches := generation == "" && !current.IdentityBound && current.Generation == ""
+ if current.UID != uid || (!boundMatches && !uidOnlyMatches) {
+ return nil, false, fmt.Errorf("registry deletion recovery identity changed")
+ }
+ return out, false, nil
+ }
+
+ if generation != "" {
+ if current.Pending || !current.IdentityBound || current.UID != uid || current.Generation != generation {
+ return nil, false, fmt.Errorf("registry identity changed before deletion")
+ }
+ current.DeletionStarted = true
+ } else {
+ // A completed bound row must not be silently weakened. Pending rollback
+ // is the exception: after the caller has authorized userdel it becomes a
+ // recovery-only witness, so a crash cannot turn that pending intent into
+ // unattended live-account deletion authority.
+ if current.IdentityBound && !current.Pending {
+ return nil, false, fmt.Errorf("identity-bound registry row requires its generation")
+ }
+ if current.UID != 0 && current.UID != uid {
+ return nil, false, fmt.Errorf("registry UID changed before deletion")
+ }
+ current.UID = uid
+ current.Generation = ""
+ current.IdentityBound = false
+ current.Pending = false
+ current.DeletionStarted = true
+ }
+ if _, ok, err := ParseLine(current.TSV()); err != nil || !ok {
+ if err == nil {
+ err = fmt.Errorf("deletion transition did not produce a data row")
+ }
+ return nil, false, fmt.Errorf("invalid deletion transition: %w", err)
+ }
+ out[i] = current
+ return out, true, nil
+ }
+
+ if generation != "" {
+ return nil, false, fmt.Errorf("registry identity disappeared before deletion")
+ }
+ recovery := Record{User: user, UID: uid, DeletionStarted: true}
+ if _, ok, err := ParseLine(recovery.TSV()); err != nil || !ok {
+ if err == nil {
+ err = fmt.Errorf("deletion transition did not produce a data row")
+ }
+ return nil, false, fmt.Errorf("invalid deletion transition: %w", err)
+ }
+ return append(out, recovery), true, nil
+}
+
+// FinishDeletionRecovery removes only the exact row whose deletion phase was
+// durably started. Every row is bound by user+UID; an identity-bound row also
+// requires its exact generation, while a UID-only row requires generation to be
+// empty. Absence is an idempotent success and a different same-name row is never
+// removed.
+func (s *Store) FinishDeletionRecovery(user string, uid int, generation string) error {
+ if err := validateDeletionIdentity(user, uid, generation); err != nil {
+ return fmt.Errorf("invalid deletion recovery identity: %w", err)
+ }
+ absent, err := s.completelyAbsent()
+ if err != nil {
+ return err
+ }
+ if absent {
+ return nil
+ }
+ return s.withLock(func() error {
+ recs, err := s.readAll()
+ if err != nil {
+ return err
+ }
+ out, changed, err := finishDeletionRecoveryRecords(recs, user, uid, generation)
+ if err != nil || !changed {
+ return err
+ }
+ return s.writeAll(out)
+ })
+}
+
+func finishDeletionRecoveryRecords(recs []Record, user string, uid int, generation string) ([]Record, bool, error) {
+ if err := validateDeletionIdentity(user, uid, generation); err != nil {
+ return nil, false, fmt.Errorf("invalid deletion recovery identity: %w", err)
+ }
+ out := make([]Record, 0, len(recs))
+ for i, r := range recs {
+ if r.User != user {
+ out = append(out, r)
+ continue
+ }
+ boundMatches := generation != "" && r.IdentityBound && r.Generation == generation
+ uidOnlyMatches := generation == "" && !r.IdentityBound && r.Generation == ""
+ if !r.DeletionStarted || r.UID != uid || (!boundMatches && !uidOnlyMatches) {
+ return nil, false, fmt.Errorf("registry deletion recovery identity changed")
+ }
+ return append(out, recs[i+1:]...), true, nil
+ }
+ return append([]Record(nil), recs...), false, nil
+}
+
+// Remove deletes an ordinary entry for user (no error if absent). Recovery rows
+// require FinishDeletionRecovery and cannot be discarded by name alone.
func (s *Store) Remove(user string) error {
absent, err := s.completelyAbsent()
if err != nil {
@@ -258,6 +509,9 @@ func (s *Store) Remove(user string) error {
removed := false
for _, r := range recs {
if r.User == user {
+ if r.DeletionStarted {
+ return fmt.Errorf("registry record for %s is in deletion recovery", user)
+ }
removed = true
continue
}
@@ -319,11 +573,12 @@ func (s *Store) UnitFor(user string) (string, error) {
return "", nil
}
-// Compact removes entries whose account no longer exists, deciding under a single
-// held lock (re-checking existence inside it) so a concurrent recreate cannot
-// lose its fresh entry. exists reports whether an account is still present.
-// Returns the number of entries pruned.
-func (s *Store) Compact(exists func(user string) (bool, error)) (int, error) {
+// Compact removes ordinary entries whose account no longer exists, deciding
+// under one held lock so a concurrent recreate cannot lose its fresh entry.
+// Deletion recovery rows are retained without calling keep: that row is the
+// authority needed to finish post-userdel cleanup. Callers must not re-enter
+// Store methods from the callback. Returns the number pruned.
+func (s *Store) Compact(keep func(Record) (bool, error)) (int, error) {
absent, err := s.completelyAbsent()
if err != nil {
return 0, err
@@ -339,7 +594,11 @@ func (s *Store) Compact(exists func(user string) (bool, error)) (int, error) {
}
out := recs[:0:0]
for _, r := range recs {
- live, err := exists(r.User)
+ if r.DeletionStarted {
+ out = append(out, r)
+ continue
+ }
+ live, err := keep(r)
if err != nil {
return err
}
diff --git a/internal/registry/store_durability_root_test.go b/internal/registry/store_durability_root_test.go
index cd198b0..64c49e0 100644
--- a/internal/registry/store_durability_root_test.go
+++ b/internal/registry/store_durability_root_test.go
@@ -58,6 +58,50 @@ func TestEnsureFileSyncsRepairedMetadataAndReportsFailure(t *testing.T) {
}
}
+func TestOpenOrCreateLockAtUsesOneStableInode(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ dirFile, err := os.Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dirFile.Close()
+
+ path := filepath.Join(dir, "registry.lock")
+ first, err := openOrCreateLockAt(dirFile, filepath.Base(path), path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer first.Close()
+ second, err := openOrCreateLockAt(dirFile, filepath.Base(path), path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer second.Close()
+
+ firstStat := fiStatFD(t, first)
+ secondStat := fiStatFD(t, second)
+ if firstStat.Dev != secondStat.Dev || firstStat.Ino != secondStat.Ino {
+ t.Fatalf("concurrent lock opens resolved to different inodes: first=%d:%d second=%d:%d",
+ firstStat.Dev, firstStat.Ino, secondStat.Dev, secondStat.Ino)
+ }
+ if err := syscall.Flock(int(first.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ t.Fatalf("lock first fd: %v", err)
+ }
+ defer func() { _ = syscall.Flock(int(first.Fd()), syscall.LOCK_UN) }()
+ if err := syscall.Flock(int(second.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); !errors.Is(err, syscall.EWOULDBLOCK) {
+ t.Fatalf("second fd did not contend on the same lock inode: %v", err)
+ }
+}
+
func fiStat(t *testing.T, path string) *syscall.Stat_t {
t.Helper()
fi, err := os.Stat(path)
@@ -66,3 +110,12 @@ func fiStat(t *testing.T, path string) *syscall.Stat_t {
}
return fi.Sys().(*syscall.Stat_t)
}
+
+func fiStatFD(t *testing.T, f *os.File) *syscall.Stat_t {
+ t.Helper()
+ fi, err := f.Stat()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fi.Sys().(*syscall.Stat_t)
+}
diff --git a/internal/registry/store_root_test.go b/internal/registry/store_root_test.go
index 519dd74..74b8de3 100644
--- a/internal/registry/store_root_test.go
+++ b/internal/registry/store_root_test.go
@@ -62,7 +62,54 @@ func TestInitRepairsExistingRegistryFileAndLockMetadata(t *testing.T) {
}
}
-func TestInitMigratesV2RegistryToV3UnderLock(t *testing.T) {
+func TestConcurrentFirstInitUsesOneRegistryAndLock(t *testing.T) {
+ if os.Getuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := filepath.Join(t.TempDir(), "registry")
+ s := ®istry.Store{
+ Dir: dir,
+ File: filepath.Join(dir, "registry.tsv"),
+ Lock: filepath.Join(dir, "registry.lock"),
+ }
+
+ const workers = 32
+ start := make(chan struct{})
+ errs := make(chan error, workers)
+ var wg sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- s.Init()
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ if err != nil {
+ t.Fatalf("concurrent Init: %v", err)
+ }
+ }
+ recs, err := s.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(recs) != 0 {
+ t.Fatalf("new registry contains records: %+v", recs)
+ }
+ b, err := os.ReadFile(s.File)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(b) != registry.Header+"\n" {
+ t.Fatalf("concurrent Init registry = %q, want one schema header", b)
+ }
+}
+
+func TestInitMigratesV2RegistryToV4UnderLock(t *testing.T) {
if os.Getuid() != 0 {
t.Skip("requires root")
}
@@ -93,7 +140,7 @@ func TestInitMigratesV2RegistryToV3UnderLock(t *testing.T) {
t.Fatal(err)
}
if !strings.HasPrefix(string(b), registry.Header+"\n") {
- t.Fatalf("registry was not migrated to v3: %q", b)
+ t.Fatalf("registry was not migrated to v4: %q", b)
}
recs, err := s.List()
if err != nil || len(recs) != 1 || recs[0].UID != 1001 || recs[0].Pending || recs[0].IdentityBound {
@@ -101,6 +148,59 @@ func TestInitMigratesV2RegistryToV3UnderLock(t *testing.T) {
}
}
+func TestInitMigratesReleasedV3RowsToV4(t *testing.T) {
+ if os.Getuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock")}
+ const generation = "0123456789abcdef0123456789abcdef"
+ active := strings.Join([]string{
+ "xxvcc-v3a", "2026-07-07 12:00:00 UTC", "2026-07-08 12:00:00 UTC",
+ "yes", "203.0.113.5", "22", "SHA256:active", "yes", "active.timer",
+ "1001", generation, "no", "yes",
+ }, "\t")
+ pending := strings.Join([]string{
+ "xxvcc-v3p", "2026-07-07 13:00:00 UTC", "2026-07-08 13:00:00 UTC",
+ "no", "203.0.113.6", "2222", "SHA256:pending", "no", "",
+ "0", generation, "yes", "yes",
+ }, "\t")
+ if err := os.WriteFile(s.File, []byte("# linux-temp-admin registry v3\n"+active+"\n"+pending+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(s.Lock, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Init(); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(s.File)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(string(b), registry.Header+"\n") {
+ t.Fatalf("registry was not migrated to v4: %q", b)
+ }
+ recs, err := s.List()
+ if err != nil || len(recs) != 2 {
+ t.Fatalf("migrated records=%+v err=%v", recs, err)
+ }
+ if recs[0].User != "xxvcc-v3a" || recs[0].UID != 1001 || recs[0].Generation != generation ||
+ recs[0].Pending || !recs[0].IdentityBound || recs[0].DeletionStarted || recs[0].AutoUnit != "active.timer" {
+ t.Fatalf("active v3 row changed during migration: %+v", recs[0])
+ }
+ if recs[1].User != "xxvcc-v3p" || recs[1].UID != 0 || recs[1].Generation != generation ||
+ !recs[1].Pending || !recs[1].IdentityBound || recs[1].DeletionStarted || recs[1].Port != 2222 {
+ t.Fatalf("pending v3 row changed during migration: %+v", recs[1])
+ }
+}
+
func TestInitRejectsExistingNonRegularRegistryFiles(t *testing.T) {
if os.Getuid() != 0 {
t.Skip("requires root")
@@ -121,6 +221,42 @@ func TestInitRejectsExistingNonRegularRegistryFiles(t *testing.T) {
}
}
+func TestInitRejectsRegistryFileOutsideDedicatedDirectoryWithoutMutation(t *testing.T) {
+ if os.Getuid() != 0 {
+ t.Skip("requires root")
+ }
+ dir := t.TempDir()
+ if err := os.Chown(dir, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ victim := filepath.Join(t.TempDir(), "victim")
+ want := []byte("do not touch\n")
+ if err := os.WriteFile(victim, want, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := ®istry.Store{Dir: dir, File: victim, Lock: filepath.Join(dir, "registry.lock")}
+ if err := s.Init(); err == nil {
+ t.Fatal("Init accepted a registry file outside its dedicated directory")
+ }
+ got, err := os.ReadFile(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("outside file content changed: %q", got)
+ }
+ fi, err := os.Stat(victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o644 {
+ t.Fatalf("outside file mode changed to %o", fi.Mode().Perm())
+ }
+}
+
func TestStoreRecordUpsertRemove(t *testing.T) {
s := newStore(t)
rec := registry.Record{User: "xxvcc-a1", Host: "h", Port: 22, Sudo: true, AutoRevoke: true, AutoUnit: "u"}
@@ -160,7 +296,7 @@ func TestStoreCompact(t *testing.T) {
t.Fatal(err)
}
}
- removed, err := s.Compact(func(user string) (bool, error) { return user == "xxvcc-live", nil })
+ removed, err := s.Compact(func(rec registry.Record) (bool, error) { return rec.User == "xxvcc-live", nil })
if err != nil {
t.Fatal(err)
}
@@ -175,6 +311,152 @@ func TestStoreCompact(t *testing.T) {
}
}
+func TestDeletionRecoveryStateIsProtected(t *testing.T) {
+ s := newStore(t)
+ const generation = "0123456789abcdef0123456789abcdef"
+ recovery := registry.Record{
+ User: "xxvcc-recovery", Port: 22, UID: 1001, Generation: generation, IdentityBound: true,
+ }
+ stale := registry.Record{User: "xxvcc-stale", Port: 22}
+ if err := s.Record(recovery); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Record(stale); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.BeginDeletion(recovery.User, recovery.UID, recovery.Generation); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.Record(recovery); err == nil {
+ t.Fatal("Record cleared a deletion recovery phase")
+ }
+ if err := s.Remove(recovery.User); err == nil {
+ t.Fatal("Remove discarded a deletion recovery phase")
+ }
+ callbackUsers := []string{}
+ removed, err := s.Compact(func(rec registry.Record) (bool, error) {
+ callbackUsers = append(callbackUsers, rec.User)
+ return false, nil
+ })
+ if err != nil || removed != 1 {
+ t.Fatalf("Compact removed=%d err=%v", removed, err)
+ }
+ if strings.Join(callbackUsers, ",") != stale.User {
+ t.Fatalf("Compact callback users = %v, want only ordinary stale row", callbackUsers)
+ }
+ if err := s.FinishDeletionRecovery(recovery.User, recovery.UID+1, recovery.Generation); err == nil {
+ t.Fatal("FinishDeletionRecovery accepted the wrong UID")
+ }
+ if rec, found, err := s.Lookup(recovery.User); err != nil || !found || !rec.DeletionStarted {
+ t.Fatalf("failed transition changed recovery row: found=%v rec=%+v err=%v", found, rec, err)
+ }
+ if err := s.FinishDeletionRecovery(recovery.User, recovery.UID, recovery.Generation); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.FinishDeletionRecovery(recovery.User, recovery.UID, recovery.Generation); err != nil {
+ t.Fatalf("idempotent finish: %v", err)
+ }
+}
+
+func TestUIDOnlyDeletionRecoveryStateIsProtected(t *testing.T) {
+ s := newStore(t)
+ const (
+ user = "xxvcc-uid-only"
+ stale = "xxvcc-stale"
+ uid = 1001
+ )
+ if err := s.BeginDeletion(user, uid, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Record(registry.Record{User: user, Port: 22, UID: uid}); err == nil {
+ t.Fatal("Record cleared a UID-only deletion recovery phase")
+ }
+ if err := s.Remove(user); err == nil {
+ t.Fatal("Remove discarded a UID-only deletion recovery phase")
+ }
+ if err := s.Record(registry.Record{User: stale, Port: 22}); err != nil {
+ t.Fatal(err)
+ }
+ callbackUsers := []string{}
+ removed, err := s.Compact(func(rec registry.Record) (bool, error) {
+ callbackUsers = append(callbackUsers, rec.User)
+ return false, nil
+ })
+ if err != nil || removed != 1 {
+ t.Fatalf("Compact removed=%d err=%v", removed, err)
+ }
+ if strings.Join(callbackUsers, ",") != stale {
+ t.Fatalf("Compact callback users = %v, want only ordinary stale row", callbackUsers)
+ }
+ want := registry.Record{User: user, UID: uid, DeletionStarted: true}
+ if got, found, err := s.Lookup(user); err != nil || !found || got != want {
+ t.Fatalf("UID-only recovery changed: found=%v rec=%+v err=%v", found, got, err)
+ }
+ if err := s.FinishDeletionRecovery(user, uid, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBeginDeletionConvertsPendingRollbackToUIDOnlyRecovery(t *testing.T) {
+ s := newStore(t)
+ const generation = "0123456789abcdef0123456789abcdef"
+ rec := registry.Record{
+ User: "xxvcc-pending", Port: 22, Generation: generation, IdentityBound: true, Pending: true,
+ }
+ if err := s.Record(rec); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.BeginDeletion(rec.User, 1001, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, found, err := s.Lookup(rec.User)
+ if err != nil || !found || got.UID != 1001 || got.Pending || got.IdentityBound ||
+ got.Generation != "" || !got.DeletionStarted {
+ t.Fatalf("pending deletion state: found=%v rec=%+v err=%v", found, got, err)
+ }
+ if err := s.BeginDeletion(rec.User, 1001, ""); err != nil {
+ t.Fatalf("idempotent begin: %v", err)
+ }
+}
+
+func TestStorePersistsLegacyAndUnregisteredUIDOnlyRecovery(t *testing.T) {
+ s := newStore(t)
+ legacy := registry.Record{
+ User: "xxvcc-legacy", Port: 22, UID: 1001,
+ Generation: "0123456789abcdef0123456789abcdef", AutoUnit: "legacy.timer",
+ }
+ if err := s.Record(legacy); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.BeginDeletion(legacy.User, legacy.UID, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, found, err := s.Lookup(legacy.User)
+ if err != nil || !found || !got.DeletionStarted || got.IdentityBound || got.Pending ||
+ got.Generation != "" || got.UID != legacy.UID || got.AutoUnit != legacy.AutoUnit {
+ t.Fatalf("legacy recovery row: found=%v rec=%+v err=%v", found, got, err)
+ }
+
+ const unregistered = "xxvcc-unregistered"
+ if err := s.BeginDeletion(unregistered, 1002, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, found, err = s.Lookup(unregistered)
+ if err != nil || !found || got != (registry.Record{User: unregistered, UID: 1002, DeletionStarted: true}) {
+ t.Fatalf("unregistered recovery row: found=%v rec=%+v err=%v", found, got, err)
+ }
+ if err := s.FinishDeletionRecovery(unregistered, 1002, legacy.Generation); err == nil {
+ t.Fatal("UID-only recovery accepted a generation")
+ }
+ if err := s.FinishDeletionRecovery(unregistered, 1002, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, found, err := s.Lookup(unregistered); err != nil || found {
+ t.Fatalf("finished unregistered recovery still present: found=%v err=%v", found, err)
+ }
+}
+
func TestStoreConcurrentRecord(t *testing.T) {
s := newStore(t)
const n = 20
diff --git a/internal/registry/store_test.go b/internal/registry/store_test.go
index ea8f7f4..f60a2c8 100644
--- a/internal/registry/store_test.go
+++ b/internal/registry/store_test.go
@@ -3,6 +3,7 @@ package registry
import (
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
@@ -44,7 +45,7 @@ func TestMissingStoreRemovalAndCompactAreNoOps(t *testing.T) {
t.Fatalf("Remove on a fully absent store: %v", err)
}
called := false
- removed, err := s.Compact(func(string) (bool, error) {
+ removed, err := s.Compact(func(Record) (bool, error) {
called = true
return false, nil
})
@@ -78,3 +79,197 @@ func TestWriteAllRejectsOutputAboveRegistryLimit(t *testing.T) {
t.Fatalf("oversized registry write created output: %v", err)
}
}
+
+func TestValidateLayoutRequiresDedicatedSiblingPaths(t *testing.T) {
+ dir := t.TempDir()
+ valid := &Store{
+ Dir: dir,
+ File: filepath.Join(dir, "registry.tsv"),
+ Lock: filepath.Join(dir, "registry.lock"),
+ }
+ if err := valid.validateLayout(); err != nil {
+ t.Fatalf("valid registry layout rejected: %v", err)
+ }
+
+ for name, mutate := range map[string]func(*Store){
+ "relative directory": func(s *Store) { s.Dir = "relative" },
+ "file outside": func(s *Store) { s.File = filepath.Join(filepath.Dir(dir), "outside.tsv") },
+ "lock outside": func(s *Store) { s.Lock = filepath.Join(filepath.Dir(dir), "outside.lock") },
+ "nested file": func(s *Store) { s.File = filepath.Join(dir, "nested", "registry.tsv") },
+ "same path": func(s *Store) { s.Lock = s.File },
+ } {
+ t.Run(name, func(t *testing.T) {
+ candidate := *valid
+ mutate(&candidate)
+ if err := candidate.validateLayout(); err == nil {
+ t.Fatal("unsafe registry layout was accepted")
+ }
+ })
+ }
+}
+
+func TestBeginDeletionRecordsSupportsBoundAndUIDOnlyRecovery(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ tests := []struct {
+ name string
+ in []Record
+ user string
+ generation string
+ check func(*testing.T, []Record)
+ }{
+ {
+ name: "generation bound",
+ in: []Record{{
+ User: "xxvcc-bound", Port: 22, UID: 1001,
+ Generation: generation, IdentityBound: true,
+ }},
+ user: "xxvcc-bound", generation: generation,
+ check: func(t *testing.T, got []Record) {
+ if len(got) != 1 || !got[0].DeletionStarted || !got[0].IdentityBound ||
+ got[0].Generation != generation || got[0].UID != 1001 || got[0].Pending {
+ t.Fatalf("bound transition = %+v", got)
+ }
+ },
+ },
+ {
+ name: "registered legacy",
+ in: []Record{{
+ User: "xxvcc-legacy", Port: 2222, UID: 1001,
+ Generation: generation, AutoUnit: "legacy.timer",
+ }},
+ user: "xxvcc-legacy",
+ check: func(t *testing.T, got []Record) {
+ if len(got) != 1 || !got[0].DeletionStarted || got[0].IdentityBound ||
+ got[0].Generation != "" || got[0].Pending || got[0].UID != 1001 ||
+ got[0].Port != 2222 || got[0].AutoUnit != "legacy.timer" {
+ t.Fatalf("legacy transition = %+v", got)
+ }
+ },
+ },
+ {
+ name: "unregistered",
+ user: "xxvcc-unregistered",
+ check: func(t *testing.T, got []Record) {
+ want := Record{User: "xxvcc-unregistered", UID: 1001, DeletionStarted: true}
+ if !reflect.DeepEqual(got, []Record{want}) {
+ t.Fatalf("unregistered transition = %+v, want %+v", got, want)
+ }
+ },
+ },
+ {
+ name: "pending rollback becomes recovery only",
+ in: []Record{{
+ User: "xxvcc-pending", Port: 22, Generation: generation,
+ IdentityBound: true, Pending: true,
+ }},
+ user: "xxvcc-pending",
+ check: func(t *testing.T, got []Record) {
+ if len(got) != 1 || !got[0].DeletionStarted || got[0].Pending ||
+ got[0].IdentityBound || got[0].Generation != "" || got[0].UID != 1001 {
+ t.Fatalf("pending rollback transition = %+v", got)
+ }
+ },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, changed, err := beginDeletionRecords(test.in, test.user, 1001, test.generation)
+ if err != nil || !changed {
+ t.Fatalf("beginDeletionRecords changed=%v err=%v", changed, err)
+ }
+ test.check(t, got)
+ again, changed, err := beginDeletionRecords(got, test.user, 1001, test.generation)
+ if err != nil || changed || !reflect.DeepEqual(again, got) {
+ t.Fatalf("idempotent begin changed=%v got=%+v err=%v", changed, again, err)
+ }
+ })
+ }
+}
+
+func TestBeginDeletionRecordsRejectsIdentityMismatchWithoutMutation(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ const otherGeneration = "fedcba9876543210fedcba9876543210"
+ tests := []struct {
+ name string
+ recs []Record
+ user string
+ uid int
+ generation string
+ }{
+ {
+ name: "bound row cannot be downgraded",
+ recs: []Record{{User: "xxvcc-a1", Port: 22, UID: 1001, Generation: generation, IdentityBound: true}},
+ user: "xxvcc-a1", uid: 1001,
+ },
+ {
+ name: "wrong bound generation",
+ recs: []Record{{User: "xxvcc-a1", Port: 22, UID: 1001, Generation: generation, IdentityBound: true}},
+ user: "xxvcc-a1", uid: 1001, generation: otherGeneration,
+ },
+ {
+ name: "bound identity missing",
+ user: "xxvcc-a1", uid: 1001, generation: generation,
+ },
+ {
+ name: "legacy UID mismatch",
+ recs: []Record{{User: "xxvcc-a1", Port: 22, UID: 1002}},
+ user: "xxvcc-a1", uid: 1001,
+ },
+ {
+ name: "invalid UID",
+ user: "xxvcc-a1", uid: 0,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ before := append([]Record(nil), test.recs...)
+ if _, _, err := beginDeletionRecords(test.recs, test.user, test.uid, test.generation); err == nil {
+ t.Fatal("mismatched deletion transition was accepted")
+ }
+ if !reflect.DeepEqual(test.recs, before) {
+ t.Fatalf("failed transition mutated input: got %+v want %+v", test.recs, before)
+ }
+ })
+ }
+}
+
+func TestFinishDeletionRecoveryRecordsRequiresExactModeAndIdentity(t *testing.T) {
+ const generation = "0123456789abcdef0123456789abcdef"
+ bound := Record{
+ User: "xxvcc-bound", Port: 22, UID: 1001, Generation: generation,
+ IdentityBound: true, DeletionStarted: true,
+ }
+ uidOnly := Record{User: "xxvcc-uid", UID: 1002, DeletionStarted: true}
+ recs := []Record{bound, uidOnly}
+
+ for _, test := range []struct {
+ name string
+ user string
+ uid int
+ generation string
+ }{
+ {name: "bound without generation", user: bound.User, uid: bound.UID},
+ {name: "bound wrong UID", user: bound.User, uid: bound.UID + 1, generation: generation},
+ {name: "uid-only with generation", user: uidOnly.User, uid: uidOnly.UID, generation: generation},
+ {name: "uid-only wrong UID", user: uidOnly.User, uid: uidOnly.UID + 1},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if _, _, err := finishDeletionRecoveryRecords(recs, test.user, test.uid, test.generation); err == nil {
+ t.Fatal("mismatched recovery completion was accepted")
+ }
+ })
+ }
+
+ afterBound, changed, err := finishDeletionRecoveryRecords(recs, bound.User, bound.UID, generation)
+ if err != nil || !changed || !reflect.DeepEqual(afterBound, []Record{uidOnly}) {
+ t.Fatalf("finish bound = changed %v records %+v err %v", changed, afterBound, err)
+ }
+ afterUID, changed, err := finishDeletionRecoveryRecords(recs, uidOnly.User, uidOnly.UID, "")
+ if err != nil || !changed || !reflect.DeepEqual(afterUID, []Record{bound}) {
+ t.Fatalf("finish UID-only = changed %v records %+v err %v", changed, afterUID, err)
+ }
+ missing, changed, err := finishDeletionRecoveryRecords(recs, "xxvcc-missing", 1003, "")
+ if err != nil || changed || !reflect.DeepEqual(missing, recs) {
+ t.Fatalf("idempotent missing finish = changed %v records %+v err %v", changed, missing, err)
+ }
+}
diff --git a/internal/schedule/orphans.go b/internal/schedule/orphans.go
index e31e3fb..9751fb1 100644
--- a/internal/schedule/orphans.go
+++ b/internal/schedule/orphans.go
@@ -3,6 +3,7 @@ package schedule
import (
"fmt"
"os"
+ "path/filepath"
"sort"
"strings"
@@ -28,6 +29,15 @@ import (
// v2's, so a v1 unit on an upgraded host invokes the binary running this code.
// Globbing only the v2 prefix walks straight past it.
func (s *Scheduler) UnitUsers() ([]string, error) {
+ if s == nil {
+ return nil, fmt.Errorf("no scheduler configured")
+ }
+ prefixes := s.unitPrefixes()
+ for _, prefix := range prefixes {
+ if !validManagedUnitPrefix(prefix) {
+ return nil, fmt.Errorf("unsafe managed systemd unit prefix %q", prefix)
+ }
+ }
seen := map[string]bool{}
entries, err := readSystemdDir(s.SystemdDir)
if os.IsNotExist(err) {
@@ -37,19 +47,12 @@ func (s *Scheduler) UnitUsers() ([]string, error) {
return nil, fmt.Errorf("read systemd unit directory %s: %w", s.SystemdDir, err)
}
for _, entry := range entries {
- for _, prefix := range s.unitPrefixes() {
- base := entry.Name()
- if !strings.HasPrefix(base, prefix) {
- continue
- }
- // Units come in .service/.timer pairs; both name the same account.
- base = strings.TrimSuffix(strings.TrimSuffix(base, ".timer"), ".service")
- user := strings.TrimPrefix(base, prefix)
- // validate.Username keeps a hand-made file with a strange name from being
- // reported — and later acted on — as if this tool had written it.
- if user != "" && validate.Username(user) {
- seen[user] = true
- }
+ user, managed, err := managedUnitUser(entry.Name(), prefixes)
+ if err != nil {
+ return nil, err
+ }
+ if managed {
+ seen[user] = true
}
}
users := make([]string, 0, len(seen))
@@ -65,6 +68,9 @@ var readSystemdDir = os.ReadDir
// ScheduledUsers returns accounts named by either systemd units or queued at
// jobs. This is the complete uninstall inventory even when registry rows vanish.
func (s *Scheduler) ScheduledUsers() ([]string, error) {
+ if s == nil || s.Sys == nil {
+ return nil, fmt.Errorf("no scheduler backend configured")
+ }
users, err := s.UnitUsers()
if err != nil {
return nil, err
@@ -73,30 +79,110 @@ func (s *Scheduler) ScheduledUsers() ([]string, error) {
for _, user := range users {
seen[user] = true
}
+ // A failed earlier cleanup can remove the files before daemon-reload. Active or
+ // otherwise loaded units then exist only in PID 1's manager state, so supplement
+ // the disk inventory whenever the backend exposes the production capability.
+ if s.Sys.HasSystemctl() {
+ if lister, ok := s.Sys.(loadedSystemdUnitLister); ok {
+ loaded, err := lister.loadedSystemdUnits()
+ if err != nil {
+ return nil, err
+ }
+ prefixes := s.unitPrefixes()
+ for _, unit := range loaded {
+ user, managed, err := managedUnitUser(unit, prefixes)
+ if err != nil {
+ return nil, err
+ }
+ if managed {
+ seen[user] = true
+ }
+ }
+ }
+ }
// `at` is optional. No installed backend footprint means there cannot be a
// runnable queue to inventory; a partial installation still calls AtJobs and
// fails closed below because it may leave live jobs hidden from teardown.
if !s.Sys.HasAt() {
- return users, nil
+ return sortedScheduledUsers(seen), nil
}
jobs, err := s.Sys.AtJobs()
if err != nil {
return nil, err
}
for _, job := range jobs {
+ // Invites run as root, so only a root-owned at job can be this tool's
+ // schedule. A local user may submit an identical command line; treating it as
+ // inventory would let that user block cleanup or have root remove their job.
+ if job.OwnerUID != 0 {
+ continue
+ }
for _, line := range strings.Split(job.Body, "\n") {
command, ok := parseAtRevokeCommand(line, s.InstallPath)
if ok {
seen[command.user] = true
+ } else if atLineTargetsRevoke(line, s.InstallPath, "") {
+ return nil, fmt.Errorf("at job %s contains an unsupported or corrupt owned revoke command", job.ID)
}
}
}
- users = users[:0]
+ return sortedScheduledUsers(seen), nil
+}
+
+func sortedScheduledUsers(seen map[string]bool) []string {
+ users := make([]string, 0, len(seen))
for user := range seen {
users = append(users, user)
}
sort.Strings(users)
- return users, nil
+ return users
+}
+
+func validManagedUnitPrefix(prefix string) bool {
+ if prefix == "" || filepath.Base(prefix) != prefix || len(prefix) > 200 {
+ return false
+ }
+ for _, r := range prefix {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
+ r == '-', r == '_', r == '.', r == '@':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// loadedSystemdUnitLister is an optional production capability so injected
+// System test doubles outside this package do not need to contact the host's PID
+// 1. realSystem implements it; ScheduledUsers uses it whenever available.
+type loadedSystemdUnitLister interface {
+ loadedSystemdUnits() ([]string, error)
+}
+
+func managedUnitUser(name string, prefixes []string) (string, bool, error) {
+ for _, prefix := range prefixes {
+ if !strings.HasPrefix(name, prefix) {
+ continue
+ }
+ base := name
+ switch {
+ case strings.HasSuffix(base, ".timer"):
+ base = strings.TrimSuffix(base, ".timer")
+ case strings.HasSuffix(base, ".service"):
+ base = strings.TrimSuffix(base, ".service")
+ default:
+ continue
+ }
+ user := strings.TrimPrefix(base, prefix)
+ // A malformed suffix cannot be acted on safely because Cancel is keyed by a
+ // validated username. It is still evidence inside this tool's owned namespace.
+ if user == "" || !validate.Username(user) {
+ return "", true, fmt.Errorf("managed systemd unit %q has an invalid account suffix", name)
+ }
+ return user, true, nil
+ }
+ return "", false, nil
}
// Orphans returns the accounts whose auto-revoke unit is still on disk although
diff --git a/internal/schedule/orphans_test.go b/internal/schedule/orphans_test.go
index bc8c4a1..b7d8c5d 100644
--- a/internal/schedule/orphans_test.go
+++ b/internal/schedule/orphans_test.go
@@ -21,6 +21,16 @@ func TestUnitUsersPropagatesDirectoryReadFailure(t *testing.T) {
}
}
+func TestUnitUsersRejectsUnsafePrefixBeforeReadingEmptyDirectory(t *testing.T) {
+ for _, prefix := range []string{"", "unsafe/prefix", "unsafe prefix", "unsafe\tprefix", "unsafe\nprefix"} {
+ s := newFinder(t)
+ s.UnitPrefix = prefix
+ if _, err := s.UnitUsers(); err == nil || !strings.Contains(err.Error(), "unsafe managed systemd unit prefix") {
+ t.Fatalf("UnitUsers(%q) error = %v, want unsafe-prefix refusal", prefix, err)
+ }
+ }
+}
+
func newFinder(t *testing.T, files ...string) *Scheduler {
t.Helper()
dir := t.TempDir()
@@ -93,14 +103,10 @@ func TestUnitUsersFindsTheV1UnitTheV2GlobWalksPast(t *testing.T) {
}
}
-// TestUnitUsersIgnoresFilesItDidNotWrite: the prefix is a namespace, not a
-// licence to act on anything sharing it.
-func TestUnitUsersIgnoresFilesItDidNotWrite(t *testing.T) {
+func TestUnitUsersIgnoresFilesOutsideManagedUnitShape(t *testing.T) {
s := newFinder(t,
- "linux-temp-admin-v2-revoke-.service", // no username
- "linux-temp-admin-v2-revoke-BadName!.timer", // not a legal username
- "linux-temp-admin-v2-revoke-UPPER.timer", // usernames are lower-case here
- "linux-temp-admin-v2-revoke-has space.timer",
+ "linux-temp-admin-v2-revoke-no-suffix",
+ "linux-temp-admin-v2-revoke-wrong.socket",
"unrelated.service",
"linux-temp-admin-v2-revoke-good.timer",
)
@@ -111,6 +117,40 @@ func TestUnitUsersIgnoresFilesItDidNotWrite(t *testing.T) {
eq(t, users, "good")
}
+func TestUnitUsersFailsClosedOnMalformedManagedUnitName(t *testing.T) {
+ for _, name := range []string{
+ "linux-temp-admin-v2-revoke-.service",
+ "linux-temp-admin-v2-revoke-BadName!.timer",
+ "linux-temp-admin-v2-revoke-UPPER.timer",
+ "linux-temp-admin-v2-revoke-has space.timer",
+ "linux-temp-admin-revoke-.timer",
+ } {
+ t.Run(name, func(t *testing.T) {
+ s := newFinder(t, name)
+ if _, err := s.UnitUsers(); err == nil || !strings.Contains(err.Error(), name) {
+ t.Fatalf("UnitUsers error = %v, want malformed managed-unit refusal", err)
+ }
+ })
+ }
+}
+
+func TestUnitUsersInventoriesManagedNamesRegardlessOfEntryType(t *testing.T) {
+ s := newFinder(t)
+ service := "linux-temp-admin-v2-revoke-special.service"
+ timer := "linux-temp-admin-v2-revoke-special.timer"
+ if err := os.Mkdir(filepath.Join(s.SystemdDir, service), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("missing-target", filepath.Join(s.SystemdDir, timer)); err != nil {
+ t.Fatal(err)
+ }
+ users, err := s.UnitUsers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, users, "special")
+}
+
// TestOrphansAreUnitsWhoseAccountIsGone mirrors sudoers.Orphans/sshdconf.Orphans,
// the two sweeps this one had no counterpart to.
func TestOrphansAreUnitsWhoseAccountIsGone(t *testing.T) {
@@ -139,13 +179,56 @@ func TestScheduledUsersIncludesAtJobsWithoutRegistry(t *testing.T) {
eq(t, users, "queueduser")
}
+func TestScheduledUsersIgnoresNonRootAtMimics(t *testing.T) {
+ s := newFinder(t)
+ s.Sys = &fakeSystem{hasAt: true, atJobs: []AtJob{
+ {ID: "7", OwnerUID: 1001, Body: "/usr/local/sbin/linux-temp-admin revoke --user forged --yes --unknown\n"},
+ {ID: "8", Body: "/usr/local/sbin/linux-temp-admin revoke --user owned --yes\n"},
+ }}
+ users, err := s.ScheduledUsers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, users, "owned")
+}
+
+func TestScheduledUsersIncludesUnitsLoadedOnlyInSystemdManager(t *testing.T) {
+ s := newFinder(t)
+ s.Sys = &fakeSystem{hasSystemctl: true, loadedUnits: []string{
+ "linux-temp-admin-v2-revoke-loaded.timer",
+ "linux-temp-admin-revoke-legacy.service",
+ "unrelated.timer",
+ }}
+ users, err := s.ScheduledUsers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, users, "legacy", "loaded")
+}
+
+func TestScheduledUsersFailsClosedOnMalformedOrUnreadableLoadedUnitInventory(t *testing.T) {
+ t.Run("malformed managed name", func(t *testing.T) {
+ s := newFinder(t)
+ s.Sys = &fakeSystem{hasSystemctl: true, loadedUnits: []string{"linux-temp-admin-v2-revoke-Bad!.timer"}}
+ if _, err := s.ScheduledUsers(); err == nil || !strings.Contains(err.Error(), "invalid account suffix") {
+ t.Fatalf("ScheduledUsers error = %v, want malformed loaded-unit refusal", err)
+ }
+ })
+ t.Run("manager query", func(t *testing.T) {
+ s := newFinder(t)
+ s.Sys = &fakeSystem{hasSystemctl: true, loadedErr: errors.New("D-Bus unavailable")}
+ if _, err := s.ScheduledUsers(); err == nil || !strings.Contains(err.Error(), "D-Bus unavailable") {
+ t.Fatalf("ScheduledUsers error = %v, want manager inventory failure", err)
+ }
+ })
+}
+
func TestScheduledUsersOnlyAcceptsKnownStandaloneRevokeCommands(t *testing.T) {
s := newFinder(t)
s.Sys = &fakeSystem{hasAt: true, atJobs: []AtJob{
{ID: "1", Body: "# /usr/local/sbin/linux-temp-admin revoke --user comment --yes\n"},
{ID: "2", Body: "echo /usr/local/sbin/linux-temp-admin revoke --user echoed --yes\n"},
{ID: "3", Body: "/tmp/usr/local/sbin/linux-temp-admin revoke --user wrongpath --yes\n"},
- {ID: "4", Body: "/usr/local/sbin/linux-temp-admin revoke --user badsuffix --yes --unknown\n"},
{ID: "5", Body: "/usr/local/sbin/linux-temp-admin revoke --user legacy --yes\n"},
{ID: "6", Body: "/usr/local/sbin/linux-temp-admin revoke --user forced --yes --force --confirm-force forced\n"},
{ID: "7", Body: "/usr/local/sbin/linux-temp-admin revoke --user current --yes --force --confirm-force current --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef\n"},
@@ -158,6 +241,17 @@ func TestScheduledUsersOnlyAcceptsKnownStandaloneRevokeCommands(t *testing.T) {
eq(t, users, "current", "forced", "legacy")
}
+func TestScheduledUsersFailsClosedOnMalformedOwnedAtCommand(t *testing.T) {
+ s := newFinder(t)
+ s.Sys = &fakeSystem{hasAt: true, atJobs: []AtJob{{
+ ID: "4",
+ Body: "/usr/local/sbin/linux-temp-admin revoke --user future --yes --unknown\n",
+ }}}
+ if _, err := s.ScheduledUsers(); err == nil || !strings.Contains(err.Error(), "at job 4") {
+ t.Fatalf("ScheduledUsers error = %v, want malformed owned-job refusal", err)
+ }
+}
+
func TestScheduledUsersAllowsCompletelyAbsentAtBackend(t *testing.T) {
s := newFinder(t, "linux-temp-admin-v2-revoke-unituser.timer")
s.Sys = &fakeSystem{atJobsErr: os.ErrNotExist}
@@ -168,3 +262,11 @@ func TestScheduledUsersAllowsCompletelyAbsentAtBackend(t *testing.T) {
}
eq(t, users, "unituser")
}
+
+func TestScheduledUsersRejectsMissingBackend(t *testing.T) {
+ s := newFinder(t)
+ s.Sys = nil
+ if _, err := s.ScheduledUsers(); err == nil || !strings.Contains(err.Error(), "no scheduler backend") {
+ t.Fatalf("ScheduledUsers missing-backend error = %v", err)
+ }
+}
diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go
index e9ae184..8894951 100644
--- a/internal/schedule/schedule.go
+++ b/internal/schedule/schedule.go
@@ -25,21 +25,26 @@ type System interface {
// HasAt reports any installed at-backend footprint. A completely absent
// backend is not an inventory error; a partial backend is and must fail closed.
HasAt() bool
- // ScheduleAt queues command to run in `hours` hours and returns the job id.
- ScheduleAt(command string, hours int) (jobID string, err error)
+ // ScheduleAt queues command for the absolute, minute-aligned deadline and
+ // returns its canonical positive decimal job id. If it returns an error after
+ // an ambiguous submission, it first attempts to remove every queued job whose
+ // body contains that exact standalone command.
+ ScheduleAt(command string, deadline time.Time) (jobID string, err error)
// RemoveAtJobsFor removes queued jobs matching a known standalone revoke
// command selected by command's legacy-compatible prefix.
RemoveAtJobsFor(command string) error
// AtrmJob removes a specific at job by id. An already-absent job is success.
AtrmJob(id string) error
- // AtJobs returns queued job bodies so uninstall can inventory jobs whose
- // registry row has been lost. Missing inventory commands are an error.
+ // AtJobs returns queued job bodies and their generated owner UID so uninstall
+ // can inventory root-created jobs whose registry row has been lost. Missing
+ // inventory commands or an unparseable owner header are errors.
AtJobs() ([]AtJob, error)
}
type AtJob struct {
- ID string
- Body string
+ ID string
+ Body string
+ OwnerUID uint32
}
// Scheduler writes units / queues jobs. Paths and time source are fields for tests.
@@ -97,8 +102,8 @@ func (s *Scheduler) revokeAtNeedle(user string) string {
}
// OnCalendar formats the absolute UTC trigger time for a systemd timer.
-func OnCalendar(now time.Time, hours int) string {
- return now.UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05 UTC")
+func OnCalendar(deadline time.Time) string {
+ return deadline.UTC().Format("2006-01-02 15:04:05 UTC")
}
func (s *Scheduler) serviceContent(user string, uid int, generation string) string {
@@ -120,18 +125,28 @@ RestartSec=5min
}
func timerContent(unit, onCalendar string) string {
+ return timerContentWithAccuracy(unit, onCalendar, "1us")
+}
+
+// legacyTimerContent is accepted only when inventorying timers written by
+// releases that used systemd's one-minute coalescing window.
+func legacyTimerContent(unit, onCalendar string) string {
+ return timerContentWithAccuracy(unit, onCalendar, "1min")
+}
+
+func timerContentWithAccuracy(unit, onCalendar, accuracy string) string {
return fmt.Sprintf(`[Unit]
Description=linux-temp-admin auto revoke timer for %s
[Timer]
OnCalendar=%s
Persistent=true
-AccuracySec=1min
+AccuracySec=%s
Unit=%s.service
[Install]
WantedBy=timers.target
-`, unit, onCalendar, unit)
+`, unit, onCalendar, accuracy, unit)
}
// Schedule creates the auto-revoke task and returns its recorded identifier
@@ -143,7 +158,7 @@ WantedBy=timers.target
// systemd host that could not write a unit report the fallback's misleading "no
// systemctl or at available", sending the operator to debug a missing tool that
// was in fact present.
-func (s *Scheduler) Schedule(user string, uid int, generation string, hours int) (string, error) {
+func (s *Scheduler) Schedule(user string, uid int, generation string, deadline time.Time) (string, error) {
if !validate.Username(user) {
return "", fmt.Errorf("invalid temporary username %q", user)
}
@@ -153,15 +168,17 @@ func (s *Scheduler) Schedule(user string, uid int, generation string, hours int)
if !validate.Generation(generation) {
return "", fmt.Errorf("invalid account generation %q", generation)
}
- if !validate.Hours(hours) {
- return "", fmt.Errorf("invalid account lifetime %d hours", hours)
- }
if s == nil || s.Sys == nil {
return "", fmt.Errorf("no scheduler backend configured")
}
+ now := s.now()
+ if !validDeadline(now, deadline) {
+ return "", fmt.Errorf("invalid auto-revoke deadline %s", deadline.Format(time.RFC3339Nano))
+ }
+ deadline = deadline.UTC()
var systemdErr error
if s.Sys.HasSystemctl() {
- unit, err := s.scheduleSystemd(user, uid, generation, hours)
+ unit, err := s.scheduleSystemd(user, uid, generation, deadline)
if err == nil {
return unit, nil
}
@@ -171,18 +188,18 @@ func (s *Scheduler) Schedule(user string, uid int, generation string, hours int)
return "", fmt.Errorf("systemd: %w", err)
}
}
- unit, atErr := s.scheduleAt(user, uid, generation, hours)
+ unit, atErr := s.scheduleAt(user, uid, generation, deadline)
if atErr != nil && systemdErr != nil {
return "", fmt.Errorf("systemd: %w; at fallback: %v", systemdErr, atErr)
}
return unit, atErr
}
-func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, hours int) (string, error) {
- unit := s.UnitName(user)
- if strings.ContainsAny(unit, "/ ") {
- return "", fmt.Errorf("invalid unit name %q", unit)
+func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, deadline time.Time) (string, error) {
+ if !validManagedUnitPrefix(s.UnitPrefix) {
+ return "", fmt.Errorf("unsafe managed systemd unit prefix %q", s.UnitPrefix)
}
+ unit := s.UnitName(user)
servicePath := filepath.Join(s.SystemdDir, unit+".service")
timerPath := filepath.Join(s.SystemdDir, unit+".timer")
if err := fsutil.WriteRootFile(servicePath, []byte(s.serviceContent(user, uid, generation)), 0o644); err != nil {
@@ -192,7 +209,7 @@ func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, hou
}
return "", err
}
- oc := OnCalendar(s.Now(), hours)
+ oc := OnCalendar(deadline)
if err := fsutil.WriteRootFile(timerPath, []byte(timerContent(unit, oc)), 0o644); err != nil {
var committed *fsutil.DurabilityError
if errors.As(err, &committed) {
@@ -209,6 +226,21 @@ func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, hou
return unit, nil
}
+func (s *Scheduler) now() time.Time {
+ if s != nil && s.Now != nil {
+ return s.Now()
+ }
+ return time.Now()
+}
+
+func validDeadline(now, deadline time.Time) bool {
+ if deadline.IsZero() || deadline.Second() != 0 || deadline.Nanosecond() != 0 || !deadline.After(now) {
+ return false
+ }
+ maxDelay := time.Duration(config.MaxExpireHours)*time.Hour + time.Minute
+ return deadline.Sub(now) <= maxDelay
+}
+
func systemdWriteRollback(cause error, paths ...string) error {
errs := []error{cause}
rollbackFailed := false
@@ -234,7 +266,7 @@ func (s *Scheduler) rollbackFailedEnable(unit, servicePath, timerPath string, en
errs := []error{fmt.Errorf("enable systemd timer: %w", enableErr)}
rollbackFailed := false
timerUnit := unit + ".timer"
- if err := s.Sys.Systemctl("disable", "--now", timerUnit); err != nil && !systemctlUnitFileMissing(err, timerUnit) {
+ if err := s.disableAndConfirmTimerStopped(timerUnit); err != nil {
errs = append(errs, fmt.Errorf("rollback disable systemd timer: %w", err))
// enable --now may have started the timer before returning its error. If
// stopping it cannot be confirmed, keep both files as durable inventory and
@@ -263,14 +295,66 @@ func (s *Scheduler) rollbackFailedEnable(unit, servicePath, timerPath string, en
return joined
}
-func (s *Scheduler) scheduleAt(user string, uid int, generation string, hours int) (string, error) {
+// disableAndConfirmTimerStopped handles systemctl's split disable/--now
+// implementation. When the unit file is missing, `disable --now` returns before
+// it reaches the stop phase, even though an already-loaded timer may still be
+// active in the manager. Treat that diagnostic only as a reason to explicitly
+// stop the loaded timer and confirm its final state, never as proof it stopped.
+func (s *Scheduler) disableAndConfirmTimerStopped(timerUnit string) error {
+ disableErr := s.Sys.Systemctl("disable", "--now", timerUnit)
+ if disableErr == nil {
+ return nil
+ }
+ if !systemctlUnitFileMissing(disableErr, timerUnit) {
+ return disableErr
+ }
+
+ stopErr := s.Sys.Systemctl("stop", timerUnit)
+ if stopErr != nil {
+ if systemctlStopUnitNotLoaded(stopErr, timerUnit) {
+ return nil
+ }
+ return errors.Join(
+ fmt.Errorf("disable systemd timer: %w", disableErr),
+ fmt.Errorf("stop missing-file timer: %w", stopErr),
+ )
+ }
+
+ stateErr := s.Sys.Systemctl("is-active", timerUnit)
+ if stateErr == nil {
+ return errors.Join(
+ fmt.Errorf("disable systemd timer: %w", disableErr),
+ fmt.Errorf("timer %s remains active after stop", timerUnit),
+ )
+ }
+ if !systemctlTimerStoppedState(stateErr, timerUnit) {
+ return errors.Join(
+ fmt.Errorf("disable systemd timer: %w", disableErr),
+ fmt.Errorf("confirm timer stopped: %w", stateErr),
+ )
+ }
+ return nil
+}
+
+func (s *Scheduler) scheduleAt(user string, uid int, generation string, deadline time.Time) (string, error) {
if !s.Sys.HasAt() {
return "", fmt.Errorf("no systemctl or at available")
}
- id, err := s.Sys.ScheduleAt(s.RevokeCommand(user, uid, generation), hours)
+ if !deadline.After(s.now()) {
+ return "", fmt.Errorf("auto-revoke deadline %s passed before the at fallback could be queued", deadline.Format(time.RFC3339))
+ }
+ command := s.RevokeCommand(user, uid, generation)
+ id, err := s.Sys.ScheduleAt(command, deadline)
if err != nil {
return "", err
}
+ if !numericJobID(id) {
+ cause := fmt.Errorf("at scheduler returned invalid job id %q", id)
+ if cleanupErr := s.Sys.RemoveAtJobsFor(s.revokeAtNeedle(user)); cleanupErr != nil {
+ return "", errors.Join(cause, fmt.Errorf("sweep jobs after invalid at id: %w", cleanupErr))
+ }
+ return "", cause
+ }
return "at:" + id, nil
}
@@ -282,13 +366,46 @@ func (s *Scheduler) scheduleAt(user string, uid int, generation string, hours in
// files and reloading prevents every successful automatic revoke from leaving a
// permanent orphaned .service behind.
func (s *Scheduler) Cancel(user, recordedUnit string) error {
+ if !validate.Username(user) {
+ return fmt.Errorf("invalid temporary username %q", user)
+ }
+ if s == nil || s.Sys == nil {
+ return fmt.Errorf("no scheduler backend configured")
+ }
var errs []error
hasSystemctl := s.Sys.HasSystemctl()
- // Remove a specifically-recorded at job even where atq is unavailable (so
- // RemoveAtJobsFor's body sweep can't run).
+ prefixes := s.unitPrefixes()
+ validPrefixes := make([]string, 0, len(prefixes))
+ for _, prefix := range prefixes {
+ if !validManagedUnitPrefix(prefix) {
+ errs = append(errs, fmt.Errorf("unsafe managed systemd unit prefix %q", prefix))
+ continue
+ }
+ validPrefixes = append(validPrefixes, prefix)
+ }
+ // A recorded at id is inventory evidence, not deletion authority. at job ids
+ // are eventually reusable, so handing a stale id directly to atrm can remove an
+ // unrelated job that later acquired the same number. The body sweep below is
+ // the only safe removal path: it accepts only standalone revoke commands emitted
+ // by known releases. If that inventory backend has disappeared, preserve the
+ // registry evidence and fail closed instead of guessing from the id.
if strings.HasPrefix(recordedUnit, "at:") {
- if err := s.Sys.AtrmJob(strings.TrimPrefix(recordedUnit, "at:")); err != nil {
- errs = append(errs, err)
+ id := strings.TrimPrefix(recordedUnit, "at:")
+ if !numericJobID(id) {
+ errs = append(errs, fmt.Errorf("unsupported recorded auto-revoke identifier %q", recordedUnit))
+ } else if !s.Sys.HasAt() {
+ errs = append(errs, fmt.Errorf("at backend is unavailable; cannot verify recorded auto-revoke job %s before preserving its registry evidence", id))
+ }
+ } else if recordedUnit != "" {
+ known := false
+ for _, prefix := range validPrefixes {
+ if recordedUnit == prefix+user {
+ known = true
+ break
+ }
+ }
+ if !known {
+ errs = append(errs, fmt.Errorf("unsupported recorded auto-revoke identifier %q", recordedUnit))
}
}
if err := s.Sys.RemoveAtJobsFor(s.revokeAtNeedle(user)); err != nil {
@@ -301,8 +418,7 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error {
// an uninstall then removes the binary, that timer fails forever. Disabling by
// the v2 name alone would leave it armed. There is normally at most one unit per
// account, so the extra names are no-ops on a pure-v2 host.
- reloadNeeded := false
- for _, prefix := range s.unitPrefixes() {
+ for _, prefix := range validPrefixes {
unit := prefix + user
if strings.ContainsAny(unit, "/ ") {
continue
@@ -327,8 +443,7 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error {
}
if hasSystemctl {
timerUnit := unit + ".timer"
- err := s.Sys.Systemctl("disable", "--now", timerUnit)
- if err != nil && !systemctlUnitFileMissing(err, timerUnit) {
+ if err := s.disableAndConfirmTimerStopped(timerUnit); err != nil {
errs = append(errs, err)
// Preserve both files as retry/inventory evidence. Deleting them after
// a stop failure can leave a timer active only in systemd's memory.
@@ -339,18 +454,19 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error {
errs = append(errs, err)
}
}
- if removed, err := removeIfNotSymlink(timerPath); err != nil {
+ if _, err := removeIfNotSymlink(timerPath); err != nil {
errs = append(errs, err)
- } else if removed {
- reloadNeeded = true
}
- if removed, err := removeIfNotSymlink(servicePath); err != nil {
+ if _, err := removeIfNotSymlink(servicePath); err != nil {
errs = append(errs, err)
- } else if removed {
- reloadNeeded = true
}
}
- if reloadNeeded && hasSystemctl {
+ if hasSystemctl {
+ // Always reload, even when both files were already absent. ScheduledUsers can
+ // discover a managed timer that survives only in PID 1's manager state; after
+ // stopping it, daemon-reload is what drops the deleted fragment. It also makes
+ // a retry repair an earlier cleanup whose file removals committed before its
+ // daemon-reload failed.
if err := s.Sys.Systemctl("daemon-reload"); err != nil {
errs = append(errs, err)
}
@@ -427,7 +543,7 @@ func (s *Scheduler) managedTimerStamp(name string) bool {
return false
}
for _, prefix := range s.unitPrefixes() {
- if prefix == "" || filepath.Base(prefix) != prefix || strings.ContainsAny(prefix, "/ ") {
+ if !validManagedUnitPrefix(prefix) {
continue
}
stem := strings.TrimSuffix(strings.TrimPrefix(name, "stamp-"+prefix), ".timer")
diff --git a/internal/schedule/schedule_root_test.go b/internal/schedule/schedule_root_test.go
index 4f3c64d..b4cff1e 100644
--- a/internal/schedule/schedule_root_test.go
+++ b/internal/schedule/schedule_root_test.go
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
)
func TestScheduleWritesSystemdUnits(t *testing.T) {
@@ -23,7 +24,8 @@ func TestScheduleWritesSystemdUnits(t *testing.T) {
sys := &fakeSystem{hasSystemctl: true}
s := newScheduler(dir, sys)
- unit, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 24)
+ deadline := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)
+ unit, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", deadline)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go
index 09019b9..abab519 100644
--- a/internal/schedule/schedule_test.go
+++ b/internal/schedule/schedule_test.go
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/xxvcc/linux-temp-admin/internal/config"
)
type fakeSystem struct {
@@ -15,14 +17,16 @@ type fakeSystem struct {
hasAt bool
calls [][]string
atCommand string
- atHours int
+ atDeadline time.Time
atID string
removedFor []string
atrmd []string
atJobs []AtJob
+ loadedUnits []string
removeAtErr error
atrmErr error
atJobsErr error
+ loadedErr error
systemctlErr func(args ...string) error
}
@@ -35,16 +39,23 @@ func (f *fakeSystem) Systemctl(args ...string) error {
}
return nil
}
-func (f *fakeSystem) ScheduleAt(command string, hours int) (string, error) {
- f.atCommand, f.atHours = command, hours
+func (f *fakeSystem) ScheduleAt(command string, deadline time.Time) (string, error) {
+ f.atCommand, f.atDeadline = command, deadline
return f.atID, nil
}
+
+func deadlineAfter(s *Scheduler, hours int) time.Time {
+ return s.now().Add(time.Duration(hours) * time.Hour)
+}
func (f *fakeSystem) RemoveAtJobsFor(command string) error {
f.removedFor = append(f.removedFor, command)
return f.removeAtErr
}
func (f *fakeSystem) AtrmJob(id string) error { f.atrmd = append(f.atrmd, id); return f.atrmErr }
func (f *fakeSystem) AtJobs() ([]AtJob, error) { return f.atJobs, f.atJobsErr }
+func (f *fakeSystem) loadedSystemdUnits() ([]string, error) {
+ return f.loadedUnits, f.loadedErr
+}
func newScheduler(dir string, sys System) *Scheduler {
return &Scheduler{
@@ -58,7 +69,7 @@ func newScheduler(dir string, sys System) *Scheduler {
}
func TestOnCalendarAndNames(t *testing.T) {
- if got := OnCalendar(time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC), 24); got != "2026-07-08 12:00:00 UTC" {
+ if got := OnCalendar(time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)); got != "2026-07-08 12:00:00 UTC" {
t.Errorf("OnCalendar = %q", got)
}
s := newScheduler("/x", &fakeSystem{})
@@ -82,7 +93,7 @@ func TestUnitContents(t *testing.T) {
}
tmr := timerContent("linux-temp-admin-v2-revoke-xxvcc-a1", "2026-07-08 12:00:00 UTC")
for _, want := range []string{"OnCalendar=2026-07-08 12:00:00 UTC", "Persistent=true",
- "Unit=linux-temp-admin-v2-revoke-xxvcc-a1.service", "WantedBy=timers.target"} {
+ "AccuracySec=1us", "Unit=linux-temp-admin-v2-revoke-xxvcc-a1.service", "WantedBy=timers.target"} {
if !strings.Contains(tmr, want) {
t.Errorf("timer missing %q:\n%s", want, tmr)
}
@@ -92,15 +103,16 @@ func TestUnitContents(t *testing.T) {
func TestScheduleFallsBackToAt(t *testing.T) {
sys := &fakeSystem{hasSystemctl: false, hasAt: true, atID: "42"}
s := newScheduler(t.TempDir(), sys)
- unit, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 6)
+ deadline := deadlineAfter(s, 6)
+ unit, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", deadline)
if err != nil {
t.Fatal(err)
}
if unit != "at:42" {
t.Errorf("unit = %q, want at:42", unit)
}
- if sys.atCommand != s.RevokeCommand("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef") || sys.atHours != 6 {
- t.Errorf("ScheduleAt got %q, %d", sys.atCommand, sys.atHours)
+ if sys.atCommand != s.RevokeCommand("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef") || !sys.atDeadline.Equal(deadline) {
+ t.Errorf("ScheduleAt got %q, %s; want deadline %s", sys.atCommand, sys.atDeadline, deadline)
}
// The queued command carries --force --confirm-force so a lost registry row at
// expiry cannot make the unattended revoke refuse the account.
@@ -111,19 +123,90 @@ func TestScheduleFallsBackToAt(t *testing.T) {
func TestScheduleNoBackend(t *testing.T) {
s := newScheduler(t.TempDir(), &fakeSystem{})
- if _, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 6); err == nil {
+ if _, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", deadlineAfter(s, 6)); err == nil {
t.Fatal("expected error when no systemctl or at")
}
}
+func TestScheduleRefusesAtFallbackAfterDeadlinePasses(t *testing.T) {
+ base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
+ deadline := base.Add(time.Minute)
+ clockCalls := 0
+ sys := &fakeSystem{hasAt: true, atID: "42"}
+ s := newScheduler(t.TempDir(), sys)
+ s.Now = func() time.Time {
+ clockCalls++
+ if clockCalls == 1 {
+ return base
+ }
+ return deadline
+ }
+
+ _, err := s.Schedule("xxvcc-a1", 1001, testGeneration, deadline)
+ if err == nil || !strings.Contains(err.Error(), "passed before the at fallback") {
+ t.Fatalf("Schedule error = %v, want elapsed-deadline refusal", err)
+ }
+ if sys.atCommand != "" {
+ t.Fatalf("expired deadline reached at backend: %q", sys.atCommand)
+ }
+}
+
+func TestScheduleRejectsInvalidAtJobIDAndSweepsKnownJobs(t *testing.T) {
+ for _, id := range []string{"", "0", "not-numeric"} {
+ t.Run(id, func(t *testing.T) {
+ sys := &fakeSystem{hasAt: true, atID: id}
+ s := newScheduler(t.TempDir(), sys)
+ if _, err := s.Schedule("xxvcc-a1", 1001, testGeneration, deadlineAfter(s, 6)); err == nil || !strings.Contains(err.Error(), "invalid job id") {
+ t.Fatalf("Schedule invalid id %q error = %v", id, err)
+ }
+ if len(sys.removedFor) != 1 || sys.removedFor[0] != s.revokeAtNeedle("xxvcc-a1") {
+ t.Fatalf("invalid-id cleanup selectors = %v", sys.removedFor)
+ }
+ })
+ }
+}
+
+func TestScheduleRejectsUnsafeUnitPrefix(t *testing.T) {
+ sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"}
+ s := newScheduler(t.TempDir(), sys)
+ s.UnitPrefix = "unsafe\tprefix-"
+ unit, err := s.Schedule("xxvcc-a1", 1001, testGeneration, deadlineAfter(s, 6))
+ if err != nil || unit != "at:42" {
+ t.Fatalf("Schedule unsafe systemd prefix fallback = %q, %v", unit, err)
+ }
+ if len(sys.calls) != 0 {
+ t.Fatalf("unsafe prefix reached systemctl: %v", sys.calls)
+ }
+}
+
+func TestSchedulerUsesWallClockWhenNowIsUnset(t *testing.T) {
+ s := &Scheduler{}
+ before := time.Now()
+ got := s.now()
+ after := time.Now()
+ if got.Before(before) || got.After(after) {
+ t.Fatalf("fallback clock returned %v outside [%v, %v]", got, before, after)
+ }
+}
+
+func TestCancelRejectsInvalidInputAndMissingBackend(t *testing.T) {
+ if err := (&Scheduler{}).Cancel("bad user", ""); err == nil || !strings.Contains(err.Error(), "invalid temporary username") {
+ t.Fatalf("Cancel invalid-user error = %v", err)
+ }
+ if err := (&Scheduler{}).Cancel("xxvcc-a1", ""); err == nil || !strings.Contains(err.Error(), "no scheduler backend") {
+ t.Fatalf("Cancel missing-backend error = %v", err)
+ }
+}
+
func TestScheduleRejectsReservedLinuxUIDBeforeMutation(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot represent the reserved uint32 uid sentinel")
}
sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"}
s := newScheduler(t.TempDir(), sys)
- reserved := int(uint64(^uint32(0)))
- if _, err := s.Schedule("xxvcc-a1", reserved, testGeneration, 6); err == nil || !strings.Contains(err.Error(), "invalid Linux account UID") {
+ reservedKernelID := uint64(^uint32(0))
+ reserved := int(reservedKernelID)
+ if _, err := s.Schedule("xxvcc-a1", reserved, testGeneration, deadlineAfter(s, 6)); err == nil || !strings.Contains(err.Error(), "invalid Linux account UID") {
t.Fatalf("Schedule reserved UID error = %v, want range refusal", err)
}
if len(sys.calls) != 0 || sys.atCommand != "" {
@@ -131,22 +214,25 @@ func TestScheduleRejectsReservedLinuxUIDBeforeMutation(t *testing.T) {
}
}
-func TestScheduleRejectsInvalidIdentityAndLifetimeBeforeMutation(t *testing.T) {
+func TestScheduleRejectsInvalidIdentityAndDeadlineBeforeMutation(t *testing.T) {
+ now := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
+ validDeadline := now.Add(time.Hour)
for _, tc := range []struct {
name string
user string
generation string
- hours int
+ deadline time.Time
}{
- {name: "username", user: "bad user", generation: testGeneration, hours: 1},
- {name: "generation", user: "xxvcc-a1", generation: "bad", hours: 1},
- {name: "zero hours", user: "xxvcc-a1", generation: testGeneration, hours: 0},
- {name: "excessive hours", user: "xxvcc-a1", generation: testGeneration, hours: 24*366 + 1},
+ {name: "username", user: "bad user", generation: testGeneration, deadline: validDeadline},
+ {name: "generation", user: "xxvcc-a1", generation: "bad", deadline: validDeadline},
+ {name: "expired", user: "xxvcc-a1", generation: testGeneration, deadline: now},
+ {name: "not minute aligned", user: "xxvcc-a1", generation: testGeneration, deadline: validDeadline.Add(time.Second)},
+ {name: "too far", user: "xxvcc-a1", generation: testGeneration, deadline: now.Add(time.Duration(config.MaxExpireHours)*time.Hour + 2*time.Minute)},
} {
t.Run(tc.name, func(t *testing.T) {
sys := &fakeSystem{hasSystemctl: true, hasAt: true}
- s := &Scheduler{SystemdDir: t.TempDir(), InstallPath: "/usr/local/sbin/linux-temp-admin", UnitPrefix: "lta-", Now: time.Now, Sys: sys}
- if _, err := s.Schedule(tc.user, 1001, tc.generation, tc.hours); err == nil {
+ s := &Scheduler{SystemdDir: t.TempDir(), InstallPath: "/usr/local/sbin/linux-temp-admin", UnitPrefix: "lta-", Now: func() time.Time { return now }, Sys: sys}
+ if _, err := s.Schedule(tc.user, 1001, tc.generation, tc.deadline); err == nil {
t.Fatal("Schedule accepted invalid input")
}
if len(sys.calls) != 0 || sys.atCommand != "" {
@@ -181,13 +267,17 @@ func TestScheduleRollsBackPartiallyEnabledSystemdTimerBeforeAtFallback(t *testin
t.Fatal(err)
}
- got, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 6)
+ deadline := deadlineAfter(s, 6)
+ got, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", deadline)
if err != nil {
t.Fatal(err)
}
if got != "at:42" {
t.Fatalf("Schedule = %q, want at fallback", got)
}
+ if !sys.atDeadline.Equal(deadline) {
+ t.Fatalf("at fallback deadline = %s, want original absolute deadline %s", sys.atDeadline, deadline)
+ }
wantCalls := []string{
"daemon-reload",
"enable --now " + unit + ".timer",
@@ -224,7 +314,7 @@ func TestScheduleDoesNotFallbackWhenSystemdRollbackFails(t *testing.T) {
}
s := newScheduler(dir, sys)
- _, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 6)
+ _, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", deadlineAfter(s, 6))
if err == nil || !strings.Contains(err.Error(), "enable failed") || !strings.Contains(err.Error(), "rollback disable failed") {
t.Fatalf("Schedule error = %v, want original and rollback failures", err)
}
@@ -393,6 +483,13 @@ func TestCancelTreatsMissingTimerAsSuccessWhenOnlyServiceRemains(t *testing.T) {
output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.",
}
}
+ if len(args) == 2 && args[0] == "stop" {
+ return &systemctlError{
+ args: append([]string(nil), args...),
+ err: errors.New("exit status 5"),
+ output: "Failed to stop " + unit + ".timer: Unit " + unit + ".timer not loaded.",
+ }
+ }
return nil
}
@@ -407,6 +504,121 @@ func TestCancelTreatsMissingTimerAsSuccessWhenOnlyServiceRemains(t *testing.T) {
}
}
+func TestCancelExplicitlyStopsActiveTimerWhoseUnitFileIsMissing(t *testing.T) {
+ dir := t.TempDir()
+ sys := &fakeSystem{hasSystemctl: true}
+ s := newScheduler(dir, sys)
+ unit := s.UnitName("xxvcc-a1")
+ servicePath := filepath.Join(dir, unit+".service")
+ if err := os.WriteFile(servicePath, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ active := true
+ sys.systemctlErr = func(args ...string) error {
+ switch {
+ case len(args) == 3 && args[0] == "disable":
+ return &systemctlError{
+ args: append([]string(nil), args...),
+ err: errors.New("exit status 1"),
+ output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.",
+ }
+ case len(args) == 2 && args[0] == "stop":
+ active = false
+ return nil
+ case len(args) == 2 && args[0] == "is-active":
+ if active {
+ return nil
+ }
+ return &systemctlError{args: append([]string(nil), args...), err: errSystemdUnitInactive, output: "inactive"}
+ default:
+ return nil
+ }
+ }
+
+ if err := s.Cancel("xxvcc-a1", ""); err != nil {
+ t.Fatal(err)
+ }
+ if active || !calledSystemctl(sys.calls, "stop") {
+ t.Fatalf("active missing-file timer was not explicitly stopped; calls=%v", sys.calls)
+ }
+ if _, err := os.Lstat(servicePath); !os.IsNotExist(err) {
+ t.Fatalf("service evidence survived confirmed stop: %v", err)
+ }
+}
+
+func TestCancelPreservesEvidenceWhenMissingTimerStateIsUncertain(t *testing.T) {
+ dir := t.TempDir()
+ sys := &fakeSystem{hasSystemctl: true}
+ s := newScheduler(dir, sys)
+ unit := s.UnitName("xxvcc-a1")
+ servicePath := filepath.Join(dir, unit+".service")
+ if err := os.WriteFile(servicePath, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sys.systemctlErr = func(args ...string) error {
+ if len(args) == 3 && args[0] == "disable" {
+ return &systemctlError{
+ args: append([]string(nil), args...),
+ err: errors.New("exit status 1"),
+ output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.",
+ }
+ }
+ if len(args) == 2 && args[0] == "stop" {
+ return errors.New("injected state query failure")
+ }
+ return nil
+ }
+
+ err := s.Cancel("xxvcc-a1", "")
+ if err == nil || !strings.Contains(err.Error(), "injected state query failure") {
+ t.Fatalf("Cancel error = %v, want state uncertainty", err)
+ }
+ if _, err := os.Lstat(servicePath); err != nil {
+ t.Fatalf("service evidence was removed without a stopped verdict: %v", err)
+ }
+}
+
+func TestScheduleDoesNotFallbackWhenMissingFileRollbackCannotStopTimer(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("systemd schedule rollback requires root-owned fixtures")
+ }
+ dir := t.TempDir()
+ sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"}
+ s := newScheduler(dir, sys)
+ unit := s.UnitName("xxvcc-a1")
+ sys.systemctlErr = func(args ...string) error {
+ switch {
+ case len(args) == 3 && args[0] == "enable":
+ return errors.New("enable failed after starting timer")
+ case len(args) == 3 && args[0] == "disable":
+ return &systemctlError{
+ args: append([]string(nil), args...),
+ err: errors.New("exit status 1"),
+ output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.",
+ }
+ case len(args) == 3 && args[0] == "is-active":
+ return nil
+ case len(args) == 2 && args[0] == "stop":
+ return errors.New("injected stop failure")
+ default:
+ return nil
+ }
+ }
+
+ _, err := s.Schedule("xxvcc-a1", 1001, testGeneration, deadlineAfter(s, 6))
+ if err == nil || !strings.Contains(err.Error(), "injected stop failure") {
+ t.Fatalf("Schedule error = %v, want unconfirmed rollback stop", err)
+ }
+ if sys.atCommand != "" {
+ t.Fatalf("at fallback ran after timer stop remained uncertain: %q", sys.atCommand)
+ }
+ for _, suffix := range []string{".service", ".timer"} {
+ if _, statErr := os.Lstat(filepath.Join(dir, unit+suffix)); statErr != nil {
+ t.Fatalf("%s evidence was removed after stop failure: %v", suffix, statErr)
+ }
+ }
+}
+
func TestCancelStillReportsNonMissingTimerFailure(t *testing.T) {
dir := t.TempDir()
sys := &fakeSystem{hasSystemctl: true}
@@ -500,6 +712,63 @@ func TestCancelPropagatesAtRemovalFailure(t *testing.T) {
}
}
+func TestCancelNeverRemovesARecordedAtIDWithoutBodyVerification(t *testing.T) {
+ sys := &fakeSystem{hasAt: true}
+ s := newScheduler(t.TempDir(), sys)
+ if err := s.Cancel("xxvcc-a1", "at:42"); err != nil {
+ t.Fatal(err)
+ }
+ if len(sys.atrmd) != 0 {
+ t.Fatalf("Cancel passed a reusable recorded id directly to atrm: %v", sys.atrmd)
+ }
+ if len(sys.removedFor) != 1 {
+ t.Fatalf("Cancel did not use the verified command-body sweep: %v", sys.removedFor)
+ }
+}
+
+func TestCancelKeepsRecordedAtEvidenceWithoutInventoryBackend(t *testing.T) {
+ sys := &fakeSystem{}
+ s := newScheduler(t.TempDir(), sys)
+ err := s.Cancel("xxvcc-a1", "at:42")
+ if err == nil || !strings.Contains(err.Error(), "at backend is unavailable") {
+ t.Fatalf("Cancel error = %v, want missing-inventory refusal", err)
+ }
+ if len(sys.atrmd) != 0 {
+ t.Fatalf("Cancel passed an unverified recorded id directly to atrm: %v", sys.atrmd)
+ }
+}
+
+func TestCancelPreservesUnknownRecordedScheduleEvidence(t *testing.T) {
+ for _, recorded := range []string{"future-revoke-xxvcc-a1", "at:not-numeric", "at:0"} {
+ t.Run(recorded, func(t *testing.T) {
+ sys := &fakeSystem{}
+ s := newScheduler(t.TempDir(), sys)
+ err := s.Cancel("xxvcc-a1", recorded)
+ if err == nil || !strings.Contains(err.Error(), "unsupported recorded auto-revoke identifier") {
+ t.Fatalf("Cancel(%q) error = %v, want unsupported evidence refusal", recorded, err)
+ }
+ if len(sys.atrmd) != 0 {
+ t.Fatalf("Cancel passed an invalid recorded id to atrm: %v", sys.atrmd)
+ }
+ if len(sys.removedFor) != 1 {
+ t.Fatalf("Cancel did not still sweep exact known at commands: %v", sys.removedFor)
+ }
+ })
+ }
+}
+
+func TestCancelReloadsManagerWhenUnitFilesWereAlreadyAbsent(t *testing.T) {
+ sys := &fakeSystem{hasSystemctl: true}
+ s := newScheduler(t.TempDir(), sys)
+
+ if err := s.Cancel("xxvcc-a1", ""); err != nil {
+ t.Fatal(err)
+ }
+ if !calledSystemctl(sys.calls, "daemon-reload") {
+ t.Fatalf("Cancel did not reload manager-only cleanup: calls=%v", sys.calls)
+ }
+}
+
// TestCancelUnderFiringServiceRemovesBothFiles pins the successful firing path:
// the unit is already loaded, so unlinking its configuration and reloading does
// not stop the oneshot process and avoids a permanent orphaned .service.
@@ -541,9 +810,12 @@ func TestParseAtJobID(t *testing.T) {
cases := map[string]string{
"job 7 at Wed Jul 8 12:00:00 2026": "7",
"warning: commands will be executed\njob 12 at ...": "12",
- "9\tWed Jul 8": "9",
- "job -1 at ...": "",
- "nothing useful": "",
+ "9\tWed Jul 8": "",
+ "job 7 on Wed Jul 8": "",
+ "job 7 at ...\njob 8 at ...": "",
+ "job 0 at ...": "",
+ "job -1 at ...": "",
+ "nothing useful": "",
}
for in, want := range cases {
if got := parseAtJobID(in); got != want {
diff --git a/internal/schedule/system.go b/internal/schedule/system.go
index 53e20fd..7e18241 100644
--- a/internal/schedule/system.go
+++ b/internal/schedule/system.go
@@ -19,7 +19,9 @@ const (
schedulerOutputLimit = int64(64 << 10)
atQueueOutputLimit = int64(4 << 20)
atJobBodyLimit = int64(1 << 20)
+ atOwnerProbeLimit = int64(64 << 10)
maxAtJobs = 4096
+ maxLoadedSystemdUnits = 16384
)
var (
@@ -57,7 +59,7 @@ func has(name string) bool { _, err := exec.LookPath(name); return err == nil }
func (realSystem) HasSystemctl() bool { return has("systemctl") }
func (realSystem) HasAt() bool {
- return has("at") || has("atq") || has("atrm") || has("atd")
+ return has("at") || has("atq") || has("atrm") || has("atd") || has("batch")
}
func (realSystem) Systemctl(args ...string) error {
@@ -74,9 +76,61 @@ func (realSystem) Systemctl(args ...string) error {
return nil
}
-// systemctlUnitFileMissing reports only the exact, benign failure produced when
+// loadedSystemdUnits inventories the manager, not only unit files on disk.
+// A timer whose file was removed before daemon-reload can remain loaded and
+// armed, so uninstall must still be able to derive its account name.
+func (realSystem) loadedSystemdUnits() ([]string, error) {
+ if !has("systemctl") {
+ return nil, fmt.Errorf("systemctl is unavailable")
+ }
+ args := []string{
+ "list-units", "--all", "--type=service", "--type=timer",
+ "--plain", "--full", "--no-legend", "--no-pager",
+ }
+ out, err := executil.Output("systemctl", args, schedulerCommandOptions(atQueueOutputLimit))
+ if err != nil {
+ return nil, fmt.Errorf("systemctl list loaded schedule units: %w", err)
+ }
+ return parseLoadedSystemdUnits(string(out))
+}
+
+func parseLoadedSystemdUnits(out string) ([]string, error) {
+ var units []string
+ seen := make(map[string]bool)
+ sc := bufio.NewScanner(strings.NewReader(out))
+ sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit))
+ lineNo := 0
+ for sc.Scan() {
+ lineNo++
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ // list-units always emits UNIT LOAD ACTIVE SUB, with DESCRIPTION optional.
+ if len(fields) < 4 || (!strings.HasSuffix(fields[0], ".service") && !strings.HasSuffix(fields[0], ".timer")) {
+ return nil, fmt.Errorf("parse systemctl list-units line %d: %q", lineNo, line)
+ }
+ unit := fields[0]
+ if seen[unit] {
+ return nil, fmt.Errorf("parse systemctl list-units line %d: duplicate unit %q", lineNo, unit)
+ }
+ seen[unit] = true
+ units = append(units, unit)
+ if len(units) > maxLoadedSystemdUnits {
+ return nil, fmt.Errorf("systemd manager contains more than %d loaded service/timer units", maxLoadedSystemdUnits)
+ }
+ }
+ if err := sc.Err(); err != nil {
+ return nil, fmt.Errorf("parse systemctl list-units: %w", err)
+ }
+ return units, nil
+}
+
+// systemctlUnitFileMissing reports only the exact failure produced when
// `systemctl disable --now` races with (or follows) removal of its target unit.
-// All other exit failures remain visible to the caller.
+// It is not success by itself: systemctl returns from the disable phase before
+// --now reaches stop, so callers must independently confirm the timer is inactive.
func systemctlUnitFileMissing(err error, unit string) bool {
var commandErr *systemctlError
if !errors.As(err, &commandErr) || len(commandErr.args) != 3 {
@@ -89,7 +143,40 @@ func systemctlUnitFileMissing(err error, unit string) bool {
return commandErr.output == want
}
-func (realSystem) ScheduleAt(command string, hours int) (string, error) {
+func systemctlStopUnitNotLoaded(err error, unit string) bool {
+ var commandErr *systemctlError
+ if !errors.As(err, &commandErr) || len(commandErr.args) != 2 ||
+ commandErr.args[0] != "stop" || commandErr.args[1] != unit {
+ return false
+ }
+ want := fmt.Sprintf("Failed to stop %s: Unit %s not loaded.", unit, unit)
+ return commandErr.output == want
+}
+
+// systemctlTimerStoppedState accepts only explicit non-running states from the
+// non-quiet `is-active` query used after a successful stop. Exit status 3 alone
+// is insufficient because systemd also uses it for "activating".
+func systemctlTimerStoppedState(err error, timer string) bool {
+ var commandErr *systemctlError
+ if !errors.As(err, &commandErr) || len(commandErr.args) != 2 ||
+ commandErr.args[0] != "is-active" || commandErr.args[1] != timer {
+ return false
+ }
+ var exitErr *exec.ExitError
+ if !errors.As(commandErr, &exitErr) {
+ return errors.Is(commandErr, errSystemdUnitInactive) && commandErr.output == "inactive"
+ }
+ switch commandErr.output {
+ case "inactive", "failed":
+ return exitErr.ExitCode() == 3
+ case "unknown":
+ return exitErr.ExitCode() == 4
+ default:
+ return false
+ }
+}
+
+func (realSystem) ScheduleAt(command string, deadline time.Time) (string, error) {
for _, tool := range []string{"at", "atq", "atrm"} {
if !has(tool) {
return "", fmt.Errorf("%s is unavailable; refusing to create an at job that cannot be inventoried and cancelled", tool)
@@ -99,42 +186,75 @@ func (realSystem) ScheduleAt(command string, hours int) (string, error) {
return "", fmt.Errorf("atd is not running and could not be started; use systemd or start atd")
}
opts := schedulerCommandOptions(schedulerOutputLimit)
- opts.Stdin = strings.NewReader(command + "\n")
- out, err := executil.CombinedOutput("at", []string{"now", "+", strconv.Itoa(hours), "hours"}, opts)
+ // POSIX at -t is minute-granular and interprets its operand in the process
+ // timezone. Force UTC so DST gaps/folds cannot move the job by an hour. at
+ // copies its own environment into the queued script, so undo TZ before the
+ // revoke command to preserve the host's normal timezone at execution.
+ opts.ExtraEnv = append(opts.ExtraEnv, "TZ=UTC")
+ opts.Stdin = strings.NewReader("unset TZ\n" + command + "\n")
+ atTime := deadline.UTC().Format("200601021504")
+ out, err := executil.CombinedOutput("at", []string{"-t", atTime}, opts)
if err != nil {
- return "", fmt.Errorf("at: %w: %s", err, strings.TrimSpace(string(out)))
+ cause := fmt.Errorf("at: %w: %s", err, strings.TrimSpace(string(out)))
+ return "", (realSystem{}).cleanupAmbiguousAtSubmission(command, cause)
}
id := parseAtJobID(string(out))
if id == "" {
- return "", fmt.Errorf("could not parse at job id from %q", string(out))
+ cause := fmt.Errorf("could not parse at job id from %q", string(out))
+ return "", (realSystem{}).cleanupAmbiguousAtSubmission(command, cause)
}
return id, nil
}
-// parseAtJobID extracts the numeric job id from at's output ("job 7 at ...").
+// cleanupAmbiguousAtSubmission closes the commit-unknown window where `at` may
+// have queued a job before its process failed or emitted an unparseable id. The
+// current revoke command contains a random account generation, so exact-command
+// matches are owned retries of this same scheduling attempt rather than a broad
+// username selector. Inventory/removal uncertainty is joined with the original
+// error so the caller cannot mistake an unconfirmed rollback for a clean failure.
+func (r realSystem) cleanupAmbiguousAtSubmission(command string, cause error) error {
+ errs := []error{cause}
+ jobs, err := r.AtJobs()
+ if err != nil {
+ return errors.Join(cause, fmt.Errorf("inventory at jobs after ambiguous submission: %w", err))
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), atInventoryTimeout)
+ defer cancel()
+ for _, job := range jobs {
+ if job.OwnerUID != 0 || !atBodyHasExactCommand(job.Body, command) {
+ continue
+ }
+ err := r.removeAtJobIf(ctx, job.ID, func(body string) (bool, error) {
+ return rootAtBodyMatches(body, func(body string) (bool, error) {
+ return atBodyHasExactCommand(body, command), nil
+ })
+ })
+ if err != nil {
+ errs = append(errs, fmt.Errorf("roll back ambiguously submitted at job %s: %w", job.ID, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// parseAtJobID accepts exactly one C-locale submission record ("job 7 at ...").
+// Choosing the first of multiple candidates, or an unrelated numeric line, can
+// record another user's job while leaving the newly queued revoke untracked.
func parseAtJobID(out string) string {
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit))
+ id := ""
+ matches := 0
for sc.Scan() {
fields := strings.Fields(sc.Text())
- if len(fields) >= 2 && fields[0] == "job" {
- if numericJobID(fields[1]) {
- return fields[1]
- }
+ if len(fields) >= 3 && fields[0] == "job" && fields[2] == "at" && numericJobID(fields[1]) {
+ id = fields[1]
+ matches++
}
}
- // Fallback: first line whose first field is numeric.
- sc = bufio.NewScanner(strings.NewReader(out))
- sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit))
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- if len(fields) >= 1 {
- if numericJobID(fields[0]) {
- return fields[0]
- }
- }
+ if sc.Err() != nil || matches != 1 {
+ return ""
}
- return ""
+ return id
}
// ensureAtd confirms or starts the atd daemon so queued jobs actually fire. It
@@ -173,7 +293,10 @@ func ensureAtd() bool {
}
}
if has("pgrep") {
- return run("pgrep", "-x", "atd")
+ // atd starts as root and may drop its effective credentials to the daemon
+ // account, but its real UID remains 0. Binding the fallback probe to real root
+ // prevents an unprivileged process from spoofing only the short name "atd".
+ return run("pgrep", "-x", "-U", "0", "atd")
}
return false
}
@@ -221,18 +344,51 @@ func atJobQueuedContext(ctx context.Context, id string) (bool, error) {
if err != nil {
return false, fmt.Errorf("atq: %w", err)
}
- sc := bufio.NewScanner(strings.NewReader(string(out)))
+ ids, err := parseAtQueueIDs(string(out))
+ if err != nil {
+ return false, err
+ }
+ for _, queuedID := range ids {
+ if queuedID == id {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// parseAtQueueIDs treats every non-empty atq line as inventory evidence. A
+// malformed or duplicate line must not be skipped: doing so can turn an
+// incomplete queue into "job absent" and authorize cleanup of the last witness.
+func parseAtQueueIDs(out string) ([]string, error) {
+ var ids []string
+ seen := make(map[string]bool)
+ sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit))
+ lineNo := 0
for sc.Scan() {
- fields := strings.Fields(sc.Text())
- if len(fields) > 0 && fields[0] == id {
- return true, nil
+ lineNo++
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) == 0 || !numericJobID(fields[0]) {
+ return nil, fmt.Errorf("parse atq line %d: invalid job id in %q", lineNo, line)
+ }
+ id := fields[0]
+ if seen[id] {
+ return nil, fmt.Errorf("parse atq line %d: duplicate job id %s", lineNo, id)
+ }
+ seen[id] = true
+ ids = append(ids, id)
+ if len(ids) > maxAtJobs {
+ return nil, fmt.Errorf("at queue contains more than %d inspectable jobs", maxAtJobs)
}
}
if err := sc.Err(); err != nil {
- return false, err
+ return nil, fmt.Errorf("parse atq: %w", err)
}
- return false, nil
+ return ids, nil
}
func (r realSystem) RemoveAtJobsFor(command string) error {
@@ -252,9 +408,23 @@ func (r realSystem) RemoveAtJobsFor(command string) error {
return err
}
var errs []error
+ ctx, cancel := context.WithTimeout(context.Background(), atInventoryTimeout)
+ defer cancel()
for _, job := range jobs {
- if atBodyHasKnownRevoke(job.Body, selector.installPath, selector.user) {
- if err := r.AtrmJob(job.ID); err != nil {
+ if job.OwnerUID != 0 {
+ continue
+ }
+ match, inspectErr := atBodyHasKnownRevoke(job.Body, selector.installPath, selector.user)
+ if inspectErr != nil {
+ errs = append(errs, fmt.Errorf("inspect at job %s: %w", job.ID, inspectErr))
+ continue
+ }
+ if match {
+ if err := r.removeAtJobIf(ctx, job.ID, func(body string) (bool, error) {
+ return rootAtBodyMatches(body, func(body string) (bool, error) {
+ return atBodyHasKnownRevoke(body, selector.installPath, selector.user)
+ })
+ }); err != nil {
errs = append(errs, err)
}
}
@@ -262,6 +432,138 @@ func (r realSystem) RemoveAtJobsFor(command string) error {
return errors.Join(errs...)
}
+// removeAtJobIf binds an at deletion to a fresh body read. At job IDs are
+// eventually reusable, so a body observed during the earlier queue inventory is
+// not authority to pass that ID to atrm later. If the ID now names an unrelated
+// job, the original target is already gone and the replacement is preserved.
+// GNU/POSIX at exposes no atomic compare-and-delete primitive; this recheck makes
+// the remaining read-to-atrm interval as small as the external interface allows.
+func (r realSystem) removeAtJobIf(ctx context.Context, id string, match func(string) (bool, error)) error {
+ if !numericJobID(id) {
+ return fmt.Errorf("invalid at job id %q", id)
+ }
+ if match == nil {
+ return fmt.Errorf("at job matcher is not configured")
+ }
+ if !has("at") || !has("atq") || !has("atrm") {
+ return fmt.Errorf("complete at inventory/removal tools are unavailable")
+ }
+ body, owner, present, err := readAtJobContext(ctx, id)
+ if err != nil {
+ return fmt.Errorf("revalidate at job %s before removal: %w", id, err)
+ }
+ if !present || owner != 0 {
+ return nil
+ }
+ matched, err := match(body)
+ if err != nil {
+ return fmt.Errorf("revalidate at job %s before removal: %w", id, err)
+ }
+ if !matched {
+ return nil
+ }
+ opts := schedulerCommandOptions(schedulerOutputLimit)
+ opts.Context = ctx
+ out, removeErr := executil.CombinedOutput("atrm", []string{id}, opts)
+ current, currentOwner, stillPresent, inspectErr := readAtJobContext(ctx, id)
+ if inspectErr != nil {
+ if removeErr != nil {
+ return errors.Join(
+ fmt.Errorf("atrm %s: %w: %s", id, removeErr, strings.TrimSpace(string(out))),
+ fmt.Errorf("recheck at job %s: %w", id, inspectErr),
+ )
+ }
+ return fmt.Errorf("recheck at job %s after atrm success: %w", id, inspectErr)
+ }
+ if !stillPresent {
+ return nil
+ }
+ if currentOwner != 0 {
+ return nil
+ }
+ stillMatched, matchErr := match(current)
+ if matchErr != nil {
+ if removeErr != nil {
+ return errors.Join(
+ fmt.Errorf("atrm %s: %w: %s", id, removeErr, strings.TrimSpace(string(out))),
+ fmt.Errorf("recheck at job %s: %w", id, matchErr),
+ )
+ }
+ return fmt.Errorf("recheck at job %s after atrm success: %w", id, matchErr)
+ }
+ if !stillMatched {
+ return nil
+ }
+ if removeErr != nil {
+ return fmt.Errorf("atrm %s: %w: %s", id, removeErr, strings.TrimSpace(string(out)))
+ }
+ return fmt.Errorf("atrm %s reported success but the matching job remains queued", id)
+}
+
+// readAtJobContext probes the generated owner header under a small output bound
+// before retaining a body. Non-root users can queue arbitrarily large jobs; an
+// owner-first probe lets complete root inventory ignore those bodies without
+// letting their size poison cleanup or uninstall. Root jobs are read in full
+// under the ordinary body bound and their owner header is checked again.
+func readAtJobContext(ctx context.Context, id string) (string, uint32, bool, error) {
+ if !numericJobID(id) {
+ return "", 0, false, fmt.Errorf("invalid at job id %q", id)
+ }
+ opts := schedulerCommandOptions(atOwnerProbeLimit)
+ opts.Context = ctx
+ prefix, err := executil.Output("at", []string{"-c", id}, opts)
+ if err != nil && !errors.Is(err, executil.ErrOutputLimit) {
+ queued, queueErr := atJobQueuedContext(ctx, id)
+ if queueErr != nil {
+ return "", 0, false, errors.Join(
+ fmt.Errorf("read at job %s: %w", id, err),
+ fmt.Errorf("recheck at job %s: %w", id, queueErr),
+ )
+ }
+ if !queued {
+ return "", 0, false, nil
+ }
+ return "", 0, false, fmt.Errorf("read at job %s: %w", id, err)
+ }
+ owner, ownerErr := parseAtOwner(prefix)
+ if ownerErr != nil {
+ return "", 0, false, ownerErr
+ }
+ if owner != 0 {
+ return "", owner, true, nil
+ }
+ if err == nil {
+ return string(prefix), owner, true, nil
+ }
+
+ // A root-owned body exceeded the owner probe. Read it once under the full
+ // bound, then require the same root header from those exact bytes.
+ opts = schedulerCommandOptions(atJobBodyLimit)
+ opts.Context = ctx
+ body, err := executil.Output("at", []string{"-c", id}, opts)
+ if err != nil {
+ queued, queueErr := atJobQueuedContext(ctx, id)
+ if queueErr != nil {
+ return "", 0, false, errors.Join(
+ fmt.Errorf("read at job %s: %w", id, err),
+ fmt.Errorf("recheck at job %s: %w", id, queueErr),
+ )
+ }
+ if !queued {
+ return "", 0, false, nil
+ }
+ return "", 0, false, fmt.Errorf("read at job %s: %w", id, err)
+ }
+ owner, ownerErr = parseAtOwner(body)
+ if ownerErr != nil {
+ return "", 0, false, ownerErr
+ }
+ if owner != 0 {
+ return "", owner, true, nil
+ }
+ return string(body), owner, true, nil
+}
+
func (realSystem) AtJobs() ([]AtJob, error) {
if !has("atq") {
return nil, fmt.Errorf("atq is unavailable")
@@ -277,50 +579,79 @@ func (realSystem) AtJobs() ([]AtJob, error) {
if err != nil {
return nil, fmt.Errorf("atq: %w", err)
}
+ ids, err := parseAtQueueIDs(string(out))
+ if err != nil {
+ return nil, err
+ }
var jobs []AtJob
- inspected := 0
totalBodyBytes := int64(0)
- sc := bufio.NewScanner(strings.NewReader(string(out)))
- sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit))
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- if len(fields) == 0 {
- continue
+ for _, id := range ids {
+ body, owner, present, err := readAtJobContext(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("inspect at job %s: %w", id, err)
}
- id := fields[0]
- if !numericJobID(id) {
+ if !present {
continue
}
- inspected++
- if inspected > maxAtJobs {
- return nil, fmt.Errorf("at queue contains more than %d inspectable jobs", maxAtJobs)
- }
- bodyOpts := schedulerCommandOptions(atJobBodyLimit)
- bodyOpts.Context = ctx
- body, err := executil.Output("at", []string{"-c", id}, bodyOpts)
- if err != nil {
- queued, queueErr := atJobQueuedContext(ctx, id)
- if queueErr != nil {
- return nil, errors.Join(
- fmt.Errorf("read at job %s: %w", id, err),
- fmt.Errorf("recheck at job %s: %w", id, queueErr),
- )
- }
- if !queued {
- continue
- }
- return nil, fmt.Errorf("read at job %s: %w", id, err)
+ if owner != 0 {
+ jobs = append(jobs, AtJob{ID: id, OwnerUID: owner})
+ continue
}
totalBodyBytes += int64(len(body))
if totalBodyBytes > atInventoryMaxBodyBytes {
return nil, fmt.Errorf("at job inventory exceeds %d bytes", atInventoryMaxBodyBytes)
}
- jobs = append(jobs, AtJob{ID: id, Body: string(body)})
+ jobs = append(jobs, AtJob{ID: id, Body: body, OwnerUID: owner})
+ }
+ return jobs, nil
+}
+
+// parseAtOwner reads the first root-controlled atrun header emitted by at -c.
+// User-supplied command text can contain an identical-looking comment later, so
+// the first atrun-shaped header is authoritative and malformed data fails closed.
+func parseAtOwner(body []byte) (uint32, error) {
+ sc := bufio.NewScanner(strings.NewReader(string(body)))
+ sc.Buffer(make([]byte, 1024), int(atJobBodyLimit))
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) < 2 || fields[0] != "#" || fields[1] != "atrun" {
+ continue
+ }
+ if len(fields) != 4 || !strings.HasPrefix(fields[2], "uid=") || !strings.HasPrefix(fields[3], "gid=") {
+ return 0, fmt.Errorf("invalid atrun owner header")
+ }
+ uid, err := parseAtKernelID(strings.TrimPrefix(fields[2], "uid="))
+ if err != nil {
+ return 0, fmt.Errorf("invalid atrun UID %q", fields[2])
+ }
+ if _, err := parseAtKernelID(strings.TrimPrefix(fields[3], "gid=")); err != nil {
+ return 0, fmt.Errorf("invalid atrun GID %q", fields[3])
+ }
+ return uid, nil
}
if err := sc.Err(); err != nil {
- return nil, err
+ return 0, fmt.Errorf("scan at job: %w", err)
}
- return jobs, nil
+ return 0, fmt.Errorf("job has no atrun owner header")
+}
+
+func parseAtKernelID(value string) (uint32, error) {
+ id, err := strconv.ParseUint(value, 10, 32)
+ if err != nil || id == uint64(^uint32(0)) {
+ return 0, fmt.Errorf("invalid kernel ID %q", value)
+ }
+ return uint32(id), nil
+}
+
+func rootAtBodyMatches(body string, match func(string) (bool, error)) (bool, error) {
+ owner, err := parseAtOwner([]byte(body))
+ if err != nil {
+ return false, err
+ }
+ if owner != 0 {
+ return false, nil
+ }
+ return match(body)
}
type atRevokeKind uint8
@@ -384,11 +715,44 @@ func parseAtRevokeCommand(line, expectedInstallPath string) (atRevokeCommand, bo
return parsed, true
}
-func atBodyHasKnownRevoke(body, installPath, user string) bool {
+func atBodyHasKnownRevoke(body, installPath, user string) (bool, error) {
+ matched := false
for _, line := range strings.Split(body, "\n") {
command, ok := parseAtRevokeCommand(line, installPath)
if ok && command.user == user {
- return true
+ matched = true
+ continue
+ }
+ if !ok && atLineTargetsRevoke(line, installPath, user) {
+ return false, fmt.Errorf("owned revoke command for %s has an unsupported or corrupt shape", user)
+ }
+ }
+ return matched, nil
+}
+
+// atLineTargetsRevoke identifies an owned-looking command without authorizing
+// deletion of its job. It is used only to fail closed when the exact parser
+// rejects a command that still invokes this installation's revoke entry point.
+func atLineTargetsRevoke(line, installPath, user string) bool {
+ fields := strings.Fields(strings.TrimSpace(line))
+ if installPath == "" || len(fields) < 2 || fields[0] != installPath || fields[1] != "revoke" {
+ return false
+ }
+ if user == "" {
+ return true
+ }
+ for i := 2; i < len(fields); i++ {
+ switch fields[i] {
+ case "--user", "-user":
+ if i+1 < len(fields) && fields[i+1] == user {
+ return true
+ }
+ default:
+ for _, prefix := range []string{"--user=", "-user="} {
+ if strings.TrimPrefix(fields[i], prefix) == user && strings.HasPrefix(fields[i], prefix) {
+ return true
+ }
+ }
}
}
return false
diff --git a/internal/schedule/system_test.go b/internal/schedule/system_test.go
index 7cad34d..ecc0e3e 100644
--- a/internal/schedule/system_test.go
+++ b/internal/schedule/system_test.go
@@ -3,6 +3,7 @@ package schedule
import (
"context"
"errors"
+ "fmt"
"os"
"path/filepath"
"strconv"
@@ -21,6 +22,25 @@ func writeCommand(t *testing.T, dir, name, body string) {
}
}
+func TestRealSystemHasAtDetectsEveryBackendFootprint(t *testing.T) {
+ for _, command := range []string{"at", "atq", "atrm", "atd", "batch"} {
+ t.Run(command, func(t *testing.T) {
+ dir := t.TempDir()
+ writeCommand(t, dir, command, "exit 0")
+ t.Setenv("PATH", dir)
+ if !(realSystem{}).HasAt() {
+ t.Fatalf("HasAt did not detect %s", command)
+ }
+ })
+ }
+ t.Run("absent", func(t *testing.T) {
+ t.Setenv("PATH", t.TempDir())
+ if (realSystem{}).HasAt() {
+ t.Fatal("HasAt reported an absent backend")
+ }
+ })
+}
+
func TestAtrmJobTreatsAlreadyAbsentAsSuccess(t *testing.T) {
dir := t.TempDir()
writeCommand(t, dir, "atq", "exit 0")
@@ -118,6 +138,31 @@ func TestSystemctlTimerStateClassification(t *testing.T) {
}
}
+func TestSystemctlStoppedStateRejectsActivating(t *testing.T) {
+ const unit = "linux-temp-admin-v2-revoke-xxvcc-a1.timer"
+ for _, tc := range []struct {
+ state string
+ exit int
+ want bool
+ }{
+ {state: "inactive", exit: 3, want: true},
+ {state: "failed", exit: 3, want: true},
+ {state: "unknown", exit: 4, want: true},
+ {state: "activating", exit: 3, want: false},
+ {state: "deactivating", exit: 3, want: false},
+ } {
+ t.Run(tc.state, func(t *testing.T) {
+ dir := t.TempDir()
+ writeCommand(t, dir, "systemctl", fmt.Sprintf("echo %s; exit %d", tc.state, tc.exit))
+ t.Setenv("PATH", dir)
+ err := (realSystem{}).Systemctl("is-active", unit)
+ if got := systemctlTimerStoppedState(err, unit); got != tc.want {
+ t.Fatalf("systemctlTimerStoppedState(%v) = %v, want %v", err, got, tc.want)
+ }
+ })
+ }
+}
+
func TestAtJobsFailsClosedWhenInventoryCommandsAreMissing(t *testing.T) {
t.Run("atq", func(t *testing.T) {
dir := t.TempDir()
@@ -143,18 +188,101 @@ func TestAtJobsFailsClosedWhenInventoryCommandsAreMissing(t *testing.T) {
func TestAtJobsInventoryDoesNotRequireAtrm(t *testing.T) {
dir := t.TempDir()
writeCommand(t, dir, "atq", "printf '42\\tFri Jul 24 00:00:00 2026 a root\\n'")
- writeCommand(t, dir, "at", "printf '%s\\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes'")
+ writeCommand(t, dir, "at", "printf '%s\\n' '# atrun uid=0 gid=0' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes'")
t.Setenv("PATH", dir)
jobs, err := (realSystem{}).AtJobs()
if err != nil {
t.Fatal(err)
}
- if len(jobs) != 1 || jobs[0].ID != "42" || !strings.Contains(jobs[0].Body, "revoke --user xxvcc-a1") {
+ if len(jobs) != 1 || jobs[0].ID != "42" || jobs[0].OwnerUID != 0 || !strings.Contains(jobs[0].Body, "revoke --user xxvcc-a1") {
t.Fatalf("AtJobs = %#v, want job 42 without atrm installed", jobs)
}
}
+func TestAtJobsRequiresCanonicalOwnerHeader(t *testing.T) {
+ for _, body := range []string{
+ "/usr/local/sbin/linux-temp-admin revoke --user forged --yes\n",
+ "# atrun owner unknown\n# atrun uid=0 gid=0\n",
+ "# atrun uid=4294967295 gid=0\n",
+ } {
+ dir := t.TempDir()
+ writeCommand(t, dir, "atq", "printf '42 x\\n'")
+ writeCommand(t, dir, "at", "printf '%s' '"+body+"'")
+ t.Setenv("PATH", dir)
+ if _, err := (realSystem{}).AtJobs(); err == nil {
+ t.Fatalf("AtJobs accepted owner body %q: %v", body, err)
+ }
+ }
+}
+
+func TestAtJobsSkipsOversizedNonRootBodyAfterOwnerProbe(t *testing.T) {
+ dir := t.TempDir()
+ writeCommand(t, dir, "atq", "printf '42 x\\n'")
+ writeCommand(t, dir, "at", "printf '%s\\n' '# atrun uid=1001 gid=1001'; while :; do printf 0123456789abcdef; done")
+ t.Setenv("PATH", dir)
+
+ jobs, err := (realSystem{}).AtJobs()
+ if err != nil {
+ t.Fatalf("oversized non-root body poisoned root inventory: %v", err)
+ }
+ if len(jobs) != 1 || jobs[0].ID != "42" || jobs[0].OwnerUID != 1001 || jobs[0].Body != "" {
+ t.Fatalf("AtJobs = %#v, want owner-only non-root inventory", jobs)
+ }
+}
+
+func TestAtQueueParsingFailsClosedOnMalformedOrDuplicateLines(t *testing.T) {
+ for _, output := range []string{
+ "warning: partial queue output\n42 x\n",
+ "42 x\n42 duplicate\n",
+ } {
+ if _, err := parseAtQueueIDs(output); err == nil {
+ t.Fatalf("parseAtQueueIDs(%q) succeeded, want incomplete-inventory refusal", output)
+ }
+ }
+
+ dir := t.TempDir()
+ writeCommand(t, dir, "atq", "printf '%s\\n' 'corrupt queue line'")
+ t.Setenv("PATH", dir)
+ if queued, err := atJobQueued("42"); err == nil || queued {
+ t.Fatalf("atJobQueued malformed inventory = %v, %v; want false, error", queued, err)
+ }
+}
+
+func TestAtJobsRejectsMalformedQueueInsteadOfSilentlySkippingIt(t *testing.T) {
+ dir := t.TempDir()
+ marker := filepath.Join(dir, "at-called")
+ writeCommand(t, dir, "atq", "printf 'broken-line\\n42 x\\n'")
+ writeCommand(t, dir, "at", ": > '"+marker+"'")
+ t.Setenv("PATH", dir)
+
+ if _, err := (realSystem{}).AtJobs(); err == nil || !strings.Contains(err.Error(), "parse atq line 1") {
+ t.Fatalf("AtJobs malformed queue error = %v", err)
+ }
+ if _, err := os.Lstat(marker); !os.IsNotExist(err) {
+ t.Fatalf("AtJobs inspected bodies after malformed queue: %v", err)
+ }
+}
+
+func TestLoadedSystemdUnitsParsesBoundedCompleteInventory(t *testing.T) {
+ dir := t.TempDir()
+ writeCommand(t, dir, "systemctl", `printf '%s\n' \
+ 'linux-temp-admin-v2-revoke-loaded.timer loaded active waiting description' \
+ 'linux-temp-admin-v2-revoke-loaded.service loaded inactive dead'`)
+ t.Setenv("PATH", dir)
+
+ units, err := (realSystem{}).loadedSystemdUnits()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(units, ",") != "linux-temp-admin-v2-revoke-loaded.timer,linux-temp-admin-v2-revoke-loaded.service" {
+ t.Fatalf("loaded systemd units = %v", units)
+ }
+ if _, err := parseLoadedSystemdUnits("truncated output\n"); err == nil {
+ t.Fatal("parseLoadedSystemdUnits accepted an incomplete manager line")
+ }
+}
+
func TestAtJobsSkipsJobThatDisappearsAfterAtq(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "listed")
@@ -175,7 +303,7 @@ func TestAtJobsBoundsWholeInventorySizeAndTime(t *testing.T) {
t.Run("aggregate body size", func(t *testing.T) {
dir := t.TempDir()
writeCommand(t, dir, "atq", "printf '1 x\\n2 x\\n'")
- writeCommand(t, dir, "at", "printf '12345\\n'")
+ writeCommand(t, dir, "at", "printf '%s\\n' '# atrun uid=0 gid=0' '12345'")
t.Setenv("PATH", dir)
oldLimit := atInventoryMaxBodyBytes
atInventoryMaxBodyBytes = 8
@@ -213,6 +341,16 @@ func TestEnsureAtdRejectsWhenNoProbeCanConfirmIt(t *testing.T) {
}
}
+func TestEnsureAtdPgrepFallbackRequiresRootRealUID(t *testing.T) {
+ dir := t.TempDir()
+ writeCommand(t, dir, "pgrep", `[ "$1" = -x ] && [ "$2" = -U ] && [ "$3" = 0 ] && [ "$4" = atd ]`)
+ t.Setenv("PATH", dir)
+
+ if !ensureAtd() {
+ t.Fatal("ensureAtd did not accept the root-bound pgrep confirmation")
+ }
+}
+
func TestEnsureAtdDoesNotTrustServiceStartExitAlone(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "start-called")
@@ -234,7 +372,7 @@ func TestScheduleAtRequiresCancellationToolsBeforeQueueing(t *testing.T) {
writeCommand(t, dir, "atq", "exit 0")
t.Setenv("PATH", dir)
- _, err := (realSystem{}).ScheduleAt("true", 1)
+ _, err := (realSystem{}).ScheduleAt("true", time.Date(2030, 1, 2, 3, 4, 0, 0, time.UTC))
if err == nil || !strings.Contains(err.Error(), "atrm is unavailable") {
t.Fatalf("ScheduleAt error = %v, want missing atrm refusal", err)
}
@@ -248,26 +386,69 @@ func TestScheduleAtForcesCLocaleBeforeParsingJobID(t *testing.T) {
writeCommand(t, dir, "atq", "exit 0")
writeCommand(t, dir, "atrm", "exit 0")
writeCommand(t, dir, "pgrep", "exit 0")
- writeCommand(t, dir, "at", "[ \"$LC_ALL\" = C ] || { echo localized-output >&2; exit 9; }; while read line; do :; done; echo 'job 7 at Fri Jul 24 00:00:00 2026'")
+ writeCommand(t, dir, "at", "[ \"$LC_ALL\" = C ] || { echo localized-output >&2; exit 9; }; [ \"$TZ\" = UTC ] || { echo wrong-timezone >&2; exit 8; }; [ \"$1\" = -t ] && [ \"$2\" = 203001020804 ] || { echo wrong-deadline >&2; exit 7; }; IFS= read -r first; IFS= read -r second; [ \"$first\" = 'unset TZ' ] && [ \"$second\" = true ] || { echo wrong-job-body >&2; exit 6; }; echo 'job 7 at Fri Jul 24 00:00:00 2026'")
t.Setenv("PATH", dir)
t.Setenv("LC_ALL", "C.UTF-8")
- id, err := (realSystem{}).ScheduleAt("true", 1)
+ id, err := (realSystem{}).ScheduleAt("true", time.Date(2030, 1, 2, 3, 4, 0, 0, time.FixedZone("local", -5*60*60)))
if err != nil || id != "7" {
t.Fatalf("ScheduleAt id=%q err=%v, want C-locale job 7", id, err)
}
}
+func TestScheduleAtRollsBackAmbiguouslySubmittedJob(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ queueExit string
+ want string
+ }{
+ {name: "command error after queue", queueExit: "echo queued-but-failed >&2; exit 1", want: "queued-but-failed"},
+ {name: "unparseable job id", queueExit: "echo accepted-without-an-id; exit 0", want: "could not parse at job id"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ queued := filepath.Join(dir, "queued")
+ body := filepath.Join(dir, "body")
+ removed := filepath.Join(dir, "removed")
+ writeCommand(t, dir, "pgrep", "exit 0")
+ writeCommand(t, dir, "atq", "[ -f '"+queued+"' ] && printf '42 x\\n'; exit 0")
+ writeCommand(t, dir, "atrm", "/bin/rm -f '"+queued+"'; printf '%s\\n' \"$1\" > '"+removed+"'")
+ writeCommand(t, dir, "at", `if [ "$1" = "-c" ]; then
+ printf '%s\n' '# atrun uid=0 gid=0'
+ /bin/cat '`+body+`'
+ exit 0
+fi
+/bin/cat > '`+body+`'
+: > '`+queued+`'
+`+tc.queueExit)
+ t.Setenv("PATH", dir)
+
+ command := "/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef"
+ _, scheduleErr := (realSystem{}).ScheduleAt(command, time.Date(2030, 1, 2, 3, 4, 0, 0, time.UTC))
+ if scheduleErr == nil || !strings.Contains(scheduleErr.Error(), tc.want) {
+ t.Fatalf("ScheduleAt error = %v, want %q", scheduleErr, tc.want)
+ }
+ if _, err := os.Lstat(queued); !os.IsNotExist(err) {
+ t.Fatalf("ambiguously submitted job survived rollback: stat=%v schedule=%v", err, scheduleErr)
+ }
+ if got, err := os.ReadFile(removed); err != nil || strings.TrimSpace(string(got)) != "42" {
+ t.Fatalf("rolled-back job id = %q err=%v, want 42", got, err)
+ }
+ })
+ }
+}
+
func TestRemoveAtJobsForMatchesOnlyKnownStandaloneRevokeCommand(t *testing.T) {
dir := t.TempDir()
removed := filepath.Join(dir, "removed")
- writeCommand(t, dir, "atq", "printf '1 x\\n2 x\\n3 x\\n4 x\\n5 x\\n'")
- writeCommand(t, dir, "at", `case "$2" in
-1) printf '%s\n' '# /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes' ;;
-2) printf '%s\n' 'echo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes' ;;
-3) printf '%s\n' '/usr/local/sbin/linux-temp-admin-helper revoke --user xxvcc-a1 --yes' ;;
-4) printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --unknown' ;;
-5) printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef' ;;
+ writeCommand(t, dir, "atq", "printf '1 x\\n2 x\\n3 x\\n'; [ -f '"+removed+"' ] || printf '5 x\\n'")
+ writeCommand(t, dir, "at", `printf '%s\n' '# atrun uid=0 gid=0'
+ case "$2" in
+ 1) printf '%s\n' '# /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes' ;;
+ 2) printf '%s\n' 'echo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes' ;;
+ 3) printf '%s\n' '/usr/local/sbin/linux-temp-admin-helper revoke --user xxvcc-a1 --yes' ;;
+5) [ ! -f '`+removed+`' ] || exit 1
+ printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef' ;;
esac`)
writeCommand(t, dir, "atrm", "printf '%s\\n' \"$1\" >> '"+removed+"'")
t.Setenv("PATH", dir)
@@ -285,6 +466,129 @@ esac`)
}
}
+func TestRemoveAtJobsForIgnoresNonRootMimic(t *testing.T) {
+ dir := t.TempDir()
+ removed := filepath.Join(dir, "removed")
+ writeCommand(t, dir, "atq", "printf '9 x\\n'")
+ writeCommand(t, dir, "at", "printf '%s\\n' '# atrun uid=1001 gid=1001' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --unknown'")
+ writeCommand(t, dir, "atrm", "printf '%s\\n' \"$1\" > '"+removed+"'")
+ t.Setenv("PATH", dir)
+
+ s := newScheduler(dir, realSystem{})
+ if err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1")); err != nil {
+ t.Fatalf("non-root mimic poisoned root schedule cleanup: %v", err)
+ }
+ if _, err := os.Lstat(removed); !os.IsNotExist(err) {
+ t.Fatalf("non-root mimic reached atrm: %v", err)
+ }
+}
+
+func TestRemoveAtJobsForDoesNotDeleteAReusedJobID(t *testing.T) {
+ dir := t.TempDir()
+ reads := filepath.Join(dir, "reads")
+ removed := filepath.Join(dir, "removed")
+ writeCommand(t, dir, "atq", "printf '7 x\\n'")
+ writeCommand(t, dir, "at", `count=0
+if [ -f '`+reads+`' ]; then count=$(/bin/cat '`+reads+`'); fi
+count=$((count + 1))
+ printf '%s\n' "$count" > '`+reads+`'
+ printf '%s\n' '# atrun uid=0 gid=0'
+ if [ "$count" -eq 1 ]; then
+ printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef'
+else
+ printf '%s\n' '/usr/bin/true'
+fi`)
+ writeCommand(t, dir, "atrm", "printf '%s\\n' \"$1\" > '"+removed+"'")
+ t.Setenv("PATH", dir)
+
+ s := newScheduler(dir, realSystem{})
+ if err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(removed); !os.IsNotExist(err) {
+ t.Fatalf("replacement job reached atrm through a reused ID: %v", err)
+ }
+}
+
+func TestRemoveAtJobsForVerifiesAtrmSuccess(t *testing.T) {
+ dir := t.TempDir()
+ writeCommand(t, dir, "atq", "printf '7 x\\n'")
+ writeCommand(t, dir, "at", "printf '%s\\n' '# atrun uid=0 gid=0' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef'")
+ writeCommand(t, dir, "atrm", "exit 0")
+ t.Setenv("PATH", dir)
+
+ s := newScheduler(dir, realSystem{})
+ err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1"))
+ if err == nil || !strings.Contains(err.Error(), "reported success but the matching job remains") {
+ t.Fatalf("RemoveAtJobsFor no-op atrm error = %v, want surviving-target refusal", err)
+ }
+}
+
+func TestRemoveAtJobsForAcceptsReusedIDAfterAtrmSuccess(t *testing.T) {
+ dir := t.TempDir()
+ reads := filepath.Join(dir, "reads")
+ writeCommand(t, dir, "atq", "printf '7 x\\n'")
+ writeCommand(t, dir, "at", `count=0
+if [ -f '`+reads+`' ]; then count=$(/bin/cat '`+reads+`'); fi
+count=$((count + 1))
+ printf '%s\n' "$count" > '`+reads+`'
+ printf '%s\n' '# atrun uid=0 gid=0'
+ if [ "$count" -le 2 ]; then
+ printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef'
+else
+ printf '%s\n' '/usr/bin/true'
+fi`)
+ writeCommand(t, dir, "atrm", "exit 0")
+ t.Setenv("PATH", dir)
+
+ s := newScheduler(dir, realSystem{})
+ if err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1")); err != nil {
+ t.Fatalf("post-atrm ID reuse was treated as a surviving target: %v", err)
+ }
+}
+
+func TestRemoveAtJobsForFailsClosedOnMalformedOwnedCommand(t *testing.T) {
+ dir := t.TempDir()
+ removed := filepath.Join(dir, "removed")
+ writeCommand(t, dir, "atq", "printf '4 x\\n'")
+ writeCommand(t, dir, "at", "printf '%s\\n' '# atrun uid=0 gid=0' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --unknown'")
+ writeCommand(t, dir, "atrm", "printf '%s\\n' \"$1\" > '"+removed+"'")
+ t.Setenv("PATH", dir)
+
+ s := newScheduler(dir, realSystem{})
+ err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1"))
+ if err == nil || !strings.Contains(err.Error(), "unsupported or corrupt") {
+ t.Fatalf("RemoveAtJobsFor error = %v, want malformed owned-job refusal", err)
+ }
+ if _, err := os.Lstat(removed); !os.IsNotExist(err) {
+ t.Fatalf("malformed job was removed instead of preserved: %v", err)
+ }
+}
+
+func TestRemoveAtJobsForDetectsMalformedTargetWithReorderedOrEqualsUserFlag(t *testing.T) {
+ for _, command := range []string{
+ "/usr/local/sbin/linux-temp-admin revoke --yes --user xxvcc-a1 --unknown",
+ "/usr/local/sbin/linux-temp-admin revoke --user=xxvcc-a1 --yes --unknown",
+ "/usr/local/sbin/linux-temp-admin revoke -user xxvcc-a1 --yes --unknown",
+ } {
+ t.Run(command, func(t *testing.T) {
+ match, err := atBodyHasKnownRevoke(command, "/usr/local/sbin/linux-temp-admin", "xxvcc-a1")
+ if err == nil || match {
+ t.Fatalf("atBodyHasKnownRevoke(%q) = %v, %v; want false, error", command, match, err)
+ }
+ })
+ }
+}
+
+func TestAtBodyKnownRevokeScansPastMatchForMalformedOwnedCommand(t *testing.T) {
+ body := "/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes\n" +
+ "/usr/local/sbin/linux-temp-admin revoke --yes --user xxvcc-a1 --unknown\n"
+ match, err := atBodyHasKnownRevoke(body, "/usr/local/sbin/linux-temp-admin", "xxvcc-a1")
+ if err == nil || match {
+ t.Fatalf("atBodyHasKnownRevoke mixed body = %v, %v; want false, error", match, err)
+ }
+}
+
func TestParseAtRevokeCommandRejectsReservedLinuxUID(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot represent the reserved uint32 uid sentinel")
diff --git a/internal/schedule/valid.go b/internal/schedule/valid.go
index 7107349..0d6c94e 100644
--- a/internal/schedule/valid.go
+++ b/internal/schedule/valid.go
@@ -28,6 +28,9 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit
if !validate.Username(user) || !validate.AccountID(uid) || !validate.Generation(generation) {
return false, nil
}
+ if s == nil {
+ return false, fmt.Errorf("inventory schedule: no scheduler configured")
+ }
if strings.HasPrefix(recordedUnit, "at:") {
id := strings.TrimPrefix(recordedUnit, "at:")
@@ -46,7 +49,7 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit
if job.ID != id {
continue
}
- if found || !atBodyHasExactCommand(job.Body, s.RevokeCommand(user, uid, generation)) {
+ if found || job.OwnerUID != 0 || !atBodyHasExactCommand(job.Body, s.RevokeCommand(user, uid, generation)) {
return false, nil
}
found = true
@@ -71,7 +74,7 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit
}
calendar, ok := uniqueCalendar(timer)
- if !ok || string(timer) != timerContent(unit, calendar) {
+ if !ok || (string(timer) != timerContent(unit, calendar) && string(timer) != legacyTimerContent(unit, calendar)) {
return false, nil
}
trigger, err := time.Parse("2006-01-02 15:04:05 UTC", calendar)
@@ -140,7 +143,10 @@ func systemctlTimerStateNegative(err error, query, timer string) bool {
}
func numericJobID(id string) bool {
- if id == "" {
+ // at/atq emit canonical positive decimal identifiers. Reject leading zeroes
+ // and unbounded digit strings so a corrupt registry/queue cannot be passed as
+ // a giant helper argument or alias the same job under two textual ids.
+ if id == "" || len(id) > 20 || id[0] == '0' {
return false
}
for _, r := range id {
diff --git a/internal/schedule/valid_test.go b/internal/schedule/valid_test.go
index 7887a7b..8c7e74b 100644
--- a/internal/schedule/valid_test.go
+++ b/internal/schedule/valid_test.go
@@ -52,7 +52,8 @@ func TestValidScheduleRejectsReservedLinuxUID(t *testing.T) {
}
sys := &fakeSystem{atJobsErr: errors.New("must not inventory")}
s := newScheduler(t.TempDir(), sys)
- valid, err := s.ValidSchedule("xxvcc-a1", int(uint64(^uint32(0))), testGeneration, "at:42")
+ reservedKernelID := uint64(^uint32(0))
+ valid, err := s.ValidSchedule("xxvcc-a1", int(reservedKernelID), testGeneration, "at:42")
if err != nil || valid {
t.Fatalf("ValidSchedule reserved UID = %v, %v; want false, nil", valid, err)
}
@@ -103,6 +104,27 @@ func TestValidScheduleAcceptsExactRootOwnedSystemdPair(t *testing.T) {
}
}
+func TestValidScheduleAcceptsLegacyOneMinuteAccuracyTimer(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("valid systemd schedule files must be root-owned")
+ }
+ dir := t.TempDir()
+ s := newScheduler(dir, &fakeSystem{})
+ unit := s.UnitName("xxvcc-a1")
+ trigger := s.Now().Add(time.Hour)
+ writeSchedulePair(t, s, "xxvcc-a1", 1001, testGeneration, trigger)
+ timerPath := filepath.Join(dir, unit+".timer")
+ calendar := trigger.UTC().Format("2006-01-02 15:04:05 UTC")
+ if err := os.WriteFile(timerPath, []byte(legacyTimerContent(unit, calendar)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit)
+ if err != nil || !valid {
+ t.Fatalf("legacy timer validity = %v, %v; want true", valid, err)
+ }
+}
+
func TestValidScheduleRequiresEnabledAndActiveSystemdTimer(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("systemd schedule files must be root-owned")
@@ -248,6 +270,21 @@ func TestValidScheduleRejectsTamperedOrExpiredSystemdPair(t *testing.T) {
}
},
},
+ {
+ name: "unknown accuracy window",
+ mutate: func(t *testing.T, s *Scheduler, unit string) {
+ t.Helper()
+ path := filepath.Join(s.SystemdDir, unit+".timer")
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b = []byte(strings.ReplaceAll(string(b), "AccuracySec=1us", "AccuracySec=5min"))
+ if err := os.WriteFile(path, b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
{
name: "unsafe mode",
mutate: func(t *testing.T, s *Scheduler, unit string) {
@@ -352,6 +389,14 @@ func TestValidScheduleRejectsUnexpectedRecordedUnit(t *testing.T) {
}
}
+func TestValidScheduleRejectsNilSchedulerWithoutPanicking(t *testing.T) {
+ var s *Scheduler
+ valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42")
+ if err == nil || valid || !strings.Contains(err.Error(), "no scheduler configured") {
+ t.Fatalf("nil ValidSchedule = %v, %v", valid, err)
+ }
+}
+
func writeSchedulePair(t *testing.T, s *Scheduler, user string, uid int, generation string, trigger time.Time) {
t.Helper()
unit := s.UnitName(user)
diff --git a/internal/selfmanage/release_pipeline_test.go b/internal/selfmanage/release_pipeline_test.go
index e940044..5f45c28 100644
--- a/internal/selfmanage/release_pipeline_test.go
+++ b/internal/selfmanage/release_pipeline_test.go
@@ -42,9 +42,11 @@ func TestReleaseWriterIsSeparatedFromCandidateWorkflow(t *testing.T) {
strings.Count(stage, ".verification.signature") != 2 || strings.Count(stage, "-----BEGIN PGP SIGNATURE-----") != 2 {
t.Fatal("trusted stage workflow does not reject unsigned annotated tags")
}
- if !strings.Contains(stage, "[[ \"$lookup_status\" -eq 1 ]]") ||
- strings.Contains(stage, "[[ \"$lookup_status\" -ne 124") {
- t.Fatal("trusted stage workflow accepts an abnormal release-lookup exit status as an HTTP 404")
+ if !strings.Contains(stage, `gh api --paginate`) ||
+ !strings.Contains(stage, `releases?per_page=100`) ||
+ !strings.Contains(stage, `(( match_count == 0 ))`) ||
+ strings.Contains(stage, `releases/tags/${TAG}`) {
+ t.Fatal("trusted stage workflow does not enumerate authenticated draft and published Release identities")
}
if strings.Contains(stage, "--clobber") {
t.Fatal("trusted stage workflow must never refresh an existing draft")
@@ -234,10 +236,10 @@ func TestMirrorReleaseWorkflowPublishesVerifiedImmutableContentFailClosed(t *tes
}
}
-func TestStageReleaseLookupAcceptsOnlyExactGHHTTPErrorStatus(t *testing.T) {
+func TestStageReleaseLookupRejectsEveryExistingDraftOrPublishedTag(t *testing.T) {
stage := readReleaseFile(t, "../../.github/workflows/stage-release.yml")
- start := strings.Index(stage, " lookup=\"$(mktemp)\"")
- endMarker := " rm -f -- \"$lookup\""
+ start := strings.Index(stage, " release_records=\"")
+ endMarker := " # Re-resolve the protected tag immediately before the first write;"
if start < 0 {
t.Fatal("could not isolate staged-release absence check")
}
@@ -262,7 +264,7 @@ exec "$@"
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(`#!/bin/sh
-printf 'HTTP/2 404 Not Found\n\n{}\n'
+printf '%b' "${MOCK_RELEASES-}"
exit "${MOCK_GH_STATUS:?}"
`), 0o700); err != nil {
t.Fatal(err)
@@ -272,29 +274,35 @@ exit "${MOCK_GH_STATUS:?}"
t.Fatal(err)
}
for _, tc := range []struct {
- status int
- ok bool
+ name string
+ status int
+ releases string
+ ok bool
}{
- {status: 1, ok: true},
- {status: 0, ok: false},
- {status: 2, ok: false},
- {status: 124, ok: false},
- {status: 137, ok: false},
+ {name: "empty list", status: 0, ok: true},
+ {name: "unrelated release", status: 0, releases: "v2.7.3\\t100\\n", ok: true},
+ {name: "existing draft", status: 0, releases: "v2.8.0\\t101\\n"},
+ {name: "duplicate matching tag", status: 0, releases: "v2.8.0\\t101\\nv2.8.0\\t102\\n"},
+ {name: "zero id", status: 0, releases: "v2.7.3\\t0\\n"},
+ {name: "extra field", status: 0, releases: "v2.7.3\\t100\\textra\\n"},
+ {name: "api failure", status: 1},
+ {name: "api timeout", status: 124},
} {
- t.Run(fmt.Sprintf("status-%d", tc.status), func(t *testing.T) {
+ t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command("/bin/bash", script)
cmd.Env = []string{
"PATH=" + binDir + ":/usr/bin:/bin",
fmt.Sprintf("MOCK_GH_STATUS=%d", tc.status),
+ "MOCK_RELEASES=" + tc.releases,
"GH_REPO=xxvcc/linux-temp-admin",
"TAG=v2.8.0",
}
out, err := cmd.CombinedOutput()
if tc.ok && err != nil {
- t.Fatalf("exact HTTP error status was rejected: %v\n%s", err, out)
+ t.Fatalf("absent tag was rejected: %v\n%s", err, out)
}
if !tc.ok && err == nil {
- t.Fatalf("abnormal status was accepted: %s", out)
+ t.Fatalf("existing, malformed, or unverified state was accepted: %s", out)
}
})
}
@@ -320,6 +328,9 @@ func TestManualLatestRecoveryUsesSanitizedBoundedGitHubClient(t *testing.T) {
"GH_PROMPT_DISABLED=1",
"gh_with_timeout() {",
"timeout -k 5 300 gh \"$@\"",
+ "enumerate_recovery_target() {",
+ "expected_fallback_id=$fallback_id",
+ "highest stable fallback changed during Latest recovery",
"[[ \"$latest_status\" -eq 1 ]]",
} {
if !strings.Contains(recovery, required) {
@@ -339,9 +350,27 @@ func TestManualLatestRecoveryUsesSanitizedBoundedGitHubClient(t *testing.T) {
if strings.Contains(releasing, `GH_TOKEN="$GH_TOKEN"`) {
t.Fatal("release documentation exposes GH_TOKEN through a command argument")
}
+ if got := strings.Count(recovery, "\nenumerate_recovery_target\n"); got != 2 {
+ t.Fatalf("manual Latest recovery enumerates stable Releases %d times, want 2", got)
+ }
if got := strings.Count(releasing, "read -r -s -p 'Short-lived github.com release token: ' GH_TOKEN = 0 && (firstMutation < 0 || index < firstMutation) {
+ firstMutation = index
+ }
+ }
if preflight < 0 || firstMutation < 0 || preflight > firstMutation {
t.Fatal("publisher command preflight does not precede its first remote mutation")
}
@@ -3151,13 +3190,23 @@ func TestPublisherResumeAndRecoveryGuards(t *testing.T) {
publish := readReleaseFile(t, "../../scripts/publish-release.sh")
for _, required := range []string{
"resume exactly matching published release (no asset mutation)",
+ `repos/${REPO}/releases?per_page=100`,
+ `repos/${REPO}/releases/${EXPECTED_RELEASE_ID}`,
+ "initial_release_state",
+ "bind_initial_release",
+ "readonly EXPECTED_RELEASE_ID RELEASE_WAS_DRAFT",
+ "replace_bound_draft_assets",
+ "publish_bound_release",
+ "secure_failed_publication_state",
+ "set_latest_by_release_id",
"require_exact_signed_assets",
"require_remote_asset_digests",
"RESUMING_ALREADY_LATEST",
"LATEST_PROMOTION_ATTEMPTED=1",
"restore_latest_after_failed_promotion",
"highest_stable_release_excluding",
- "require_latest_exact",
+ "require_latest_release_exact",
+ "require_immutable_release",
"HTTP/[0-9.]+ 404",
"CRITICAL: automatic Latest restoration failed",
} {
@@ -3166,10 +3215,693 @@ func TestPublisherResumeAndRecoveryGuards(t *testing.T) {
}
}
resume := strings.Index(publish, "resume exactly matching published release")
- upload := strings.Index(publish, `gh_with_timeout release upload "$TAG"`)
+ upload := strings.Index(publish, "\n replace_bound_draft_assets\n")
if resume < 0 || upload < 0 || resume < upload {
t.Fatal("published-release resume path is not separated from draft asset upload")
}
+ if strings.Count(publish, "require_immutable_release") != 4 {
+ t.Fatal("publisher must define and enforce the immutable-Release gate before and after public verification")
+ }
+ immutableAfterPublish := strings.Index(publish, "\nif ! require_immutable_release; then")
+ versionedVerification := strings.Index(publish, `echo ">> [publish 3/4] independently verify public versioned assets"`)
+ promotionGate := strings.Index(publish, "\n require_immutable_release\n set_latest_by_release_id")
+ latestVerification := strings.Index(publish, `verify_public_set "https://github.com/${REPO}/releases/latest/download"`)
+ if immutableAfterPublish < 0 || versionedVerification < 0 || immutableAfterPublish > versionedVerification ||
+ promotionGate < 0 || latestVerification < 0 || promotionGate > latestVerification {
+ t.Fatal("publisher does not enforce immutable state before public fetching and Latest promotion")
+ }
+ if strings.Count(publish, `require_latest_release_exact "$TAG" "$EXPECTED_RELEASE_ID"`) != 2 {
+ t.Fatal("publisher does not verify the promoted target's exact numeric Latest identity before and after Latest downloads")
+ }
+ for _, forbidden := range []string{
+ "gh_with_timeout release upload",
+ "gh_with_timeout release edit",
+ "gh_with_timeout release delete",
+ `--method PATCH "repos/${REPO}/releases/tags/`,
+ `--method DELETE "repos/${REPO}/releases/tags/`,
+ `--method POST "repos/${REPO}/releases/tags/`,
+ "require_tag_release_identity",
+ } {
+ if strings.Contains(publish, forbidden) {
+ t.Fatalf("publisher still performs a tag-addressed Release mutation: %q", forbidden)
+ }
+ }
+ bind := strings.Index(publish, "\nbind_initial_release\nreadonly EXPECTED_RELEASE_ID RELEASE_WAS_DRAFT")
+ baseline := strings.Index(publish, "\nBASELINE_HIGHEST_TAG=")
+ if bind < 0 || baseline < 0 || bind > baseline {
+ t.Fatal("publisher does not bind the numeric Release identity from its initial state before baseline checks")
+ }
+}
+
+func TestPublisherDraftAssetReplacementIsRecoverableAndBound(t *testing.T) {
+ publish := readReleaseFile(t, "../../scripts/publish-release.sh")
+ start := strings.Index(publish, "remote_asset_names() {")
+ end := strings.Index(publish[start:], "\nif [[ \"$TAG\" == *-* ]]")
+ if start < 0 || end <= 0 {
+ t.Fatal("could not isolate publisher draft-asset replacement functions")
+ }
+ functions := publish[start : start+end]
+
+ const (
+ checksums = "SHA256SUMS"
+ amd64 = "linux-temp-admin-linux-amd64"
+ amd64Sig = "linux-temp-admin-linux-amd64.sig"
+ arm64 = "linux-temp-admin-linux-arm64"
+ arm64Sig = "linux-temp-admin-linux-arm64.sig"
+ )
+ type replacementCase struct {
+ name string
+ assets []string
+ wantOK bool
+ wantErr string
+ failAt int
+ }
+ tests := []replacementCase{
+ {
+ name: "staged unsigned set",
+ assets: []string{checksums, amd64, arm64},
+ wantOK: true,
+ },
+ {
+ name: "resume after one core deletion",
+ assets: []string{checksums, amd64, amd64Sig, arm64Sig},
+ wantOK: true,
+ },
+ {
+ name: "resume after one signature deletion",
+ assets: []string{checksums, amd64, amd64Sig, arm64},
+ wantOK: true,
+ },
+ {
+ name: "reject partial unsigned set",
+ assets: []string{checksums, amd64},
+ wantErr: "neither the staged set nor a recoverable one-asset interruption",
+ },
+ {
+ name: "reject two-asset deficit after signing began",
+ assets: []string{checksums, amd64, amd64Sig},
+ wantErr: "neither the staged set nor a recoverable one-asset interruption",
+ },
+ {
+ name: "reject unexpected asset",
+ assets: []string{checksums, amd64, arm64, "untrusted-extra"},
+ wantErr: "unexpected asset",
+ },
+ {
+ name: "reject duplicate asset name",
+ assets: []string{checksums, amd64, arm64, amd64},
+ wantErr: "repeats an asset name or identity",
+ },
+ }
+ exactAssets := []string{checksums, amd64, amd64Sig, arm64, arm64Sig}
+ for failAt := 1; failAt <= 10; failAt++ {
+ tests = append(tests, replacementCase{
+ name: fmt.Sprintf("resume exact set after ambiguous mutation %02d", failAt),
+ assets: exactAssets,
+ wantOK: true,
+ failAt: failAt,
+ })
+ }
+ for failAt := 1; failAt <= 2; failAt++ {
+ tests = append(tests, replacementCase{
+ name: fmt.Sprintf("resume missing signatures after ambiguous upload %02d", failAt),
+ assets: []string{checksums, amd64, arm64},
+ wantOK: true,
+ failAt: failAt,
+ })
+ }
+
+ assertRecoverableRecords := func(t *testing.T, path string, wantExact bool) {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ allowed := map[string]bool{
+ checksums: true,
+ amd64: true,
+ amd64Sig: true,
+ arm64: true,
+ arm64Sig: true,
+ }
+ seenNames := make(map[string]bool)
+ seenIDs := make(map[string]bool)
+ coreCount := 0
+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+ if len(lines) == 1 && lines[0] == "" {
+ lines = nil
+ }
+ for _, line := range lines {
+ fields := strings.Split(line, "\t")
+ if len(fields) != 3 || !allowed[fields[0]] || seenNames[fields[0]] || seenIDs[fields[1]] {
+ t.Fatalf("unsafe residual draft asset record %q after ambiguous mutation", line)
+ }
+ seenNames[fields[0]] = true
+ seenIDs[fields[1]] = true
+ if fields[0] == checksums || fields[0] == amd64 || fields[0] == arm64 {
+ coreCount++
+ }
+ }
+ if !((len(seenNames) == 3 && coreCount == 3) || len(seenNames) == 4 || len(seenNames) == 5) {
+ t.Fatalf("ambiguous mutation left a non-recoverable asset set: %s", data)
+ }
+ if wantExact && len(seenNames) != len(allowed) {
+ t.Fatalf("resumed replacement left %d assets, want exact set of %d: %s", len(seenNames), len(allowed), data)
+ }
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ bundle := filepath.Join(dir, "bundle")
+ if err := os.Mkdir(bundle, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{checksums, amd64, amd64Sig, arm64, arm64Sig} {
+ if err := os.WriteFile(filepath.Join(bundle, name), []byte("signed "+name+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var records strings.Builder
+ for i, name := range tt.assets {
+ id := 100 + i
+ fmt.Fprintf(&records, "%s\t%d\thttps://api.github.com/repos/mock/repo/releases/assets/%d\n", name, id, id)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "records"), []byte(records.String()), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "next-id"), []byte("1000"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "mutations"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "mutation-count"), []byte("0"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ script := filepath.Join(dir, "replace-assets.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+EXPECTED_RELEASE_ID=12345
+BUNDLE_DIR="$TEST_BUNDLE"
+require_draft() { return 0; }
+record_applied_mutation() {
+ local count
+ count="$(<"$TEST_STATE/mutation-count")"
+ count=$((count + 1))
+ printf '%s' "$count" > "$TEST_STATE/mutation-count"
+ (( TEST_FAIL_AT == 0 || count != TEST_FAIL_AT ))
+}
+gh_with_timeout() {
+ if [[ "$1" == api && "$2" == --paginate \
+ && "$3" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?per_page=100" ]]; then
+ case "$5" in
+ '.[].name') cut -f1 "$TEST_STATE/records" ;;
+ *'@tsv'*) cat "$TEST_STATE/records" ;;
+ *) return 98 ;;
+ esac
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" \
+ && "$3" == --jq && "$4" == .upload_url ]]; then
+ printf 'https://uploads.github.com/repos/%s/releases/%s/assets{?name,label}\n' \
+ "$REPO" "$EXPECTED_RELEASE_ID"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --method && "$3" == DELETE ]]; then
+ local id=${4##*/} before after
+ printf 'DELETE %s\n' "$4" >> "$TEST_STATE/mutations"
+ before="$(wc -l < "$TEST_STATE/records")"
+ awk -F '\t' -v id="$id" '$2 != id' "$TEST_STATE/records" > "$TEST_STATE/records.next"
+ after="$(wc -l < "$TEST_STATE/records.next")"
+ [[ "$before" -eq $((after + 1)) ]] || return 97
+ mv "$TEST_STATE/records.next" "$TEST_STATE/records"
+ record_applied_mutation || return $?
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --method && "$3" == POST ]]; then
+ local endpoint= input= arg name id size
+ for arg in "$@"; do
+ case "$arg" in
+ https://uploads.github.com/*) endpoint=$arg ;;
+ esac
+ done
+ for ((i=1; i <= $#; i++)); do
+ if [[ "${!i}" == --input ]]; then
+ i=$((i + 1))
+ input=${!i}
+ break
+ fi
+ done
+ [[ "$endpoint" == "https://uploads.github.com/repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?name="* \
+ && -f "$input" ]] || return 96
+ name=${endpoint##*?name=}
+ id="$(<"$TEST_STATE/next-id")"
+ printf '%s' "$((id + 1))" > "$TEST_STATE/next-id"
+ size="$(wc -c < "$input")"
+ printf 'POST %s\n' "$endpoint" >> "$TEST_STATE/mutations"
+ printf '%s\t%s\thttps://api.github.com/repos/%s/releases/assets/%s\n' \
+ "$name" "$id" "$REPO" "$id" >> "$TEST_STATE/records"
+ record_applied_mutation || return $?
+ printf '%s\t%s\t%s\thttps://api.github.com/repos/%s/releases/assets/%s\n' \
+ "$id" "$name" "$size" "$REPO" "$id"
+ return 0
+ fi
+ printf 'unexpected mock GitHub call: %q ' "$@" >&2
+ return 99
+}
+ ` + functions + `
+if [[ "$TEST_WANT_OK" == true ]]; then
+ require_recoverable_draft_assets
+ replace_bound_draft_assets
+ require_exact_signed_assets
+else
+ if require_recoverable_draft_assets; then
+ echo 'unsafe draft asset state passed the entry precheck' >&2
+ exit 89
+ fi
+ if replace_bound_draft_assets; then
+ echo 'unsafe draft asset state was accepted' >&2
+ exit 90
+ fi
+ [[ ! -s "$TEST_STATE/mutations" ]]
+fi
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ run := func(failAt int) ([]byte, error) {
+ cmd := exec.Command("/bin/bash", script)
+ cmd.Env = append(os.Environ(),
+ "TEST_STATE="+dir,
+ "TEST_BUNDLE="+bundle,
+ "TEST_WANT_OK="+strconv.FormatBool(tt.wantOK),
+ "TEST_FAIL_AT="+strconv.Itoa(failAt),
+ )
+ return cmd.CombinedOutput()
+ }
+ out, err := run(tt.failAt)
+ if tt.failAt > 0 {
+ if err == nil {
+ t.Fatalf("ambiguous mutation %d unexpectedly completed: %s", tt.failAt, out)
+ }
+ assertRecoverableRecords(t, filepath.Join(dir, "records"), false)
+ if writeErr := os.WriteFile(filepath.Join(dir, "mutation-count"), []byte("0"), 0o600); writeErr != nil {
+ t.Fatal(writeErr)
+ }
+ resumeOut, resumeErr := run(0)
+ if resumeErr != nil {
+ t.Fatalf("draft did not resume after ambiguous mutation %d: %v\nfirst run:\n%s\nresume:\n%s",
+ tt.failAt, resumeErr, out, resumeOut)
+ }
+ assertRecoverableRecords(t, filepath.Join(dir, "records"), true)
+ return
+ }
+ if tt.wantOK {
+ if err != nil {
+ t.Fatalf("recoverable draft asset replacement failed: %v\n%s", err, out)
+ }
+ mutations, readErr := os.ReadFile(filepath.Join(dir, "mutations"))
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ mutationLog := string(mutations)
+ firstPOST := strings.Index(mutationLog, "POST ")
+ firstDELETE := strings.Index(mutationLog, "DELETE ")
+ if firstPOST < 0 || firstDELETE < 0 || firstPOST > firstDELETE {
+ t.Fatalf("missing assets were not filled before destructive replacement:\n%s", mutationLog)
+ }
+ for _, line := range strings.Split(strings.TrimSpace(mutationLog), "\n") {
+ if strings.HasPrefix(line, "POST ") && !strings.Contains(line, "/releases/12345/assets?name=") {
+ t.Fatalf("asset upload escaped the bound Release ID: %s", line)
+ }
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("fail-closed fixture did not handle rejection: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), tt.wantErr) {
+ t.Fatalf("draft rejection did not explain %q: %s", tt.wantErr, out)
+ }
+ }
+ })
+ }
+}
+
+func TestPublisherBindsInitialReleaseIdentity(t *testing.T) {
+ publish := readReleaseFile(t, "../../scripts/publish-release.sh")
+ start := strings.Index(publish, "initial_release_state() {")
+ if start < 0 {
+ t.Fatal("could not locate publisher initial Release state reader")
+ }
+ end := strings.Index(publish[start:], "\nrequire_remote_tag_object() {")
+ if end < 0 {
+ t.Fatal("could not isolate publisher initial Release identity binding")
+ }
+ initialGuards := publish[start : start+end]
+
+ for _, tc := range []struct {
+ name string
+ records string
+ want string
+ }{
+ {name: "mutable draft", records: "v2.8.0\ttrue\tfalse\tfalse\t12345", want: "1 12345\n"},
+ {name: "immutable published release", records: "v2.8.0\tfalse\tfalse\ttrue\t12345", want: "0 12345\n"},
+ {
+ name: "unrelated releases do not hide unique draft",
+ records: "v2.7.3\tfalse\tfalse\ttrue\t11111\n" +
+ "v2.8.0\ttrue\tfalse\tfalse\t12345\n" +
+ "v2.9.0-rc.1\tfalse\ttrue\ttrue\t22222",
+ want: "1 12345\n",
+ },
+ {name: "mutable published release", records: "v2.8.0\tfalse\tfalse\tfalse\t12345"},
+ {name: "immutable draft", records: "v2.8.0\ttrue\tfalse\ttrue\t12345"},
+ {name: "no matching release", records: "v2.8.1\tfalse\tfalse\ttrue\t12345"},
+ {name: "empty release list"},
+ {name: "missing numeric identity", records: "v2.8.0\tfalse\tfalse\ttrue\tnull"},
+ {name: "zero identity", records: "v2.8.0\tfalse\tfalse\ttrue\t0"},
+ {
+ name: "duplicate tag is ambiguous",
+ records: "v2.8.0\ttrue\tfalse\tfalse\t12345\n" +
+ "v2.8.0\tfalse\tfalse\ttrue\t67890",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ script := filepath.Join(t.TempDir(), "initial-release.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+expected_prerelease=false
+gh_with_timeout() {
+ [[ "$1" == api && "$2" == --paginate \
+ && "$3" == "repos/${REPO}/releases?per_page=100" ]] || return 99
+ printf '%s\n' "$TEST_RELEASE_RECORDS"
+}
+` + initialGuards + `
+bind_initial_release
+printf '%s %s\n' "$RELEASE_WAS_DRAFT" "$EXPECTED_RELEASE_ID"
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("/bin/bash", script)
+ cmd.Env = append(os.Environ(), "TEST_RELEASE_RECORDS="+tc.records)
+ out, err := cmd.CombinedOutput()
+ if tc.want != "" {
+ if err != nil || string(out) != tc.want {
+ t.Fatalf("valid initial Release state was rejected: %v\n%s", err, out)
+ }
+ } else if err == nil {
+ t.Fatalf("unsafe initial Release records %q were accepted: %s", tc.records, out)
+ }
+ })
+ }
+
+ t.Run("published-only tag endpoint is unnecessary and numeric identity remains fixed", func(t *testing.T) {
+ script := filepath.Join(t.TempDir(), "draft-identity.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+expected_prerelease=false
+printf 'v2.8.0\ttrue\tfalse\tfalse\t12345\n' > "$TEST_STATE/release-records"
+printf 'v2.8.0 true false false 12345\n' > "$TEST_STATE/bound-state"
+gh_with_timeout() {
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/tags/${TAG}" ]]; then
+ printf 'HTTP 404: published release not found\n' >&2
+ printf 'tag\n' >> "$TEST_STATE/calls"
+ return 1
+ fi
+ if [[ "$1" == api && "$2" == --paginate \
+ && "$3" == "repos/${REPO}/releases?per_page=100" ]]; then
+ printf 'list\n' >> "$TEST_STATE/calls"
+ cat "$TEST_STATE/release-records"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/12345" ]]; then
+ printf 'bound\n' >> "$TEST_STATE/calls"
+ cat "$TEST_STATE/bound-state"
+ return 0
+ fi
+ return 99
+}
+` + initialGuards + `
+if gh_with_timeout api "repos/${REPO}/releases/tags/${TAG}" >/dev/null 2>&1; then
+ echo "published-only tag endpoint unexpectedly exposed the draft" >&2
+ exit 98
+fi
+: > "$TEST_STATE/calls"
+bind_initial_release
+require_draft
+grep -Fxq list "$TEST_STATE/calls"
+grep -Fxq bound "$TEST_STATE/calls"
+if grep -Fxq tag "$TEST_STATE/calls"; then
+ echo "publisher depended on the published-only tag endpoint" >&2
+ exit 97
+fi
+printf 'v2.8.0 true false false 67890\n' > "$TEST_STATE/bound-state"
+require_draft
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("/bin/bash", script)
+ cmd.Env = append(os.Environ(), "TEST_STATE="+filepath.Dir(script))
+ out, err := cmd.CombinedOutput()
+ if err == nil || !strings.Contains(string(out), "identity changed") {
+ t.Fatalf("replacement draft Release identity was accepted: %v\n%s", err, out)
+ }
+ })
+}
+
+func TestPublisherRejectsMutableReleaseState(t *testing.T) {
+ publish := readReleaseFile(t, "../../scripts/publish-release.sh")
+ identityStart := strings.Index(publish, "initial_release_state() {")
+ identityEnd := strings.Index(publish, "\nbind_initial_release() {")
+ gateStart := strings.Index(publish, "require_immutable_release() {")
+ if identityStart < 0 || identityEnd <= identityStart || gateStart < 0 {
+ t.Fatal("could not locate publisher immutable-Release gate")
+ }
+ gateEnd := strings.Index(publish[gateStart:], "\n}\n")
+ if gateEnd < 0 {
+ t.Fatal("could not isolate publisher immutable-Release gate")
+ }
+ gate := publish[identityStart:identityEnd] + "\n" + publish[gateStart:gateStart+gateEnd+2]
+
+ for _, tc := range []struct {
+ name string
+ state string
+ ok bool
+ }{
+ {name: "expected immutable release", state: "v2.8.0 false false true 12345", ok: true},
+ {name: "mutable release", state: "v2.8.0 false false false 12345"},
+ {name: "draft release", state: "v2.8.0 true false true 12345"},
+ {name: "wrong prerelease state", state: "v2.8.0 false true true 12345"},
+ {name: "different tag", state: "v2.8.1 false false true 12345"},
+ {name: "missing numeric identity", state: "v2.8.0 false false true null"},
+ {name: "zero identity", state: "v2.8.0 false false true 0"},
+ {name: "different numeric identity", state: "v2.8.0 false false true 67890"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ script := filepath.Join(t.TempDir(), "immutable-gate.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+expected_prerelease=false
+EXPECTED_RELEASE_ID=12345
+gh_with_timeout() {
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" ]]; then
+ printf '%s\n' "$TEST_RELEASE_STATE"
+ return 0
+ fi
+ return 99
+}
+` + gate + `
+require_immutable_release
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("/bin/bash", script)
+ cmd.Env = append(os.Environ(), "TEST_RELEASE_STATE="+tc.state)
+ out, err := cmd.CombinedOutput()
+ if tc.ok && err != nil {
+ t.Fatalf("immutable release was rejected: %v\n%s", err, out)
+ }
+ if !tc.ok && err == nil {
+ t.Fatalf("unsafe release state %q was accepted", tc.state)
+ }
+ })
+ }
+
+ t.Run("release identity remains fixed", func(t *testing.T) {
+ script := filepath.Join(t.TempDir(), "immutable-identity.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+expected_prerelease=false
+EXPECTED_RELEASE_ID=12345
+gh_with_timeout() {
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" ]]; then
+ printf '%s\n' "$TEST_RELEASE_STATE"
+ return 0
+ fi
+ return 99
+}
+` + gate + `
+TEST_RELEASE_STATE="v2.8.0 false false true 12345"
+require_immutable_release
+TEST_RELEASE_STATE="v2.8.0 false false true 67890"
+require_immutable_release
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ out, err := exec.Command("/bin/bash", script).CombinedOutput()
+ if err == nil || !strings.Contains(string(out), "identity changed") {
+ t.Fatalf("replacement Release identity was accepted: %v\n%s", err, out)
+ }
+ })
+}
+
+func TestPublisherMutationStaysBoundAfterTagReplacement(t *testing.T) {
+ publish := readReleaseFile(t, "../../scripts/publish-release.sh")
+ start := strings.Index(publish, "initial_release_state() {")
+ if start < 0 {
+ t.Fatal("could not locate bound publication functions")
+ }
+ end := strings.Index(publish[start:], "\nsecure_failed_publication_state() {")
+ if end <= 0 {
+ t.Fatal("could not isolate bound publication functions")
+ }
+ functions := publish[start : start+end]
+ dir := t.TempDir()
+ script := filepath.Join(dir, "bound-publication.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+expected_prerelease=false
+EXPECTED_RELEASE_ID=12345
+printf 'v2.8.0\ttrue\tfalse\tfalse\t12345\n' > "$TEST_STATE/release-records"
+printf 'v2.8.0 true false false 12345\n' > "$TEST_STATE/bound-state"
+gh_with_timeout() {
+ printf '%q ' "$@" >> "$TEST_STATE/calls"
+ printf '\n' >> "$TEST_STATE/calls"
+ if [[ "$1" == api && "$2" == --paginate \
+ && "$3" == "repos/${REPO}/releases?per_page=100" ]]; then
+ printf 'list\n' >> "$TEST_STATE/tag-queries"
+ cat "$TEST_STATE/release-records"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" ]]; then
+ cat "$TEST_STATE/bound-state"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --method && "$3" == PATCH ]]; then
+ [[ "$4" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" ]] || return 97
+ printf 'v2.8.0 false false true 12345\n' > "$TEST_STATE/bound-state"
+ printf 'v2.8.0 false false true 12345\n'
+ return 0
+ fi
+ return 99
+}
+` + functions + `
+printf 'v2.8.0\ttrue\tfalse\tfalse\t67890\n' > "$TEST_STATE/release-records"
+publish_bound_release
+require_immutable_release
+grep -Fq 'api --method PATCH repos/mock/repo/releases/12345 ' "$TEST_STATE/calls"
+if grep -Fq 'api --method PATCH repos/mock/repo/releases/67890 ' "$TEST_STATE/calls"; then
+ echo "publication targeted replacement Release" >&2
+ exit 91
+fi
+if grep -Fq 'api --paginate ' "$TEST_STATE/calls" || [[ -s "$TEST_STATE/tag-queries" ]]; then
+ echo "publisher re-resolved the tag after binding the numeric Release ID" >&2
+ exit 92
+fi
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("/bin/bash", script)
+ cmd.Env = append(os.Environ(), "TEST_STATE="+dir)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("bound publication race guard failed: %v\n%s", err, out)
+ }
+}
+
+func TestPublisherRollsMutablePublicationBackToBoundDraft(t *testing.T) {
+ publish := readReleaseFile(t, "../../scripts/publish-release.sh")
+ start := strings.Index(publish, "initial_release_state() {")
+ if start < 0 {
+ t.Fatal("could not locate mutable-publication rollback functions")
+ }
+ end := strings.Index(publish[start:], "\nresolve_published_stable_release_id() {")
+ if end <= 0 {
+ t.Fatal("could not isolate mutable-publication rollback functions")
+ }
+ functions := publish[start : start+end]
+ dir := t.TempDir()
+ script := filepath.Join(dir, "mutable-rollback.sh")
+ body := `#!/bin/bash
+set -Eeuo pipefail
+TAG=v2.8.0
+REPO=mock/repo
+expected_prerelease=false
+EXPECTED_RELEASE_ID=12345
+printf 'v2.8.0 false false false 12345\n' > "$TEST_STATE/release-state"
+printf 'v2.8.0\tfalse\tfalse\tfalse\t12345\n' > "$TEST_STATE/release-records"
+gh_with_timeout() {
+ if [[ "$1" == api && "$2" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" ]]; then
+ cat "$TEST_STATE/release-state"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --paginate \
+ && "$3" == "repos/${REPO}/releases?per_page=100" ]]; then
+ printf 'list\n' >> "$TEST_STATE/tag-queries"
+ cat "$TEST_STATE/release-records"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --method && "$3" == PATCH ]]; then
+ [[ "$4" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" ]] || return 97
+ [[ "$*" == *'-F draft=true'* && "$*" == *'-f make_latest=false'* ]] || return 96
+ printf '%s\n' "$4" > "$TEST_STATE/write-endpoint"
+ printf 'v2.8.0 true false false 12345\n' > "$TEST_STATE/release-state"
+ cat "$TEST_STATE/release-state"
+ return 0
+ fi
+ return 99
+}
+` + functions + `
+secure_failed_publication_state
+[[ "$(cat "$TEST_STATE/release-state")" == 'v2.8.0 true false false 12345' ]]
+[[ "$(cat "$TEST_STATE/write-endpoint")" == 'repos/mock/repo/releases/12345' ]]
+
+# If another Release takes over the tag mapping after an ambiguous publication,
+# the original numeric Release must still be returned to draft without resolving
+# or following that replacement mapping.
+printf 'v2.8.0 false false false 12345\n' > "$TEST_STATE/release-state"
+printf 'v2.8.0\ttrue\tfalse\tfalse\t67890\n' > "$TEST_STATE/release-records"
+: > "$TEST_STATE/write-endpoint"
+secure_failed_publication_state
+[[ "$(cat "$TEST_STATE/release-state")" == 'v2.8.0 true false false 12345' ]]
+[[ "$(cat "$TEST_STATE/write-endpoint")" == 'repos/mock/repo/releases/12345' ]]
+[[ ! -s "$TEST_STATE/tag-queries" ]]
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("/bin/bash", script)
+ cmd.Env = append(os.Environ(), "TEST_STATE="+dir)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("mutable publication was not safely returned to the bound draft: %v\n%s", err, out)
+ }
}
func TestOnlineReleaseScriptsRejectTimedOutLatest404(t *testing.T) {
@@ -3246,40 +3978,73 @@ current_latest_tag > "$TEST_STATE/result"
func TestPublisherLatestRestorationWithMockGitHub(t *testing.T) {
publish := readReleaseFile(t, "../../scripts/publish-release.sh")
start := strings.Index(publish, "decimal_gt() {")
- end := strings.Index(publish, "\nrelease_state() {")
+ end := strings.Index(publish, "\nrequire_remote_tag_object() {")
if start < 0 || end <= start {
t.Fatal("could not isolate publisher Latest recovery functions")
}
recoveryFunctions := publish[start:end]
tests := []struct {
- name string
- stableTags string
- initial string
- expected string
- apiMode string
- wantOK bool
+ name string
+ stableTags string
+ stableAfterMutation string
+ latestAfterMutation string
+ initial string
+ expected string
+ apiMode string
+ mutableFallback bool
+ wantOK bool
+ wantErr string
}{
{
name: "restore previous highest stable",
stableTags: "v2.7.3\nv2.8.0\n",
- initial: "v2.8.0",
- expected: "v2.7.3",
+ initial: "v2.8.0 280",
+ expected: "v2.7.3 273",
apiMode: "404",
wantOK: true,
},
{
name: "restore concurrently published higher stable",
stableTags: "v2.7.3\nv2.8.0\nv2.9.0\n",
- initial: "v2.8.0",
- expected: "v2.9.0",
+ initial: "v2.8.0 280",
+ expected: "v2.9.0 290",
apiMode: "404",
wantOK: true,
},
+ {
+ name: "detect higher stable published during restoration",
+ stableTags: "v2.7.3\nv2.8.0\n",
+ stableAfterMutation: "v2.7.3\nv2.8.0\nv2.9.0\n",
+ initial: "v2.8.0 280",
+ expected: "v2.7.3 273",
+ apiMode: "404",
+ wantOK: false,
+ wantErr: "highest stable release changed during Latest restoration",
+ },
+ {
+ name: "reject same-tag replacement Release during restoration",
+ stableTags: "v2.7.3\nv2.8.0\n",
+ latestAfterMutation: "v2.7.3 999",
+ initial: "v2.8.0 280",
+ expected: "v2.7.3 273",
+ apiMode: "404",
+ wantOK: false,
+ wantErr: "Latest restoration failed: Latest is v2.7.3 (999)",
+ },
+ {
+ name: "reject mutable fallback Release",
+ stableTags: "v2.7.3\nv2.8.0\n",
+ initial: "v2.8.0 280",
+ apiMode: "404",
+ mutableFallback: true,
+ wantOK: false,
+ wantErr: "does not resolve to one published GitHub Release",
+ },
{
name: "clear Latest when no alternative exists",
stableTags: "v2.8.0\n",
- initial: "v2.8.0",
+ initial: "v2.8.0 280",
expected: "",
apiMode: "404",
wantOK: true,
@@ -3287,7 +4052,7 @@ func TestPublisherLatestRestorationWithMockGitHub(t *testing.T) {
{
name: "transport failure is not empty Latest",
stableTags: "v2.8.0\n",
- initial: "v2.8.0",
+ initial: "v2.8.0 280",
expected: "",
apiMode: "transport",
wantOK: false,
@@ -3295,7 +4060,7 @@ func TestPublisherLatestRestorationWithMockGitHub(t *testing.T) {
{
name: "mixed 404 and server error is not empty Latest",
stableTags: "v2.8.0\n",
- initial: "v2.8.0",
+ initial: "v2.8.0 280",
expected: "",
apiMode: "mixed",
wantOK: false,
@@ -3310,6 +4075,7 @@ func TestPublisherLatestRestorationWithMockGitHub(t *testing.T) {
set -Eeuo pipefail
TAG=v2.8.0
REPO=mock/repo
+EXPECTED_RELEASE_ID=280
LOCAL_COMMAND_TIMEOUT_SECONDS=120
work="$TEST_STATE/work"
mkdir -p "$work"
@@ -3332,20 +4098,57 @@ gh() {
fi
if [[ "$1" == release && "$2" == view ]]; then
if [[ -s "$TEST_STATE/latest" ]]; then
- cat "$TEST_STATE/latest"
+ read -r tag id extra < "$TEST_STATE/latest"
+ [[ -z "$extra" && "$id" =~ ^[1-9][0-9]*$ ]] || return 95
+ printf '%s\n' "$tag"
return 0
fi
printf 'no latest release\n' >&2
return 1
fi
- if [[ "$1" == release && "$2" == edit ]]; then
- local tag=$3 arg
+ if [[ "$1" == api && "$2" == repos/mock/repo/releases/latest ]]; then
+ [[ -s "$TEST_STATE/latest" ]] || return 1
+ read -r tag id extra < "$TEST_STATE/latest"
+ [[ -z "$extra" && "$id" =~ ^[1-9][0-9]*$ ]] || return 95
+ printf '%s false false true %s\n' "$tag" "$id"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == repos/mock/repo/releases/tags/* ]]; then
+ local tag=${2##*/} id immutable=true
+ case "$tag" in
+ v2.7.3) id=273 ;;
+ v2.8.0) id=280 ;;
+ v2.9.0) id=290 ;;
+ *) return 97 ;;
+ esac
+ if [[ "$TEST_MUTABLE_FALLBACK" == true && "$tag" != "$TAG" ]]; then
+ immutable=false
+ fi
+ printf '%s false false %s %s\n' "$tag" "$immutable" "$id"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --method && "$3" == PATCH ]]; then
+ local id=${4##*/} tag arg
+ case "$id" in
+ 273) tag=v2.7.3 ;;
+ 280) tag=v2.8.0 ;;
+ 290) tag=v2.9.0 ;;
+ *) return 96 ;;
+ esac
for arg in "$@"; do
case "$arg" in
- --latest) printf '%s' "$tag" > "$TEST_STATE/latest"; return 0 ;;
- --latest=false) : > "$TEST_STATE/latest"; return 0 ;;
+ make_latest=true) printf '%s %s' "$tag" "$id" > "$TEST_STATE/latest" ;;
+ make_latest=false) : > "$TEST_STATE/latest" ;;
esac
done
+ if [[ -n "$TEST_STABLE_AFTER_MUTATION" ]]; then
+ printf '%s' "$TEST_STABLE_AFTER_MUTATION" > "$TEST_STATE/stable"
+ fi
+ if [[ -n "$TEST_LATEST_AFTER_MUTATION" ]]; then
+ printf '%s' "$TEST_LATEST_AFTER_MUTATION" > "$TEST_STATE/latest"
+ fi
+ printf '%s false false true %s\n' "$tag" "$id"
+ return 0
fi
printf 'unexpected gh invocation: %q ' "$@" >&2
return 99
@@ -3363,9 +4166,12 @@ actual="$(cat "$TEST_STATE/latest")"
cmd.Env = append(os.Environ(),
"TEST_STATE="+dir,
"TEST_STABLE_TAGS="+tt.stableTags,
+ "TEST_STABLE_AFTER_MUTATION="+tt.stableAfterMutation,
+ "TEST_LATEST_AFTER_MUTATION="+tt.latestAfterMutation,
"TEST_INITIAL_LATEST="+tt.initial,
"TEST_EXPECTED_LATEST="+tt.expected,
"TEST_API_MODE="+tt.apiMode,
+ "TEST_MUTABLE_FALLBACK="+strconv.FormatBool(tt.mutableFallback),
)
out, err := cmd.CombinedOutput()
if tt.wantOK && err != nil {
@@ -3374,6 +4180,9 @@ actual="$(cat "$TEST_STATE/latest")"
if !tt.wantOK && err == nil {
t.Fatalf("mock recovery unexpectedly succeeded: %s", out)
}
+ if tt.wantErr != "" && !strings.Contains(string(out), tt.wantErr) {
+ t.Fatalf("mock recovery did not report %q: %v\n%s", tt.wantErr, err, out)
+ }
})
}
}
@@ -3435,11 +4244,13 @@ func TestPublisherPublishedAssetValidationWithMockGitHub(t *testing.T) {
set -Eeuo pipefail
TAG=v2.8.0
REPO=mock/repo
+EXPECTED_RELEASE_ID=12345
BUNDLE_DIR="$TEST_BUNDLE"
gh() {
- [[ "$1" == release && "$2" == view ]] || return 99
+ [[ "$1" == api && "$2" == --paginate \
+ && "$3" == "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?per_page=100" ]] || return 99
case "$*" in
- *'.assets[].name'*) printf '%s' "$TEST_ASSET_NAMES" ;;
+ *'.[].name'*) printf '%s' "$TEST_ASSET_NAMES" ;;
*'.digest // ""'*) printf '%s' "$TEST_ASSET_DIGESTS" ;;
*) return 98 ;;
esac
@@ -3474,7 +4285,7 @@ func TestPublisherExitTrapRestoresLatestWithMockGitHub(t *testing.T) {
cleanupStart := strings.Index(publish, "cleanup() {")
cleanupEnd := strings.Index(publish[cleanupStart:], "\ntrap cleanup EXIT")
recoveryStart := strings.Index(publish, "decimal_gt() {")
- recoveryEnd := strings.Index(publish, "\nrelease_state() {")
+ recoveryEnd := strings.Index(publish, "\nrequire_remote_tag_object() {")
if cleanupStart < 0 || cleanupEnd < 0 || recoveryStart < 0 || recoveryEnd <= recoveryStart {
t.Fatal("could not isolate publisher EXIT recovery logic")
}
@@ -3487,16 +4298,29 @@ func TestPublisherExitTrapRestoresLatestWithMockGitHub(t *testing.T) {
set -Eeuo pipefail
TAG=v2.8.0
REPO=mock/repo
+EXPECTED_RELEASE_ID=280
LOCAL_COMMAND_TIMEOUT_SECONDS=120
work="$TEST_STATE/work"
mkdir -p "$work"
printf 'v2.7.3\nv2.8.0\n' > "$TEST_STATE/stable"
-printf 'v2.8.0' > "$TEST_STATE/latest"
+printf 'v2.8.0 280' > "$TEST_STATE/latest"
gh() {
if [[ "$1" == api && "$2" == --paginate ]]; then cat "$TEST_STATE/stable"; return 0; fi
- if [[ "$1" == release && "$2" == view ]]; then cat "$TEST_STATE/latest"; return 0; fi
- if [[ "$1" == release && "$2" == edit && "$4" == --repo && "$6" == --latest ]]; then
- printf '%s' "$3" > "$TEST_STATE/latest"
+ if [[ "$1" == release && "$2" == view ]]; then cut -d ' ' -f1 "$TEST_STATE/latest"; return 0; fi
+ if [[ "$1" == api && "$2" == repos/mock/repo/releases/latest ]]; then
+ read -r tag id extra < "$TEST_STATE/latest"
+ [[ -z "$extra" && "$id" =~ ^[1-9][0-9]*$ ]] || return 98
+ printf '%s false false true %s\n' "$tag" "$id"
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == repos/mock/repo/releases/tags/v2.7.3 ]]; then
+ printf 'v2.7.3 false false true 273\n'
+ return 0
+ fi
+ if [[ "$1" == api && "$2" == --method && "$3" == PATCH \
+ && "$4" == repos/mock/repo/releases/273 ]]; then
+ printf 'v2.7.3 273' > "$TEST_STATE/latest"
+ printf 'v2.7.3 false false true 273\n'
return 0
fi
return 99
@@ -3525,8 +4349,8 @@ exit 42
if readErr != nil {
t.Fatal(readErr)
}
- if string(latest) != "v2.7.3" {
- t.Fatalf("EXIT trap restored Latest to %q, want v2.7.3\n%s", latest, out)
+ if string(latest) != "v2.7.3 273" {
+ t.Fatalf("EXIT trap restored Latest to %q, want v2.7.3 Release 273\n%s", latest, out)
}
}
diff --git a/internal/selfmanage/selfmanage.go b/internal/selfmanage/selfmanage.go
index f86e50b..e5decab 100644
--- a/internal/selfmanage/selfmanage.go
+++ b/internal/selfmanage/selfmanage.go
@@ -695,9 +695,10 @@ func (m *Manager) downloadContextWithPolicy(ctx context.Context, url string, max
select {
case <-timer.C:
case <-ctx.Done():
- if !timer.Stop() {
- <-timer.C
- }
+ // Since Go 1.23, receiving after Stop is guaranteed to block.
+ // Do not use the pre-1.23 drain pattern when the deadline and
+ // timer become ready together.
+ timer.Stop()
return nil, markTransportFailure(fmt.Errorf("download source deadline exceeded"))
}
}
diff --git a/internal/selfmanage/selfmanage_test.go b/internal/selfmanage/selfmanage_test.go
index 6e074d8..4ecbfed 100644
--- a/internal/selfmanage/selfmanage_test.go
+++ b/internal/selfmanage/selfmanage_test.go
@@ -21,6 +21,7 @@ import (
"path/filepath"
"slices"
"strings"
+ "sync"
"testing"
"time"
@@ -692,6 +693,39 @@ func TestDownloadRetriesTransientStatus(t *testing.T) {
}
}
+func TestDownloadRetryWaitHonorsContextCancellation(t *testing.T) {
+ requested := make(chan struct{})
+ var once sync.Once
+ m := &Manager{
+ Client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ once.Do(func() { close(requested) })
+ return &http.Response{
+ StatusCode: http.StatusServiceUnavailable,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("retry")),
+ Request: req,
+ }, nil
+ })},
+ RetryDelay: time.Hour,
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ _, err := m.downloadContextWithPolicy(ctx, "https://example.invalid/asset", 16, 2, downloadPolicy{})
+ done <- err
+ }()
+ <-requested
+ cancel()
+ select {
+ case err := <-done:
+ if err == nil || !IsTransportFailure(err) || !strings.Contains(err.Error(), "deadline exceeded") {
+ t.Fatalf("cancelled retry wait error=%v, want bounded transport failure", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("cancelled retry wait did not return")
+ }
+}
+
func TestDownloadCacheBypassIsRestrictedToOfficialReleaseURLs(t *testing.T) {
cases := map[string]string{
"https://github.com/xxvcc/linux-temp-admin/releases/download/v2.8.0/linux-temp-admin-linux-amd64": "https://github.com/xxvcc/linux-temp-admin/releases/download/v2.8.0/linux-temp-admin-linux-amd64?download=1",
diff --git a/internal/sshdconf/sshdconf.go b/internal/sshdconf/sshdconf.go
index f0a442d..b138b7d 100644
--- a/internal/sshdconf/sshdconf.go
+++ b/internal/sshdconf/sshdconf.go
@@ -1,5 +1,5 @@
-// Package sshdconf grants and removes a per-account sshd exception, so an invite
-// can work on a server that does not accept public-key logins by default.
+// Package sshdconf grants and removes a per-account sshd exception, so the
+// effective configuration can admit a key credential that is disabled by default.
//
// The exception is a drop-in file of its own, containing a
// `Match User ` block followed by an empty `Match all` scope reset.
@@ -15,11 +15,11 @@
// restore a stale config from an unattended timer at 3am.
// - It is removed by revoke, exactly like the sudoers drop-in next to it.
//
-// A grant is written, syntax-checked with `sshd -t`, and then *proved* against
-// `sshd -T -C user=` before the running sshd is reloaded. If the proof
-// fails — a missing Include, a competing Match block, an sshd too old for a
-// directive — the file is removed and the grant fails. An invite is never
-// printed on top of a half-applied sshd change.
+// A grant is written, syntax-checked with `sshd -t`, and then confirmed in the
+// effective configuration from `sshd -T -C user=` before the running
+// sshd is reloaded. If that check fails — a missing Include, a competing Match
+// block, or an sshd too old for a directive — the file is removed and the grant
+// fails. This is a configuration check, not an end-to-end SSH connection test.
//
// sshd is reloaded, never restarted: a restart drops every live session, and a
// botched restart on a remote box cannot be undone from the far end. Every
@@ -34,6 +34,7 @@ import (
"errors"
"fmt"
"io"
+ "math"
"os"
"os/exec"
"path/filepath"
@@ -75,7 +76,7 @@ var (
// DefaultDir is where sshd's per-file configuration drop-ins live.
const DefaultDir = "/etc/ssh/sshd_config.d"
-// DefaultLock guards the write/validate/prove/reload sequence. It lives outside
+// DefaultLock guards the write/validate/check/reload sequence. It lives outside
// DefaultDir so it can never be swept into sshd's `*.conf` include glob.
const DefaultLock = "/run/" + config.ManagedTag + "-sshd.lock"
@@ -92,10 +93,9 @@ var ErrNoReloadMechanism = errors.New("no running sshd could be asked to re-read
// GrantResult describes what a grant actually achieved.
type GrantResult struct {
Path string
- // Reloaded says the running sshd was asked to re-read its configuration and
- // did. When false, the drop-in is on disk and proved correct there, but no
- // running daemon confirmed it — the invite must say so rather than claim a
- // verified login.
+ // Reloaded says a request for the running sshd to re-read its configuration
+ // succeeded. When false, the drop-in is on disk and confirmed by a fresh config
+ // evaluation there, but no running daemon was reached; the invite must say so.
Reloaded bool
}
@@ -129,8 +129,8 @@ func (m *Manager) FilePath(user string) string {
}
// Grant writes a Match block for user that lifts exactly the blockers in report,
-// proves it took effect, and reloads sshd. On any failure the file is removed,
-// sshd is left as it was found, and an error is returned.
+// confirms it in the effective config, and reloads sshd. On any failure the file
+// is removed, sshd is left as it was found, and an error is returned.
//
// groups are the account's real group names (not a prediction), used when an
// AllowGroups whitelist has to be satisfied.
@@ -146,6 +146,22 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport
if !report.Fixable() {
return GrantResult{}, fmt.Errorf("sshd policy cannot be lifted for one account: %s", strings.Join(unfixable(report), ", "))
}
+ // All three probes are part of the grant's safety contract. In particular,
+ // writing without Validate can make the host config invalid, and writing without
+ // Effective can leave a file outside sshd's Include graph while reporting a
+ // usable invite. A nil Reload is not equivalent to ErrNoReloadMechanism: the
+ // default reload probe returns that sentinel only after it actually looked for a
+ // running daemon, whereas a nil function proves nothing and would also make a
+ // later removal impossible to confirm.
+ if m.Validate == nil {
+ return GrantResult{}, fmt.Errorf("sshd configuration validator is not configured")
+ }
+ if m.Effective == nil {
+ return GrantResult{}, fmt.Errorf("sshd effective-configuration probe is not configured")
+ }
+ if m.Reload == nil {
+ return GrantResult{}, fmt.Errorf("sshd reload probe is not configured")
+ }
content, err := dropIn(user, groups, report)
if err != nil {
return GrantResult{}, err
@@ -159,10 +175,8 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport
// `sshd -t`, `sshd -T` and the reload all read the whole config directory, so
// they are not scoped to our own file: without this check a pre-existing
// syntax error elsewhere would be blamed on the file we are about to write.
- if m.Validate != nil {
- if err := m.Validate(); err != nil {
- return fmt.Errorf("the host's sshd configuration is already invalid; refusing to touch it: %w", err)
- }
+ if err := m.Validate(); err != nil {
+ return fmt.Errorf("the host's sshd configuration is already invalid; refusing to touch it: %w", err)
}
path := m.FilePath(user)
rollback := func(cause error, restoreDaemon bool) error {
@@ -194,42 +208,40 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport
}
return err
}
- // Everything below reads the config from disk, so the grant is proved correct
+ // Everything below reads the config from disk, so the grant is checked there
// before the running sshd is asked to adopt it. Until the reload, the running
// daemon has not seen this file at all, so removing it fully undoes the grant.
- if m.Validate != nil {
- if err := m.Validate(); err != nil {
- return rollback(fmt.Errorf("sshd rejected the configuration this grant produced: %w", err), false)
- }
- }
- if m.Effective != nil {
- cfg, err := m.Effective(user)
- if err != nil {
- return rollback(fmt.Errorf("cannot re-read the effective sshd config: %w", err), false)
- }
- // OK, not Certain: this proves the blockers we set out to lift are gone.
- // It must NOT demand Certain(), because a rule we can never evaluate — an
- // address-qualified AllowUsers, which is Unverifiable rather than a blocker —
- // would make Certain() unreachable for any drop-in, and this proof would then
- // roll back a file that took effect perfectly and blame a missing Include.
- // Whether such an unevaluable rule downgrades the invite to UNVERIFIED is the
- // caller's decision, taken from the same report; it is not this proof's job.
- if rep := sysinfo.CheckKeyLogin(cfg, user, groups); !rep.OK() {
- return rollback(fmt.Errorf("the sshd drop-in did not take effect (is `Include %s/*.conf` present in /etc/ssh/sshd_config?)", m.Dir), false)
- }
+ if err := m.Validate(); err != nil {
+ return rollback(fmt.Errorf("sshd rejected the configuration this grant produced: %w", err), false)
}
- if m.Reload != nil {
- switch err := m.Reload(); {
- case err == nil:
- res.Reloaded = true
- case errors.Is(err, ErrNoReloadMechanism):
- // Keep the file: it is correct on disk, and a socket-activated sshd will
- // read it on the next connection. But leave Reloaded false — the caller
- // must not claim a verified login on a daemon we never reached.
- res.Reloaded = false
- default:
- return rollback(fmt.Errorf("sshd reload failed: %w", err), true)
- }
+ cfg, err := m.Effective(user)
+ if err != nil {
+ return rollback(fmt.Errorf("cannot re-read the effective sshd config: %w", err), false)
+ }
+ if cfg == nil {
+ return rollback(fmt.Errorf("cannot re-read the effective sshd config: probe returned no configuration"), false)
+ }
+ // OK, not Certain: this confirms the blockers we set out to lift are gone.
+ // It must NOT demand Certain(), because a rule we can never evaluate — an
+ // address-qualified AllowUsers, which is Unverifiable rather than a blocker —
+ // would make Certain() unreachable for any drop-in, and this check would then
+ // roll back a file that produced the intended effective config and blame a
+ // missing Include.
+ // Whether such an unevaluable rule downgrades the invite to UNVERIFIED is the
+ // caller's decision, taken from the same report; it is not this check's job.
+ if rep := sysinfo.CheckKeyLogin(cfg, user, groups); !rep.OK() {
+ return rollback(fmt.Errorf("the sshd drop-in is not present in the effective config (is `Include %s/*.conf` present in /etc/ssh/sshd_config?)", m.Dir), false)
+ }
+ switch err := m.Reload(); {
+ case err == nil:
+ res.Reloaded = true
+ case errors.Is(err, ErrNoReloadMechanism):
+ // Keep the file: it is correct on disk, and a socket-activated sshd will
+ // read it on the next connection. But leave Reloaded false — the caller
+ // must not claim a verified login on a daemon we never reached.
+ res.Reloaded = false
+ default:
+ return rollback(fmt.Errorf("sshd reload failed: %w", err), true)
}
res.Path = path
return nil
@@ -244,8 +256,8 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport
// blindly — like the sudoers drop-in next to it, it only ever removes the
// managed file for this one account — so revoke need not know whether a grant
// was ever made. Removing a file that is not there is not an error and does not
-// disturb sshd unless a pending marker says an earlier removal still needs to
-// be adopted by the running daemon.
+// disturb sshd unless a pending marker says an earlier removal still needs a
+// successful reload request.
func (m *Manager) Remove(user string) error {
if !validate.Username(user) {
return fmt.Errorf("refusing to remove an sshd drop-in for invalid username %q", user)
@@ -467,7 +479,7 @@ func (m *Manager) Orphans(exists func(string) (bool, error)) ([]string, error) {
return orphans, nil
}
-// withLock serializes the whole write/validate/prove/reload sequence. `sshd -t`,
+// withLock serializes the whole write/validate/check/reload sequence. `sshd -t`,
// `sshd -T` and the reload are all global over the config directory, so two
// concurrent invites are not independent: without this, one grant's reload could
// push the other's not-yet-validated file live.
@@ -621,7 +633,7 @@ func sshdSyntaxCheck() error {
//
// Finding nothing to reload returns ErrNoReloadMechanism rather than nil: the
// caller decides what that means. Reporting it as success would let an invite
-// claim a "verified" login against a daemon that never re-read the file.
+// claim the running daemon was reached when it was not.
func reload() error {
if _, err := exec.LookPath("systemctl"); err == nil {
// The unit is "ssh" on Debian/Ubuntu and "sshd" on RHEL/Arch; one is usually
@@ -736,7 +748,7 @@ func readSSHDMasterPID(path string) (int, time.Time, error) {
if err != nil || pid <= 0 {
return 0, time.Time{}, fmt.Errorf("sshd pid file %s has invalid pid", path)
}
- return pid, time.Unix(stat.Mtim.Sec, stat.Mtim.Nsec), nil
+ return pid, time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)), nil
}
// isSSHDMaster proves that pid is a root sshd listener from the same process
@@ -797,10 +809,13 @@ func sshdProcessStartTime(pid int) (time.Time, error) {
if len(fields) <= 19 {
return time.Time{}, fmt.Errorf("process stat has too few fields")
}
- startTicks, err := strconv.ParseUint(fields[19], 10, 64)
+ startTicks, err := strconv.ParseInt(fields[19], 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("malformed process start time: %w", err)
}
+ if startTicks < 0 {
+ return time.Time{}, fmt.Errorf("malformed process start time: negative tick count")
+ }
procStat, err := readBoundedSSHDProcFile(filepath.Join(sshdProcRoot, "stat"), 1<<20)
if err != nil {
return time.Time{}, err
@@ -821,8 +836,11 @@ func sshdProcessStartTime(pid int) (time.Time, error) {
}
// Linux exposes process starttime in USER_HZ ticks. The supported Linux
// amd64/arm64 ABIs both define USER_HZ as 100 regardless of CONFIG_HZ.
- const linuxUserHZ = uint64(100)
+ const linuxUserHZ = int64(100)
seconds := startTicks / linuxUserHZ
- nanos := (startTicks % linuxUserHZ) * uint64(time.Second) / linuxUserHZ
- return time.Unix(bootSeconds, 0).Add(time.Duration(seconds)*time.Second + time.Duration(nanos)), nil
+ nanos := (startTicks % linuxUserHZ) * (int64(time.Second) / linuxUserHZ)
+ if seconds > math.MaxInt64-bootSeconds {
+ return time.Time{}, fmt.Errorf("process start time overflows kernel boot time")
+ }
+ return time.Unix(bootSeconds+seconds, nanos), nil
}
diff --git a/internal/sshdconf/sshdconf_root_test.go b/internal/sshdconf/sshdconf_root_test.go
index 16a7f9d..107fc9c 100644
--- a/internal/sshdconf/sshdconf_root_test.go
+++ b/internal/sshdconf/sshdconf_root_test.go
@@ -413,13 +413,13 @@ func TestGrantRollsBackWhenTheFixDoesNotTakeEffect(t *testing.T) {
m.Effective = func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(blocked), nil }
if _, err := m.Grant(acct, []string{acct}, report(blocked)); err == nil {
- t.Fatal("Grant must fail when the drop-in provably did not take effect")
+ t.Fatal("Grant must fail when the drop-in is absent from the effective config")
}
if ents, _ := os.ReadDir(m.Dir); len(ents) != 0 {
t.Errorf("a failed grant left files behind: %v", ents)
}
if reloads != 0 {
- t.Errorf("a grant that never proved itself reloaded sshd %d time(s)", reloads)
+ t.Errorf("a grant absent from the effective config reloaded sshd %d time(s)", reloads)
}
}
@@ -569,14 +569,14 @@ func TestRemoveOnlyEverTouchesItsOwnFile(t *testing.T) {
func TestGrantSucceedsDespiteAnUnverifiableAllowUsers(t *testing.T) {
// The false diagnosis regression: a host with `PubkeyAuthentication no` plus a
// routine `AllowUsers *@10.0.0.0/8` ("SSH only from the VPN"). The Match User
- // drop-in genuinely makes pubkey auth work, but the address-qualified AllowUsers
- // can never be evaluated, so the report is OK() yet not Certain(). Grant's proof
- // must accept it (the blockers it lifted are gone) rather than roll back a
- // working file and blame a missing Include.
+ // drop-in enables pubkey auth in the effective config, but the address-qualified
+ // AllowUsers can never be evaluated, so the report is OK() yet not Certain().
+ // Grant's check must accept it (the blockers it lifted are gone) rather than
+ // roll back the intended file and blame a missing Include.
reloads := 0
m := okManager(t, &reloads)
- // After the drop-in, sshd accepts the key but the address-qualified AllowUsers
- // still stands -- exactly what the real host reports.
+ // After the drop-in, the effective config admits the key but the
+ // address-qualified AllowUsers still stands -- exactly what the real host reports.
m.Effective = func(string) (*sysinfo.SSHDConfig, error) {
return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\nallowusers *@10.0.0.0/8\n"), nil
}
diff --git a/internal/sshdconf/sshdconf_test.go b/internal/sshdconf/sshdconf_test.go
index 2c48a85..e5dd299 100644
--- a/internal/sshdconf/sshdconf_test.go
+++ b/internal/sshdconf/sshdconf_test.go
@@ -3,6 +3,7 @@ package sshdconf
import (
"context"
"errors"
+ "math"
"os"
"path/filepath"
"strconv"
@@ -277,6 +278,30 @@ func TestSSHDMasterIdentityRejectsStalePIDAndSessionChild(t *testing.T) {
}
}
+func TestSSHDProcessStartTimeRejectsOverflow(t *testing.T) {
+ oldRoot := sshdProcRoot
+ t.Cleanup(func() { sshdProcRoot = oldRoot })
+
+ for _, tc := range []struct {
+ name string
+ bootSeconds int64
+ startTicks uint64
+ want string
+ }{
+ {"ticks exceed signed range", 1_000, math.MaxUint64, "malformed process start time"},
+ {"boot plus uptime overflows", math.MaxInt64, 100, "overflows kernel boot time"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ procRoot := filepath.Join(t.TempDir(), "proc")
+ sshdProcRoot = procRoot
+ writeFakeSSHDProcess(t, procRoot, 42, true, tc.bootSeconds, tc.startTicks)
+ if _, err := sshdProcessStartTime(42); err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("sshdProcessStartTime error = %v, want %q", err, tc.want)
+ }
+ })
+ }
+}
+
func TestSignalSSHDDoesNotHUPStalePIDReuse(t *testing.T) {
root := t.TempDir()
pidFile := filepath.Join(root, "sshd.pid")
@@ -494,6 +519,54 @@ func TestGrantRefusesWhatItCannotFix(t *testing.T) {
}
}
+func TestGrantFailsClosedWithoutRequiredProbes(t *testing.T) {
+ blockedReport := report("pubkeyauthentication no\n")
+ valid := func() error { return nil }
+ effective := func(string) (*sysinfo.SSHDConfig, error) {
+ return sysinfo.ParseSSHD("pubkeyauthentication yes\n"), nil
+ }
+ reload := func() error { return nil }
+
+ for _, tc := range []struct {
+ name string
+ m *Manager
+ }{
+ {"validator", &Manager{Dir: t.TempDir(), Effective: effective, Reload: reload}},
+ {"effective config", &Manager{Dir: t.TempDir(), Validate: valid, Reload: reload}},
+ {"reload", &Manager{Dir: t.TempDir(), Validate: valid, Effective: effective}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := tc.m.Grant(acct, []string{acct}, blockedReport); err == nil {
+ t.Fatal("Grant accepted an incomplete probe configuration")
+ }
+ if _, err := os.Lstat(tc.m.FilePath(acct)); !os.IsNotExist(err) {
+ t.Fatalf("Grant wrote a drop-in before rejecting missing probes: %v", err)
+ }
+ })
+ }
+}
+
+func TestGrantRollsBackWhenEffectiveProbeReturnsNil(t *testing.T) {
+ dir := t.TempDir()
+ m := &Manager{
+ Dir: dir,
+ Validate: func() error { return nil },
+ Effective: func(string) (*sysinfo.SSHDConfig, error) {
+ return nil, nil
+ },
+ Reload: func() error { return nil },
+ }
+ if _, err := m.Grant(acct, []string{acct}, report("pubkeyauthentication no\n")); err == nil {
+ t.Fatal("Grant accepted a nil effective configuration")
+ }
+ if _, err := os.Lstat(m.FilePath(acct)); !os.IsNotExist(err) {
+ t.Fatalf("Grant left a drop-in after the nil effective result: %v", err)
+ }
+ if _, err := os.Lstat(m.FilePath(acct) + removePendingSuffix); !os.IsNotExist(err) {
+ t.Fatalf("Grant left a pending-removal marker after rollback: %v", err)
+ }
+}
+
func TestGrantAndRemoveRefuseInvalidNames(t *testing.T) {
m := &Manager{Dir: t.TempDir()}
// Defense in depth: a name that escaped validation must never reach a
diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go
index 5709064..8af3076 100644
--- a/internal/sshkey/sshkey_test.go
+++ b/internal/sshkey/sshkey_test.go
@@ -55,7 +55,8 @@ func TestWriteAuthorizedKeysRejectsIDsOutsideKernelRange(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot represent a uid above uint32")
}
- reserved := int(uint64(^uint32(0)))
+ reservedKernelID := uint64(^uint32(0))
+ reserved := int(reservedKernelID)
tooLarge := reserved + 1
for _, ids := range [][2]int{{reserved, 1}, {1, reserved}, {tooLarge, 1}, {1, tooLarge}} {
if err := WriteAuthorizedKeys("/unused", ids[0], ids[1], nil); err == nil || !strings.Contains(err.Error(), "refusing non-user uid/gid") {
diff --git a/internal/sudoers/sudoers.go b/internal/sudoers/sudoers.go
index 85cffe2..b0ffd3f 100644
--- a/internal/sudoers/sudoers.go
+++ b/internal/sudoers/sudoers.go
@@ -12,6 +12,7 @@ import (
"path/filepath"
"sort"
"strings"
+ "syscall"
"time"
"github.com/xxvcc/linux-temp-admin/internal/config"
@@ -66,6 +67,9 @@ func (m *Manager) Grant(user string) error {
if !validate.Username(user) {
return fmt.Errorf("refusing sudoers grant for invalid username %q", user)
}
+ if m.Validate == nil {
+ return fmt.Errorf("sudoers validator is not configured")
+ }
fi, err := os.Lstat(m.Dir)
if err != nil {
return fmt.Errorf("sudoers dir: %w", err)
@@ -77,10 +81,11 @@ func (m *Manager) Grant(user string) error {
// Validate the exact bytes through stdin BEFORE the drop-in goes live in
// sudoers.d, so a syntactically broken file never briefly breaks sudo
// system-wide and no attacker-controlled temporary pathname is involved.
- if m.Validate != nil {
- if err := m.Validate(content); err != nil {
- return fmt.Errorf("sudoers validation failed: %w", err)
- }
+ if err := m.Validate(content); err != nil {
+ return fmt.Errorf("sudoers validation failed: %w", err)
+ }
+ if m.Verify == nil {
+ return fmt.Errorf("sudo policy verifier is not configured")
}
path := m.FilePath(user)
if err := fsutil.WriteRootFile(path, content, 0o440); err != nil {
@@ -92,23 +97,19 @@ func (m *Manager) Grant(user string) error {
}
return err
}
- if m.Verify != nil {
- if err := m.Verify(user); err != nil {
- // The drop-in is already live (WriteRootFile succeeded), so the grant is
- // real — back it out. If removal also fails, surface that loudly rather
- // than swallowing it, because the caller must know a NOPASSWD grant may
- // still be on disk and needs manual cleanup.
- if rmErr := m.removeFile(path); rmErr != nil {
- return fmt.Errorf("sudo policy did not take effect (%w) and rollback failed: %v; NOPASSWD drop-in may persist at %s", err, rmErr, path)
- }
- return fmt.Errorf("sudo policy did not take effect: %w", err)
+ if err := m.Verify(user); err != nil {
+ // The drop-in is already live (WriteRootFile succeeded), so the grant is
+ // real — back it out. If removal also fails, surface that loudly rather
+ // than swallowing it, because the caller must know a NOPASSWD grant may
+ // still be on disk and needs manual cleanup.
+ if rmErr := m.removeFile(path); rmErr != nil {
+ return fmt.Errorf("sudo policy did not take effect (%w) and rollback failed: %v; NOPASSWD drop-in may persist at %s", err, rmErr, path)
}
+ return fmt.Errorf("sudo policy did not take effect: %w", err)
}
return nil
}
-// Remove deletes the drop-in for user (best-effort). It only ever removes a file
-// under Dir carrying the managed prefix.
// Remove deletes the managed drop-in for user, if any. A file that is already
// absent is success — the caller wants the grant gone, and it is.
//
@@ -161,15 +162,26 @@ func (m *Manager) All() ([]string, error) {
continue
}
user := strings.TrimPrefix(entry.Name(), filePrefix)
- if user != "" && validate.Username(user) {
- users = append(users, user)
+ if user == "" || !validate.Username(user) {
+ return nil, fmt.Errorf("managed sudoers artifact has an invalid account name: %s", filepath.Join(m.Dir, entry.Name()))
}
+ users = append(users, user)
}
sort.Strings(users)
return users, nil
}
-var readManagedDir = os.ReadDir
+var readManagedDir = readSudoersDirectory
+
+func readSudoersDirectory(path string) ([]os.DirEntry, error) {
+ dir, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, err
+ }
+ entries, readErr := dir.ReadDir(-1)
+ closeErr := dir.Close()
+ return entries, errors.Join(readErr, closeErr)
+}
// Orphans returns the accounts whose managed drop-in is still on disk although
// the account itself is gone. exists reports whether an account is still present.
@@ -198,11 +210,12 @@ func (m *Manager) Orphans(exists func(string) (bool, error)) ([]string, error) {
return orphans, nil
}
-// visudoValidate syntax-checks a sudoers file. If visudo is unavailable the
-// check is skipped (best-effort).
+// visudoValidate syntax-checks a sudoers file. Missing visudo is a hard failure:
+// writing a root-capable policy without its canonical parser would discard the
+// pre-commit safety gate this package promises.
func visudoValidate(content []byte) error {
if _, err := exec.LookPath("visudo"); err != nil {
- return nil
+ return fmt.Errorf("visudo is required to validate sudo policy: %w", err)
}
opts := sudoProbeOptions
opts.Stdin = bytes.NewReader(content)
@@ -223,42 +236,86 @@ func verifyNopasswd(user string) error {
}
func verifyNopasswdOutput(out []byte) error {
+ foundAll := false
+ nopasswd := false
for _, raw := range strings.Split(string(out), "\n") {
line := strings.TrimSpace(raw)
if !strings.HasPrefix(line, "(") {
continue
}
endRunas := strings.IndexByte(line, ')')
- if endRunas < 2 || !runasIncludesRoot(line[1:endRunas]) {
+ if endRunas < 2 {
continue
}
- if nopasswdAll(strings.TrimSpace(line[endRunas+1:])) {
- return nil
+ includesRoot, ambiguous := runasRootScope(line[1:endRunas])
+ if ambiguous {
+ return fmt.Errorf("effective policy has an ambiguous negated RunAs list while verifying root NOPASSWD: ALL")
}
+ if !includesRoot {
+ continue
+ }
+ mode, exactAll := allAuthenticationMode(strings.TrimSpace(line[endRunas+1:]))
+ if !exactAll {
+ // A later root-applicable command list or restricted command can change
+ // the authentication tag for some commands. This narrow verifier does
+ // not fully parse Cmnd_Spec_List inheritance, aliases, or exclusions, so
+ // invalidate any earlier full-grant proof. A subsequent exact
+ // NOPASSWD: ALL line may establish it again.
+ foundAll = false
+ nopasswd = false
+ continue
+ }
+ // sudoers applies matching entries in policy order and uses the last
+ // match. Keep scanning so a later policy line cannot be hidden by an
+ // earlier NOPASSWD: ALL match.
+ foundAll = true
+ nopasswd = mode
+ }
+ if foundAll && nopasswd {
+ return nil
}
return fmt.Errorf("effective policy has no root NOPASSWD: ALL grant")
}
func runasIncludesRoot(runas string) bool {
+ includesRoot, ambiguous := runasRootScope(runas)
+ return includesRoot && !ambiguous
+}
+
+// runasRootScope reports whether the literal RunAs user list includes root and
+// whether it is too ambiguous to use as policy proof. A positive root/ALL token
+// combined with any exclusion may or may not still apply to root depending on
+// sudoers ordering and alias semantics. The verifier deliberately refuses the
+// complete proof instead of skipping that line and accidentally preserving an
+// earlier NOPASSWD verdict.
+func runasRootScope(runas string) (includesRoot, ambiguous bool) {
users := runas
if colon := strings.IndexByte(users, ':'); colon >= 0 {
users = users[:colon]
}
+ hasExclusion := false
for _, user := range strings.Split(users, ",") {
- switch strings.TrimSpace(user) {
+ user = strings.TrimSpace(user)
+ if strings.HasPrefix(user, "!") {
+ hasExclusion = true
+ continue
+ }
+ switch user {
case "root", "ALL":
- return true
+ includesRoot = true
}
}
- return false
+ return includesRoot, includesRoot && hasExclusion
}
-func nopasswdAll(spec string) bool {
- nopasswd := false
+// allAuthenticationMode reports the authentication mode of an exact ALL
+// command specification. The first result is true for NOPASSWD and false for
+// PASSWD; ok is false when spec is not an exact ALL command specification.
+func allAuthenticationMode(spec string) (nopasswd bool, ok bool) {
for {
colon := strings.IndexByte(spec, ':')
if colon < 0 {
- return nopasswd && strings.TrimSpace(spec) == "ALL"
+ return nopasswd, strings.TrimSpace(spec) == "ALL"
}
tag := strings.TrimSpace(spec[:colon])
switch tag {
@@ -273,7 +330,7 @@ func nopasswdAll(spec string) bool {
default:
// The colon belongs to the command or a later comma-separated rule,
// not to a leading tag sequence. It cannot be our exact ALL grant.
- return false
+ return false, false
}
spec = strings.TrimSpace(spec[colon+1:])
}
diff --git a/internal/sudoers/sudoers_test.go b/internal/sudoers/sudoers_test.go
index d945fa1..c6763a3 100644
--- a/internal/sudoers/sudoers_test.go
+++ b/internal/sudoers/sudoers_test.go
@@ -31,6 +31,40 @@ func TestAllPropagatesDirectoryReadFailure(t *testing.T) {
}
}
+func TestAllRejectsSymlinkedDirectory(t *testing.T) {
+ root := t.TempDir()
+ target := filepath.Join(root, "target")
+ if err := os.Mkdir(target, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(root, "link")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := (&Manager{Dir: link}).All(); err == nil {
+ t.Fatal("All followed a symlinked sudoers directory")
+ }
+}
+
+func TestAllAllowsAbsentDirectory(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "absent")
+ users, err := (&Manager{Dir: dir}).All()
+ if err != nil || len(users) != 0 {
+ t.Fatalf("All on absent directory = %v, %v; want empty success", users, err)
+ }
+}
+
+func TestAllRejectsMalformedManagedArtifact(t *testing.T) {
+ dir := t.TempDir()
+ name := filePrefix + "not.valid"
+ if err := os.WriteFile(filepath.Join(dir, name), nil, 0o440); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := (&Manager{Dir: dir}).All(); err == nil || !strings.Contains(err.Error(), name) {
+ t.Fatalf("All error = %v, want malformed managed artifact", err)
+ }
+}
+
func TestSudoersProbesAreBoundedAndUseCLocale(t *testing.T) {
old := sudoProbeOptions
t.Cleanup(func() { sudoProbeOptions = old })
@@ -45,6 +79,14 @@ func TestSudoersProbesAreBoundedAndUseCLocale(t *testing.T) {
}
})
+ t.Run("missing visudo fails closed", func(t *testing.T) {
+ sudoProbeOptions = old
+ t.Setenv("PATH", t.TempDir())
+ if err := visudoValidate([]byte("alice ALL=(ALL) NOPASSWD:ALL\n")); err == nil || !strings.Contains(err.Error(), "visudo is required") {
+ t.Fatalf("visudoValidate with no visudo = %v, want fail-closed error", err)
+ }
+ })
+
t.Run("sudo locale and argv", func(t *testing.T) {
sudoProbeOptions = old
dir := t.TempDir()
@@ -82,6 +124,26 @@ printf ' (root) NOPASSWD: ALL\n'`)
})
}
+func TestGrantFailsClosedWithoutValidationOrPolicyVerification(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ m *Manager
+ want string
+ }{
+ {name: "validator", m: &Manager{Dir: t.TempDir()}, want: "validator is not configured"},
+ {name: "verifier", m: &Manager{Dir: t.TempDir(), Validate: func([]byte) error { return nil }}, want: "verifier is not configured"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := tc.m.Grant("xxvcc-a1"); err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("Grant without %s = %v, want fail-closed error", tc.name, err)
+ }
+ if _, err := os.Lstat(tc.m.FilePath("xxvcc-a1")); !os.IsNotExist(err) {
+ t.Fatalf("Grant without %s wrote a live policy: %v", tc.name, err)
+ }
+ })
+ }
+}
+
func TestRemoveUsesInjectedRemoveFile(t *testing.T) {
wantErr := errors.New("injected remove failure")
var removed string
@@ -149,11 +211,23 @@ func TestVerifyNopasswdOutputRequiresRootNopasswdAll(t *testing.T) {
}{
{"root", "User alice may run the following commands:\n (root) NOPASSWD: ALL\n", true},
{"all runas", " (ALL : ALL) NOPASSWD: ALL\n", true},
+ {"all except root", " (ALL, !root) NOPASSWD: ALL\n", false},
+ {"root exclusion before all", " (!root, ALL) NOPASSWD: ALL\n", false},
+ {"negated all", " (root, !ALL) NOPASSWD: ALL\n", false},
{"non-root runas", " (daemon) NOPASSWD: ALL\n", false},
{"restricted command", " (root) NOPASSWD: /usr/bin/id\n", false},
{"password required", " (root) PASSWD: ALL\n", false},
{"unrelated nopasswd", " (daemon) NOPASSWD: /bin/true\n (root) PASSWD: ALL\n", false},
{"tag changes before all", " (root) NOPASSWD: /bin/true, PASSWD: ALL\n", false},
+ {"later passwd all overrides", " (root) NOPASSWD: ALL\n (root) PASSWD: ALL\n", false},
+ {"later all-runas passwd all overrides", " (root) NOPASSWD: ALL\n (ALL) PASSWD: ALL\n", false},
+ {"later root-applicable exclusion is globally ambiguous", " (root) NOPASSWD: ALL\n (ALL, !daemon) PASSWD: ALL\n", false},
+ {"restricted root-applicable exclusion is globally ambiguous", " (root) NOPASSWD: ALL\n (ALL, !daemon) PASSWD: /bin/true\n (root) NOPASSWD: ALL\n", false},
+ {"later restricted passwd invalidates full grant", " (root) NOPASSWD: ALL\n (root) PASSWD: /bin/true\n", false},
+ {"later command-list passwd all invalidates full grant", " (root) NOPASSWD: ALL\n (root) NOPASSWD: /bin/false, PASSWD: ALL\n", false},
+ {"exact later grant restores after command list", " (root) NOPASSWD: ALL\n (root) PASSWD: /bin/true\n (root) NOPASSWD: ALL\n", true},
+ {"later nopasswd all restores", " (root) PASSWD: ALL\n (root) NOPASSWD: ALL\n", true},
+ {"later non-root rule does not override", " (root) NOPASSWD: ALL\n (daemon) PASSWD: ALL\n", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -168,6 +242,29 @@ func TestVerifyNopasswdOutputRequiresRootNopasswdAll(t *testing.T) {
}
}
+func TestRunasIncludesRootFailsClosedOnExclusions(t *testing.T) {
+ tests := []struct {
+ runas string
+ want bool
+ }{
+ {runas: "root", want: true},
+ {runas: "ALL : ALL", want: true},
+ {runas: "daemon, root", want: true},
+ {runas: "daemon"},
+ {runas: "ALL, !root"},
+ {runas: "!root, ALL"},
+ {runas: "root, !ALL"},
+ {runas: "ALL, !daemon"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.runas, func(t *testing.T) {
+ if got := runasIncludesRoot(tt.runas); got != tt.want {
+ t.Fatalf("runasIncludesRoot(%q) = %t, want %t", tt.runas, got, tt.want)
+ }
+ })
+ }
+}
+
// TestOrphansFindsGrantsWhoseAccountIsGone pins the M1 fix. An orphaned
// NOPASSWD:ALL drop-in is the most dangerous leftover this tool can produce — it
// re-arms full root the instant its username is reused — so it must be findable.
@@ -178,7 +275,6 @@ func TestOrphansFindsGrantsWhoseAccountIsGone(t *testing.T) {
filePrefix + "xxvcc-alive", // ours, account exists -> not an orphan
"90-someone-elses-file", // not ours: never report or remove it
"foreign-valid-user", // valid username, but still not our namespace
- filePrefix + "BAD NAME", // ours-looking but not a valid username -> ignore
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x ALL=(ALL) NOPASSWD:ALL\n"), 0o440); err != nil {
t.Fatal(err)
diff --git a/internal/sysinfo/sshd.go b/internal/sysinfo/sshd.go
index 3db2b7a..4d78303 100644
--- a/internal/sysinfo/sshd.go
+++ b/internal/sysinfo/sshd.go
@@ -1,14 +1,15 @@
package sysinfo
-// This file reads sshd's *effective* configuration and answers the one question
-// the tool used to assume: will this server actually let the account we are about
-// to create log in with the key we are about to write?
+// This file reads sshd's *effective* configuration and checks whether it admits
+// the account and credential the tool plans to create.
//
-// The answer comes from `sshd -T`, never from parsing /etc/ssh/sshd_config by
-// hand: -T resolves Include directives, Match blocks, compiled-in defaults, and
-// distro crypto policy, and it is the same evaluation the running sshd performs.
-// Guessing at the file's text would be worse than not looking at all, because a
-// wrong guess turns into a confidently false invite.
+// The credential verdict comes from `sshd -T`: it resolves Include directives,
+// Match blocks, compiled-in defaults, and distro crypto policy with sshd's own
+// evaluator. A separate bounded scan of the source configuration only discovers
+// Match conditions that a user-only `-T -C` probe cannot evaluate; it never
+// derives the authentication verdict itself. This is not an end-to-end connection
+// test: network policy, PAM, SELinux, and the complete state of a running daemon
+// remain outside this check.
import (
"bufio"
@@ -126,9 +127,13 @@ type sshdConfigIdentity struct {
type sshdIncludeScan struct {
paths map[string]bool
identities map[sshdConfigIdentity]bool
- files int
- globs int
- bytes int64
+ // accountExists controls whether Match Group is evaluable by `sshd -T -C
+ // user=...`. OpenSSH cannot resolve group membership for a not-yet-created
+ // account, but it can after creation.
+ accountExists bool
+ files int
+ globs int
+ bytes int64
}
// HasConnectionScopedMatch reports whether sshd's configuration contains a
@@ -149,6 +154,16 @@ type sshdIncludeScan struct {
// An include that cannot be read is also unverifiable, so an incomplete scan can
// never produce a false verified claim.
func HasConnectionScopedMatch() bool {
+ return HasUnverifiableMatch(true)
+}
+
+// HasUnverifiableMatch reports whether sshd has a Match rule that a user-only
+// effective-config probe cannot evaluate in the caller's account phase.
+// Connection attributes (address, host, port, routing domain) are always
+// unavailable. Group is unavailable only before the account exists: OpenSSH
+// resolves actual NSS group membership when evaluating an existing user, but a
+// future account receives only the global result from `sshd -T -C user=name`.
+func HasUnverifiableMatch(accountExists bool) bool {
files := []string{sshdConfigPath}
if entries, err := strictGlob(filepath.Join(sshdConfigDropInDir, "*.conf")); err == nil {
files = append(files, entries...)
@@ -156,8 +171,9 @@ func HasConnectionScopedMatch() bool {
return true
}
scan := &sshdIncludeScan{
- paths: map[string]bool{},
- identities: map[sshdConfigIdentity]bool{},
+ paths: map[string]bool{},
+ identities: map[sshdConfigIdentity]bool{},
+ accountExists: accountExists,
}
baseDir := filepath.Dir(sshdConfigPath)
for _, f := range files {
@@ -209,13 +225,15 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d
sc := bufio.NewScanner(limited)
sc.Buffer(make([]byte, 64<<10), maxSSHDConfigLine)
for sc.Scan() {
- line, _, _ := strings.Cut(sc.Text(), "#")
- fields := strings.Fields(line)
- if len(fields) < 2 {
+ keyword, fields, parsed := parseSSHDDirective(sc.Text())
+ if !parsed {
+ return false, false
+ }
+ if keyword == "" || len(fields) == 0 {
continue
}
- if strings.EqualFold(fields[0], "Include") {
- for _, pattern := range fields[1:] {
+ if strings.EqualFold(keyword, "Include") {
+ for _, pattern := range fields {
if scan.globs >= maxSSHDIncludeGlobs {
return false, false
}
@@ -239,22 +257,39 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d
}
continue
}
- if !strings.EqualFold(fields[0], "Match") {
+ if !strings.EqualFold(keyword, "Match") {
continue
}
// Match is a sequence of criterion/value pairs, except the standalone All.
// Parse criterion positions rather than searching every token: `Match User
// host` has a value named "host", not a Host criterion.
- for i := 1; i < len(fields); {
- criterion := strings.ToLower(fields[i])
+ for i := 0; i < len(fields); {
+ criterion, _, embeddedValue := strings.Cut(fields[i], "=")
+ criterion = strings.ToLower(criterion)
switch criterion {
case "all":
i++
- case "user", "group":
- if i+1 >= len(fields) {
+ case "user":
+ if !embeddedValue && i+1 >= len(fields) {
return false, false
}
- i += 2
+ if embeddedValue {
+ i++
+ } else {
+ i += 2
+ }
+ case "group":
+ if !embeddedValue && i+1 >= len(fields) {
+ return false, false
+ }
+ if !scan.accountExists {
+ return true, true
+ }
+ if embeddedValue {
+ i++
+ } else {
+ i += 2
+ }
case "address", "host", "localaddress", "localport", "rdomain", "localnetwork", "tagged":
return true, true
default:
@@ -267,6 +302,75 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d
return false, sc.Err() == nil && limited.N > 0
}
+// parseSSHDDirective accepts both forms supported by OpenSSH's configuration
+// parser: "Keyword value" and "Keyword=value". It intentionally handles only
+// simple quoted tokens. A more complicated quoted line is reported as incomplete
+// so the caller downgrades the login verdict instead of silently skipping policy.
+func parseSSHDDirective(line string) (keyword string, args []string, complete bool) {
+ var ok bool
+ line, ok = stripSSHDComment(line)
+ if !ok {
+ return "", nil, false
+ }
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return "", nil, true
+ }
+ separator := strings.IndexAny(line, " \t=")
+ if separator < 0 {
+ keyword, ok := unquoteSimpleSSHDToken(line)
+ return keyword, nil, ok
+ }
+ keyword, ok = unquoteSimpleSSHDToken(line[:separator])
+ if !ok {
+ return "", nil, false
+ }
+ rest := strings.TrimLeft(line[separator:], " \t")
+ if strings.HasPrefix(rest, "=") {
+ rest = strings.TrimLeft(rest[1:], " \t")
+ }
+ for _, field := range strings.Fields(rest) {
+ value, ok := unquoteSimpleSSHDToken(field)
+ if !ok {
+ return "", nil, false
+ }
+ args = append(args, value)
+ }
+ return keyword, args, true
+}
+
+// stripSSHDComment mirrors OpenSSH's token boundary for comments: '#' starts a
+// comment only at the beginning of a token after whitespace. A hash embedded in
+// an unquoted or quoted argument is literal (for example Include conf#backup).
+// Backslash escapes are deliberately left unsupported; treating a complex line
+// as incomplete makes the caller downgrade the verdict instead of guessing.
+func stripSSHDComment(line string) (string, bool) {
+ inQuote := false
+ for i := 0; i < len(line); i++ {
+ switch line[i] {
+ case '\\':
+ return "", false
+ case '"':
+ inQuote = !inQuote
+ case '#':
+ if !inQuote && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t') {
+ return line[:i], true
+ }
+ }
+ }
+ return line, !inQuote
+}
+
+func unquoteSimpleSSHDToken(token string) (string, bool) {
+ if !strings.ContainsRune(token, '"') {
+ return token, true
+ }
+ if len(token) < 2 || token[0] != '"' || token[len(token)-1] != '"' || strings.Count(token, "\"") != 2 {
+ return "", false
+ }
+ return token[1 : len(token)-1], true
+}
+
var errSSHDGlobLimit = errors.New("sshd Include directory exceeds traversal limit")
var strictGlobReadDir = readSSHDGlobDir
@@ -292,7 +396,7 @@ func readSSHDGlobDir(path string) ([]os.DirEntry, error) {
// incomplete sshd Include scan must downgrade the login verdict, not hide a
// connection-scoped Match rule.
func strictGlob(pattern string) ([]string, error) {
- if _, err := filepath.Match(pattern, ""); err != nil {
+ if _, err := matchSSHDIncludeGlob(pattern, ""); err != nil {
return nil, err
}
return strictGlobDepth(pattern, 0)
@@ -357,7 +461,7 @@ func strictGlobDir(dir, pattern string, matches []string) ([]string, error) {
return nil, err
}
for _, entry := range entries {
- matched, err := filepath.Match(pattern, entry.Name())
+ matched, err := matchSSHDIncludeGlob(pattern, entry.Name())
if err != nil {
return nil, err
}
@@ -371,6 +475,67 @@ func strictGlobDir(dir, pattern string, matches []string) ([]string, error) {
return matches, nil
}
+// OpenSSH expands Include with POSIX glob(3), where [!x] negates a bracket
+// expression. Go's filepath.Match uses [^x] for the same operation. Translate
+// only an unescaped '!' immediately after '['; the rest of the pattern retains
+// filepath.Match's pathname and escaping rules.
+func matchSSHDIncludeGlob(pattern, name string) (bool, error) {
+ // POSIX glob also supports named character classes, collating symbols, and
+ // equivalence classes. filepath.Match does not. Refuse those constructs so an
+ // Include cannot silently disappear from this safety scan.
+ if hasUnsupportedPOSIXBracketConstruct(pattern) {
+ return false, filepath.ErrBadPattern
+ }
+ var normalized strings.Builder
+ normalized.Grow(len(pattern))
+ escaped := false
+ for i := 0; i < len(pattern); i++ {
+ ch := pattern[i]
+ if !escaped && ch == '[' && i+1 < len(pattern) && pattern[i+1] == '!' {
+ normalized.WriteString("[^")
+ i++
+ escaped = false
+ continue
+ }
+ normalized.WriteByte(ch)
+ if ch == '\\' && !escaped {
+ escaped = true
+ } else {
+ escaped = false
+ }
+ }
+ return filepath.Match(normalized.String(), name)
+}
+
+func hasUnsupportedPOSIXBracketConstruct(pattern string) bool {
+ inBracket := false
+ escaped := false
+ for i := 0; i < len(pattern); i++ {
+ ch := pattern[i]
+ if escaped {
+ escaped = false
+ continue
+ }
+ if ch == '\\' {
+ escaped = true
+ continue
+ }
+ if !inBracket {
+ if ch == '[' {
+ inBracket = true
+ }
+ continue
+ }
+ if ch == '[' && i+1 < len(pattern) && strings.ContainsRune(":.=", rune(pattern[i+1])) {
+ return true
+ }
+ if ch == ']' {
+ inBracket = false
+ }
+ }
+ return false
+}
+
func globHasMeta(path string) bool { return strings.ContainsAny(path, `*?[\`) }
// Blocker is one reason a login would fail. The values are stable identifiers,
@@ -444,9 +609,9 @@ func (b Blocker) Fixable() bool {
return false
}
-// LoginReport is what sshd's effective config says about one account's ability
-// to log in. Detail carries the offending effective value, so a message can
-// quote what it actually found rather than a generic complaint.
+// LoginReport describes whether sshd's effective config contains a known blocker
+// or an unevaluated rule for one account. Detail carries the offending effective
+// value, so a message can quote what it found rather than a generic complaint.
type LoginReport struct {
Blockers []Blocker
Warnings []string // human-facing English notes; the cli renders them verbatim
@@ -457,7 +622,7 @@ type LoginReport struct {
// an `AllowUsers user@host` pattern, because nobody can say which IP the
// invitee will connect from. Such a rule is neither a pass nor a blocker: it
// means "no verdict". An invite must not be stamped verified while one stands,
- // and a grant must not claim to have proved anything.
+ // and a grant must not claim a conclusive configuration result.
Unverifiable []string
// AlgoDirective is the directive name sshd itself used for the accepted
@@ -467,13 +632,12 @@ type LoginReport struct {
AlgoDirective string
}
-// OK reports whether nothing blocks the login.
+// OK reports whether the effective-config check produced no blocker.
func (r LoginReport) OK() bool { return len(r.Blockers) == 0 }
-// Certain reports whether the login provably works: nothing blocks it AND every
-// rule that bears on it could actually be evaluated. Only a Certain report may
-// be printed as "verified" — OK() alone would let an unevaluated rule pass for a
-// proof, which is the class of false promise this whole check exists to end.
+// Certain reports whether the effective-config check produced no blocker and could
+// evaluate every relevant rule. Only a Certain report may be printed as "verified
+// against the effective sshd config"; it does not prove an end-to-end login.
func (r LoginReport) Certain() bool { return r.OK() && len(r.Unverifiable) == 0 }
// Fixable reports whether every blocker can be lifted by a per-user drop-in.
@@ -507,15 +671,15 @@ func (r *LoginReport) block(b Blocker, detail string) {
r.Detail[b] = detail
}
-// CheckKeyLogin reports whether c would let user log in with an ed25519 key
-// written to ~/.ssh/authorized_keys. groups are the account's group names (its
-// primary group is enough for a freshly created account); pass the predicted
-// group before the account exists.
+// CheckKeyLogin evaluates c for an ed25519 key planned for
+// ~/.ssh/authorized_keys. groups are the account's group names (its primary group
+// is enough for a freshly created account); pass the predicted group before the
+// account exists.
//
// It is used twice: once before anything is created (to refuse or to offer a
-// fix), and once after a drop-in is written (to prove the fix actually took
-// effect). Reusing one function for both is the point — the invite can only
-// claim "SSH key only" because this exact check passed against the live config.
+// fix), and once after a drop-in is written (to confirm the effective config
+// contains the intended change). Reusing one function for both keeps the invite's
+// configuration verdict tied to the same check.
func CheckKeyLogin(c *SSHDConfig, user string, groups []string) LoginReport {
var r LoginReport
if !yes(c.First("pubkeyauthentication")) {
@@ -542,9 +706,9 @@ func CheckKeyLogin(c *SSHDConfig, user string, groups []string) LoginReport {
return r
}
-// CheckPasswordLogin reports whether c would let user log in with a password.
-// It exists so --password-login can never print a password for an account that
-// the server would refuse anyway.
+// CheckPasswordLogin evaluates c for the planned password credential. It exists
+// so --password-login is refused when the check reports a blocker or cannot fully
+// evaluate that authentication method.
func CheckPasswordLogin(c *SSHDConfig, user string, groups []string) LoginReport {
var r LoginReport
if !yes(c.First("passwordauthentication")) {
@@ -576,7 +740,7 @@ func checkAccess(c *SSHDConfig, user string, groups []string, r *LoginReport) {
if deny := c.Values("denygroups"); len(deny) > 0 && matchesUser(deny, groups) {
r.block(BlockDenyGroups, strings.Join(deny, " "))
}
- // Allow: fail open only on proof. An address-qualified entry yields no verdict
+ // Allow: require a conclusive match. An address-qualified entry yields no verdict
// rather than a pass. It must NOT become a blocker either: the automatic fix
// would then write `AllowUsers `, quietly cancelling the operator's
// network restriction for this account — repairing the report by weakening the
@@ -586,8 +750,8 @@ func checkAccess(c *SSHDConfig, user string, groups []string, r *LoginReport) {
switch {
case allowed:
// A bare entry admits the account from anywhere; the address-qualified ones
- // are then redundant, so the login is provably allowed and there is nothing
- // unverifiable to carry.
+ // are then redundant, so the config conclusively admits the account and
+ // there is nothing unverifiable to carry.
case len(unsure) > 0:
r.Unverifiable = append(r.Unverifiable, unsure...)
default:
diff --git a/internal/sysinfo/sshd_test.go b/internal/sysinfo/sshd_test.go
index 3d500f3..aad84eb 100644
--- a/internal/sysinfo/sshd_test.go
+++ b/internal/sysinfo/sshd_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
@@ -408,6 +409,38 @@ func TestHasConnectionScopedMatch(t *testing.T) {
t.Error("a `Match Address` block in the main config must be detected")
}
+ // OpenSSH accepts '=' between a keyword and its first argument. Treating the
+ // whole left-hand side as a keyword used to hide both Match and Include.
+ write(main, "PubkeyAuthentication yes\nMatch=Address 203.0.113.0/24\n DenyUsers "+acct+"\n")
+ if !HasConnectionScopedMatch() {
+ t.Error("a `Match=Address` block in the main config must be detected")
+ }
+ write(dir+"/equals.conf", "Match Address 203.0.113.0/24\n")
+ write(main, "Include=equals.conf\n")
+ if !HasConnectionScopedMatch() {
+ t.Error("a connection-scoped Match behind `Include=path` must be detected")
+ }
+
+ // '#' is literal when attached to an OpenSSH argument. Truncating at every
+ // hash would scan hash.conf below and miss the real hash.conf#backup Include.
+ write(dir+"/hash.conf", "PubkeyAuthentication yes\n")
+ write(dir+"/hash.conf#backup", "Match Address 203.0.113.0/24\n")
+ write(main, "Include hash.conf#backup\n")
+ if !HasConnectionScopedMatch() {
+ t.Error("a literal # in an Include path was mistaken for a comment")
+ }
+
+ // Include uses POSIX glob(3), whose bracket negation is [!x]. filepath.Match
+ // uses [^x], so the scanner must bridge that syntax difference.
+ if err := os.MkdirAll(dir+"/glob", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ write(dir+"/glob/abc.conf", "Match Address 203.0.113.0/24\n")
+ write(main, "Include glob/[!x]*.conf\n")
+ if !HasConnectionScopedMatch() {
+ t.Error("a connection-scoped Match behind a POSIX [!x] Include glob must be detected")
+ }
+
// In a drop-in, on the Host criterion, and as a later criterion after User.
write(main, "PubkeyAuthentication yes\n")
write(dropins+"/10-x.conf", "Match User bob Host bastion.example\n X11Forwarding no\n")
@@ -422,6 +455,17 @@ func TestHasConnectionScopedMatch(t *testing.T) {
t.Error("a plain `Match User` block is not connection-scoped and must not be flagged")
}
+ // Before useradd, OpenSSH cannot resolve the future account's NSS groups, so a
+ // Group criterion is unknown. Once the account exists, a user-only -C probe can
+ // evaluate it and only genuinely connection-scoped criteria remain unknown.
+ write(dropins+"/10-x.conf", "Match Group admins\n PermitTTY no\n")
+ if !HasUnverifiableMatch(false) {
+ t.Error("a pre-account `Match Group` rule must make the probe unverifiable")
+ }
+ if HasUnverifiableMatch(true) {
+ t.Error("an existing account's `Match Group` rule is evaluable by sshd")
+ }
+
// Criterion values that happen to equal criterion names are not criteria.
write(dropins+"/10-x.conf", "Match User host Group address\n PermitTTY no\n")
if HasConnectionScopedMatch() {
@@ -458,6 +502,31 @@ func TestHasConnectionScopedMatch(t *testing.T) {
}
}
+func TestParseSSHDDirectiveCommentBoundaries(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ line string
+ keyword string
+ args []string
+ complete bool
+ }{
+ {name: "whole comment", line: " # Include ignored.conf", complete: true},
+ {name: "inline comment", line: "Include live.conf # backup", keyword: "Include", args: []string{"live.conf"}, complete: true},
+ {name: "attached hash", line: "Include live.conf#backup", keyword: "Include", args: []string{"live.conf#backup"}, complete: true},
+ {name: "quoted hash", line: `Include "live.conf#backup"`, keyword: "Include", args: []string{"live.conf#backup"}, complete: true},
+ {name: "escaped token is unknown", line: `Include live\\ file.conf`, complete: false},
+ {name: "unterminated quote is unknown", line: `Include "live.conf`, complete: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ keyword, args, complete := parseSSHDDirective(tc.line)
+ if keyword != tc.keyword || !reflect.DeepEqual(args, tc.args) || complete != tc.complete {
+ t.Fatalf("parseSSHDDirective(%q) = %q, %v, %v; want %q, %v, %v",
+ tc.line, keyword, args, complete, tc.keyword, tc.args, tc.complete)
+ }
+ })
+ }
+}
+
func TestHasConnectionScopedMatchFailsClosedOnGlobIOError(t *testing.T) {
dir := t.TempDir()
main := dir + "/sshd_config"
@@ -485,6 +554,53 @@ func TestHasConnectionScopedMatchFailsClosedOnGlobIOError(t *testing.T) {
}
}
+func TestSSHDIncludePOSIXBracketGlobAndMalformedPattern(t *testing.T) {
+ for _, tc := range []struct {
+ pattern string
+ name string
+ want bool
+ }{
+ {pattern: "[!x]*.conf", name: "abc.conf", want: true},
+ {pattern: "[!x]*.conf", name: "xbc.conf", want: false},
+ {pattern: "[^x]*.conf", name: "abc.conf", want: true},
+ } {
+ got, err := matchSSHDIncludeGlob(tc.pattern, tc.name)
+ if err != nil || got != tc.want {
+ t.Errorf("matchSSHDIncludeGlob(%q, %q) = %v, %v; want %v", tc.pattern, tc.name, got, err, tc.want)
+ }
+ }
+ if _, err := matchSSHDIncludeGlob("[!unterminated", "anything.conf"); err == nil {
+ t.Fatal("an unterminated POSIX bracket expression was accepted")
+ }
+ for _, pattern := range []string{
+ "[[:alpha:]]*.conf", "[[.ch.]]*.conf", "[[=a=]]*.conf",
+ "[a[:digit:]]*.conf", "[a[.ch.]]*.conf", "[a[=x=]]*.conf",
+ } {
+ if _, err := matchSSHDIncludeGlob(pattern, "abc.conf"); err == nil {
+ t.Errorf("unsupported POSIX bracket construct %q was not rejected", pattern)
+ }
+ if _, err := strictGlob(filepath.Join(t.TempDir(), pattern)); err == nil {
+ t.Errorf("unsupported POSIX bracket construct %q was accepted when its directory was empty", pattern)
+ }
+ }
+
+ dir := t.TempDir()
+ main := filepath.Join(dir, "sshd_config")
+ dropins := filepath.Join(dir, "sshd_config.d")
+ if err := os.MkdirAll(dropins, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(main, []byte("Include missing/[!unterminated\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ oldConfig, oldDropins := sshdConfigPath, sshdConfigDropInDir
+ sshdConfigPath, sshdConfigDropInDir = main, dropins
+ t.Cleanup(func() { sshdConfigPath, sshdConfigDropInDir = oldConfig, oldDropins })
+ if !HasConnectionScopedMatch() {
+ t.Fatal("a malformed Include bracket expression was treated as a complete policy scan")
+ }
+}
+
func TestHasConnectionScopedMatchBoundsAndRejectsSpecialFiles(t *testing.T) {
dir := t.TempDir()
main := dir + "/sshd_config"
diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go
index 9eba33b..fb7d95e 100644
--- a/internal/sysinfo/sysinfo.go
+++ b/internal/sysinfo/sysinfo.go
@@ -1,7 +1,6 @@
// Package sysinfo detects the host's package manager, init system, SSH port, and
-// which external tools the tool still depends on. Key generation, downloads, date
-// arithmetic, passwd lookups, file installs, locking, and process signalling are
-// all native, leaving only the account-management tools as external commands.
+// account-management dependencies. It invokes bounded external helpers for
+// package installation and effective sshd configuration probes.
package sysinfo
import (
@@ -70,7 +69,7 @@ func InitSystem() string {
}
}
-// Dep describes a required external tool (Any means one of Names suffices).
+// Dep describes a required external capability and its executable alternatives.
type Dep struct {
Label string
Names []string
@@ -78,33 +77,32 @@ type Dep struct {
}
// RequiredDeps returns the external account-management tools the tool needs.
-// needSudo adds sudo.
-func RequiredDeps(needSudo bool) []Dep {
+// needPassword adds chpasswd; needSudo adds both sudo and its mandatory policy
+// validator, visudo.
+func RequiredDeps(needSudo, needPassword bool) []Dep {
deps := []Dep{
- {Label: "id", Names: []string{"id"}},
- {Label: "useradd/adduser", Names: []string{"useradd", "adduser"}},
- {Label: "usermod", Names: []string{"usermod"}},
- {Label: "chage", Names: []string{"chage"}},
- {Label: "userdel/deluser", Names: []string{"userdel", "deluser"}},
+ {Label: "id", Names: []string{"id"}, Present: has("id")},
+ {Label: "useradd", Names: []string{"useradd"}, Present: has("useradd")},
+ {Label: "usermod", Names: []string{"usermod"}, Present: has("usermod")},
+ {Label: "chage", Names: []string{"chage"}, Present: has("chage")},
+ {Label: "userdel", Names: []string{"userdel"}, Present: has("userdel")},
}
- if needSudo {
- deps = append(deps, Dep{Label: "sudo", Names: []string{"sudo"}})
+ if needPassword {
+ deps = append(deps, Dep{Label: "chpasswd", Names: []string{"chpasswd"}, Present: has("chpasswd")})
}
- for i := range deps {
- for _, n := range deps[i].Names {
- if has(n) {
- deps[i].Present = true
- break
- }
- }
+ if needSudo {
+ deps = append(deps,
+ Dep{Label: "sudo", Names: []string{"sudo"}, Present: has("sudo")},
+ Dep{Label: "visudo", Names: []string{"visudo"}, Present: has("visudo")},
+ )
}
return deps
}
// MissingDeps returns the labels of required tools that are absent.
-func MissingDeps(needSudo bool) []string {
+func MissingDeps(needSudo, needPassword bool) []string {
var missing []string
- for _, d := range RequiredDeps(needSudo) {
+ for _, d := range RequiredDeps(needSudo, needPassword) {
if !d.Present {
missing = append(missing, d.Label)
}
@@ -116,7 +114,7 @@ func MissingDeps(needSudo bool) []string {
// manager, or "" if unknown.
func PackageCandidate(label, pm string) string {
switch label {
- case "useradd/adduser", "usermod", "userdel/deluser", "chage":
+ case "useradd", "usermod", "userdel", "chage", "chpasswd":
switch pm {
case "apt":
return "passwd"
@@ -127,7 +125,7 @@ func PackageCandidate(label, pm string) string {
}
case "id":
return "coreutils"
- case "sudo":
+ case "sudo", "visudo":
return "sudo"
}
return ""
diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go
index aec400b..bf85fc9 100644
--- a/internal/sysinfo/sysinfo_test.go
+++ b/internal/sysinfo/sysinfo_test.go
@@ -173,7 +173,7 @@ func TestPackageCandidate(t *testing.T) {
if got := PackageCandidate("chage", "apt"); got != "passwd" {
t.Errorf("chage/apt = %q, want passwd", got)
}
- if got := PackageCandidate("useradd/adduser", "apk"); got != "shadow" {
+ if got := PackageCandidate("useradd", "apk"); got != "shadow" {
t.Errorf("useradd/apk = %q, want shadow", got)
}
if got := PackageCandidate("chage", "dnf"); got != "shadow-utils" {
@@ -185,14 +185,61 @@ func TestPackageCandidate(t *testing.T) {
if got := PackageCandidate("unknown-tool", "apt"); got != "" {
t.Errorf("unknown = %q, want empty", got)
}
+ if got := PackageCandidate("chpasswd", "apk"); got != "shadow" {
+ t.Errorf("chpasswd/apk = %q, want shadow", got)
+ }
}
func TestRequiredDepsShape(t *testing.T) {
- // Without sudo: id plus 4 account deps. With sudo: 6.
- if n := len(RequiredDeps(false)); n != 5 {
- t.Errorf("RequiredDeps(false) has %d deps, want 5", n)
+ // Base: id plus 4 account deps. Password and sudo features add their own
+ // helpers without making them mandatory for key-only, non-sudo invites.
+ deps := RequiredDeps(false, false)
+ if n := len(deps); n != 5 {
+ t.Errorf("RequiredDeps(false, false) has %d deps, want 5", n)
+ }
+ if n := len(RequiredDeps(false, true)); n != 6 {
+ t.Errorf("RequiredDeps(false, true) has %d deps, want 6", n)
+ }
+ if n := len(RequiredDeps(true, false)); n != 7 {
+ t.Errorf("RequiredDeps(true, false) has %d deps, want 7", n)
+ }
+ if n := len(RequiredDeps(true, true)); n != 8 {
+ t.Errorf("RequiredDeps(true, true) has %d deps, want 8", n)
+ }
+ passwordDep := false
+ for _, dep := range RequiredDeps(false, true) {
+ if dep.Label == "chpasswd" && len(dep.Names) == 1 && dep.Names[0] == "chpasswd" {
+ passwordDep = true
+ }
+ }
+ if !passwordDep {
+ t.Fatal("password mode did not require chpasswd")
+ }
+ if got := PackageCandidate("visudo", "apt"); got != "sudo" {
+ t.Errorf("PackageCandidate(visudo, apt) = %q, want sudo", got)
+ }
+ foundCreate := false
+ foundDelete := false
+ for _, dep := range deps {
+ if dep.Label == "useradd" {
+ foundCreate = len(dep.Names) == 1 && dep.Names[0] == "useradd"
+ }
+ if dep.Label == "userdel" {
+ foundDelete = len(dep.Names) == 1 && dep.Names[0] == "userdel"
+ }
+ for _, name := range dep.Names {
+ if name == "adduser" || name == "deluser" || name == "busybox" {
+ t.Errorf("RequiredDeps accepts an account helper with unproven semantics: %+v", dep)
+ }
+ }
+ }
+ if !foundCreate {
+ t.Fatalf("RequiredDeps does not require useradd: %+v", deps)
+ }
+ if !foundDelete {
+ t.Fatalf("RequiredDeps does not require userdel: %+v", deps)
}
- if n := len(RequiredDeps(true)); n != 6 {
- t.Errorf("RequiredDeps(true) has %d deps, want 6", n)
+ if got := PackageCandidate("userdel", "apt"); got != "passwd" {
+ t.Errorf("PackageCandidate(userdel, apt) = %q, want passwd", got)
}
}
diff --git a/internal/user/user.go b/internal/user/user.go
index d7535c6..329e489 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -1,8 +1,8 @@
// Package user manages the lifecycle of temporary local accounts: creating,
// locking, expiring, and deleting them, plus the protection checks that keep the
// tool from ever touching a system or real account. Account mutations shell out
-// to the distro's user tools (useradd/usermod/chage/userdel or the BusyBox
-// adduser/deluser) via an injectable runner, so argv is unit-testable; passwd
+// to the distro's shadow tools (useradd/usermod/chage/userdel) via an injectable
+// runner, so argv is unit-testable; passwd
// lookups and process termination are done natively (no getent/pkill).
package user
@@ -16,10 +16,13 @@ import (
"sort"
"strconv"
"strings"
+ "syscall"
"time"
"github.com/xxvcc/linux-temp-admin/internal/config"
"github.com/xxvcc/linux-temp-admin/internal/executil"
+ "github.com/xxvcc/linux-temp-admin/internal/fsutil"
+ "github.com/xxvcc/linux-temp-admin/internal/mountinfo"
"github.com/xxvcc/linux-temp-admin/internal/validate"
"golang.org/x/sys/unix"
)
@@ -64,21 +67,44 @@ func Lookup(name string) (Passwd, bool, error) {
if err != nil {
return Passwd{}, false, fmt.Errorf("read passwd database: %w", err)
}
+ var found *Passwd
for _, line := range strings.Split(string(data), "\n") {
parts := strings.Split(line, ":")
- if len(parts) < 7 || parts[0] != name {
+ if len(parts) == 0 || parts[0] != name {
continue
}
- uid, err1 := strconv.Atoi(parts[2])
- gid, err2 := strconv.Atoi(parts[3])
- if err1 != nil || err2 != nil || !validate.KernelID(uid) || !validate.KernelID(gid) {
- return Passwd{}, false, fmt.Errorf("malformed passwd entry for %s", name)
+ if found != nil {
+ return Passwd{}, false, fmt.Errorf("duplicate passwd entries for %s", name)
}
- return Passwd{Name: parts[0], UID: uid, GID: gid, GECOS: parts[4], Home: parts[5], Shell: parts[6]}, true, nil
+ pw, err := parsePasswdEntry(line)
+ if err != nil {
+ return Passwd{}, false, err
+ }
+ found = &pw
+ }
+ if found != nil {
+ return *found, true, nil
}
return Passwd{}, false, nil
}
+func parsePasswdEntry(line string) (Passwd, error) {
+ parts := strings.Split(line, ":")
+ name := ""
+ if len(parts) > 0 {
+ name = parts[0]
+ }
+ if len(parts) != 7 {
+ return Passwd{}, fmt.Errorf("malformed passwd entry for %s", name)
+ }
+ uid, err1 := strconv.Atoi(parts[2])
+ gid, err2 := strconv.Atoi(parts[3])
+ if err1 != nil || err2 != nil || !validate.KernelID(uid) || !validate.KernelID(gid) {
+ return Passwd{}, fmt.Errorf("malformed passwd entry for %s", name)
+ }
+ return Passwd{Name: name, UID: uid, GID: gid, GECOS: parts[4], Home: parts[5], Shell: parts[6]}, nil
+}
+
func readPasswdDatabase(path string, maxBytes int64) ([]byte, error) {
f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
if err != nil {
@@ -111,6 +137,42 @@ func Exists(name string) (bool, error) {
return ok, err
}
+// LifecycleMarkerAccounts returns local passwd names carrying an exact marker
+// written during this tool's account lifecycle. The result is discovery evidence
+// only: GECOS is root/user writable and must never authorize account deletion.
+// Callers must bind a completed registry row, UID, generation, and passwd snapshot
+// separately before performing any destructive action.
+func LifecycleMarkerAccounts() ([]string, error) {
+ data, err := readPasswdDatabase(passwdPath, maxLocalPasswdBytes)
+ if err != nil {
+ return nil, fmt.Errorf("read passwd database: %w", err)
+ }
+ seen := make(map[string]bool)
+ var names []string
+ for i, line := range strings.Split(string(data), "\n") {
+ if line == "" {
+ continue
+ }
+ pw, err := parsePasswdEntry(line)
+ if err != nil {
+ return nil, fmt.Errorf("scan account markers at passwd line %d: %w", i+1, err)
+ }
+ if seen[pw.Name] {
+ return nil, fmt.Errorf("scan account markers: duplicate passwd entries for %s", pw.Name)
+ }
+ seen[pw.Name] = true
+ if !HasLifecycleMarker(pw) {
+ continue
+ }
+ if !validate.Username(pw.Name) {
+ return nil, fmt.Errorf("account marker names invalid temporary username %q", pw.Name)
+ }
+ names = append(names, pw.Name)
+ }
+ sort.Strings(names)
+ return names, nil
+}
+
// NameInUse reports whether either the local passwd database or the host's NSS
// resolver knows name. Account ownership still comes only from /etc/passwd, but
// invite must not create a local account that shadows an LDAP/SSSD identity.
@@ -179,11 +241,27 @@ func IsManaged(name string) (bool, error) {
// markers. It is suitable for display and explicitly confirmed recovery only;
// registry-backed identity decisions must use MatchesManagedGeneration.
func IsManagedEntry(pw Passwd) bool {
- name := gecosFullName(pw.GECOS)
- if name == config.ManagedGECOS {
+ return IsLegacyManagedEntry(pw) || hasManagedGenerationMarker(pw)
+}
+
+func hasManagedGenerationMarker(pw Passwd) bool {
+ generation, found := strings.CutPrefix(gecosFullName(pw.GECOS), config.ManagedGenerationGECOSPrefix)
+ return found && validate.Generation(generation)
+}
+
+// HasLifecycleMarker recognizes exact pending, legacy managed, and
+// generation-bound managed markers. It is intentionally weaker than identity:
+// use it to notice an account that must block cleanup, never to authorize delete.
+func HasLifecycleMarker(pw Passwd) bool {
+ if IsManagedEntry(pw) {
return true
}
- generation, found := strings.CutPrefix(name, config.ManagedGenerationGECOSPrefix)
+ return hasPendingGenerationMarker(pw)
+}
+
+func hasPendingGenerationMarker(pw Passwd) bool {
+ name := gecosFullName(pw.GECOS)
+ generation, found := strings.CutPrefix(name, config.PendingGenerationGECOSPrefix)
return found && validate.Generation(generation)
}
@@ -244,6 +322,16 @@ func IsReservedName(name string) bool {
return protectedNames[name] || strings.HasPrefix(name, "systemd-")
}
+func validateMutationName(name string) error {
+ if !validate.Username(name) {
+ return fmt.Errorf("invalid username %q", name)
+ }
+ if IsReservedName(name) {
+ return fmt.Errorf("refusing reserved username %q", name)
+ }
+ return nil
+}
+
// IsProtectedRevokeTarget reports whether deleting name must be refused.
// registered says whether the tool's registry lists it, and recordedUID is the
// UID the registry recorded when it created the account (0 = not recorded, i.e.
@@ -291,7 +379,12 @@ func IsProtectedRevokeEntry(name string, pw Passwd, exists, registered bool, rec
return true
}
}
- managed := IsManagedEntry(pw)
+ // A fixed legacy marker can be reproduced and is therefore only recovery
+ // evidence. It becomes deletion authority solely when the caller obtained the
+ // explicit legacy confirmation, including when the registry row was lost. A
+ // random generation marker may still support explicit unregistered recovery;
+ // registered accounts must match their exact recorded generation below.
+ managed := hasManagedGenerationMarker(pw) || (allowLegacy && IsLegacyManagedEntry(pw))
if registered {
managed = MatchesManagedGeneration(pw, recordedGeneration) || (allowLegacy && IsLegacyManagedEntry(pw))
}
@@ -359,69 +452,214 @@ func (execRunner) RunInput(stdin string, name string, args ...string) error {
func (execRunner) Look(name string) bool { _, err := exec.LookPath(name); return err == nil }
// Manager performs account mutations via its Runner.
-type Manager struct{ Runner Runner }
+type Manager struct {
+ Runner Runner
+ LookupUser func(string) (Passwd, bool, error)
+ PrepareManagedHome func(string) error
+ CreateManagedHome func(Passwd) error
+ ValidateManagedHome func(Passwd) error
+ RemoveManagedMail func(Passwd) error
+ RemoveManagedHome func(Passwd) error
+}
// New returns a Manager using real command execution.
-func New() *Manager { return &Manager{Runner: execRunner{}} }
+func New() *Manager {
+ return &Manager{
+ Runner: execRunner{},
+ LookupUser: Lookup,
+ PrepareManagedHome: prepareManagedHome,
+ CreateManagedHome: createManagedHome,
+ ValidateManagedHome: validateCreatedHome,
+ RemoveManagedMail: removeManagedMail,
+ RemoveManagedHome: removeManagedHome,
+ }
+}
-// Create makes a new account with a generation-bound managed GECOS tag. Invite uses
-// CreatePending instead so an older binary cannot mistake a pre-UID-registration
-// account for a completed managed identity.
+// Create makes a new account with a generation-bound managed GECOS tag and an
+// empty managed Home.
func (m *Manager) Create(name, shell, generation string) error {
gecos, err := ManagedGECOSForGeneration(generation)
if err != nil {
return err
}
- return m.create(name, shell, gecos)
+ _, err = m.create(name, shell, gecos, true)
+ return err
}
-// CreatePending makes an account whose GECOS is intentionally not the managed
-// marker. The caller must persist the selected UID and then call MarkManaged
-// before granting credentials or policy.
+// CreatePending makes an account and empty Home whose GECOS is intentionally not
+// the managed marker. It preserves the original convenience API; invite uses
+// CreatePendingIdentity so it can keep the Home absent while draining inherited
+// work, then create it against the captured identity.
func (m *Manager) CreatePending(name, shell, generation string) error {
gecos, err := pendingGECOSForGeneration(generation)
if err != nil {
return err
}
- return m.create(name, shell, gecos)
+ _, err = m.create(name, shell, gecos, true)
+ return err
+}
+
+// CreatePendingIdentity is CreatePending with the post-create passwd snapshot
+// returned to the caller, except that it deliberately leaves the Home absent.
+// The invite transaction must carry this exact identity forward and call
+// CreateManagedHomeExpected only after inherited work has been drained.
+func (m *Manager) CreatePendingIdentity(name, shell, generation string) (Passwd, error) {
+ gecos, err := pendingGECOSForGeneration(generation)
+ if err != nil {
+ return Passwd{}, err
+ }
+ return m.create(name, shell, gecos, false)
}
-func (m *Manager) create(name, shell, gecos string) error {
+var managedHomeRoot = "/home"
+
+func managedHome(name string) string { return filepath.Join(managedHomeRoot, name) }
+
+var (
+ syncCreatedHomeMetadata = func(home *os.File) error { return home.Sync() }
+ syncCreatedHomeParent = func(parent *os.File) error { return parent.Sync() }
+)
+
+// DefaultHome is the dedicated home path used for every newly created account.
+func DefaultHome(name string) (string, error) {
if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+ return "", fmt.Errorf("invalid username %q", name)
+ }
+ return managedHome(name), nil
+}
+
+func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Passwd, error) {
+ if err := validateMutationName(name); err != nil {
+ return Passwd{}, err
+ }
+ home := managedHome(name)
+ prepare := m.PrepareManagedHome
+ if prepare == nil {
+ prepare = prepareManagedHome
+ }
+ if err := prepare(name); err != nil {
+ return Passwd{}, fmt.Errorf("prepare managed home: %w", err)
}
var err error
switch {
case m.Runner.Look("useradd"):
- err = m.Runner.Run("useradd", "-m", "-s", shell, "-c", gecos, name)
- case m.Runner.Look("adduser"):
- err = m.Runner.Run("adduser", "-D", "-s", shell, "-g", gecos, name)
+ // Create only the account database entry here. The expired date and locked
+ // hash are part of the same useradd transaction, so the pending name never
+ // depends on a later chage/usermod call for its initial login gate. -M also
+ // prevents /etc/skel (including any locally provisioned SSH credential) from
+ // being copied before the selected UID has been proved idle.
+ err = m.Runner.Run("useradd", "-M", "-d", home, "-s", shell, "-c", gecos,
+ "-e", expiredDate, "-p", initialLockedPasswordHash, name)
default:
- return fmt.Errorf("no useradd/adduser available")
+ return Passwd{}, fmt.Errorf("useradd not available")
}
if err != nil {
- return err
+ return Passwd{}, err
}
- // useradd/adduser choose a numeric UID automatically. A process left behind
- // after an out-of-band deletion may still carry that number; giving it to the
- // new account would immediately give the process ownership of the new home and
- // any later sudo/key material. Check all four Linux credential UIDs before the
- // caller is allowed to use the account, and roll the just-created account back
- // whenever the check cannot prove the UID is idle.
- pw, ok, lookupErr := Lookup(name)
+ // useradd chooses a numeric UID automatically. A process left behind after an
+ // out-of-band deletion may still carry that number. Check all four Linux
+ // credential UIDs before creating a UID-owned Home or allowing the caller to use
+ // the account. If the check is inconclusive or finds a residual process, retain
+ // the expired, password-locked pending account without a Home: deleting it would
+ // free the reused UID while that process still carries it.
+ pw, ok, lookupErr := m.lookup(name)
if lookupErr != nil {
- return m.rollbackCreate(name, fmt.Errorf("look up newly created account: %w", lookupErr))
+ return Passwd{}, errors.Join(
+ fmt.Errorf("look up newly created account: %w", lookupErr),
+ fmt.Errorf("newly created account %s was retained without a verified identity", name),
+ )
+ }
+ if !ok {
+ return Passwd{}, fmt.Errorf("newly created account %s is absent from the local account database", name)
+ }
+ if !validate.AccountID(pw.UID) || !validate.AccountID(pw.GID) {
+ return Passwd{}, errors.Join(
+ fmt.Errorf("newly created account %s has no safe local UID/GID", name),
+ fmt.Errorf("account was retained for manual recovery because identity %d:%d is unsafe", pw.UID, pw.GID),
+ )
}
- if !ok || pw.UID < 1 {
- return m.rollbackCreate(name, fmt.Errorf("newly created account %s has no safe local UID", name))
+ if pw.Name != name || pw.Home != home || pw.Shell != shell || gecosFullName(pw.GECOS) != gecos {
+ return Passwd{}, fmt.Errorf("newly created account identity does not match the requested name, home, shell, and marker; account retained for manual recovery")
}
pids, scanErr := processesForUID(pw.UID)
if scanErr != nil {
- return m.rollbackCreate(name, fmt.Errorf("scan processes before using UID %d: %w", pw.UID, scanErr))
+ return Passwd{}, fmt.Errorf("scan processes before using UID %d: %w; account retained to keep the UID occupied", pw.UID, scanErr)
}
if len(pids) != 0 {
- return m.rollbackCreate(name, fmt.Errorf("refusing reused UID %d: residual processes %v already carry it", pw.UID, pids))
+ return Passwd{}, fmt.Errorf("refusing reused UID %d: residual processes %v already carry it; account retained to keep the UID occupied", pw.UID, pids)
+ }
+ // A previous account generation can leave a same-name mail spool behind even
+ // when its Home is gone. The newly selected UID may be the same, so clear that
+ // artifact while this identity is still expired, password-locked, and has no
+ // credential. A different owner or special file fails closed and retains the
+ // pending account to keep the name and UID occupied.
+ if err := m.ClearManagedMailExpected(name, pw); err != nil {
+ return Passwd{}, fmt.Errorf("clear mail spool before using account identity: %w; account retained for manual recovery", err)
+ }
+ if !shouldCreateHome {
+ return pw, nil
+ }
+ if err := m.CreateManagedHomeExpected(name, pw); err != nil {
+ return Passwd{}, fmt.Errorf("%w; account retained for manual recovery", err)
+ }
+ return pw, nil
+}
+
+// CreateManagedHomeExpected creates and validates an empty Home only while the
+// complete passwd identity captured at useradd still exists unchanged. Invite
+// calls this after draining inherited deferred work, so an old account generation
+// has no writable Home during that drain.
+func (m *Manager) CreateManagedHomeExpected(name string, expected Passwd) error {
+ if err := validateMutationName(name); err != nil {
+ return err
+ }
+ if expected.Name != name || !validate.AccountID(expected.UID) ||
+ !validate.AccountID(expected.GID) || expected.Home != managedHome(name) ||
+ expected.Shell == "" ||
+ (!hasManagedGenerationMarker(expected) && !hasPendingGenerationMarker(expected)) {
+ return fmt.Errorf("invalid expected account identity for managed Home creation")
+ }
+ if err := m.verifyExpectedIdentity(name, expected, "before managed Home creation"); err != nil {
+ return err
+ }
+ createHome := m.CreateManagedHome
+ if createHome == nil {
+ createHome = createManagedHome
+ }
+ createErr := createHome(expected)
+ identityErr := m.verifyExpectedIdentity(name, expected, "during managed Home creation")
+ if createErr != nil || identityErr != nil {
+ if createErr != nil {
+ createErr = fmt.Errorf("create empty managed account Home: %w", createErr)
+ }
+ return errors.Join(createErr, identityErr)
+ }
+ inspect := m.ValidateManagedHome
+ if inspect == nil {
+ inspect = validateCreatedHome
+ }
+ inspectErr := inspect(expected)
+ identityErr = m.verifyExpectedIdentity(name, expected, "during managed Home validation")
+ if inspectErr != nil || identityErr != nil {
+ if inspectErr != nil {
+ inspectErr = fmt.Errorf("validate newly created account Home: %w", inspectErr)
+ }
+ return errors.Join(inspectErr, identityErr)
+ }
+ return nil
+}
+
+func (m *Manager) verifyExpectedIdentity(name string, expected Passwd, phase string) error {
+ current, exists, err := m.lookup(name)
+ if err != nil {
+ return fmt.Errorf("verify account identity %s: %w", phase, err)
+ }
+ if !exists {
+ return fmt.Errorf("account %s disappeared %s", name, phase)
+ }
+ if current != expected {
+ return fmt.Errorf("account identity changed %s", phase)
}
return nil
}
@@ -430,8 +668,8 @@ func (m *Manager) create(name, shell, gecos string) error {
// its numeric UID has been durably recorded. usermod is a required dependency
// for both invite and fail-closed revoke, so there is no weaker fallback here.
func (m *Manager) MarkManaged(name, generation string) error {
- if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+ if err := validateMutationName(name); err != nil {
+ return err
}
gecos, err := ManagedGECOSForGeneration(generation)
if err != nil {
@@ -443,11 +681,41 @@ func (m *Manager) MarkManaged(name, generation string) error {
return m.Runner.Run("usermod", "-c", gecos, name)
}
-func (m *Manager) rollbackCreate(name string, cause error) error {
- if err := m.Delete(name); err != nil {
- return errors.Join(cause, fmt.Errorf("rollback newly created account %s: %w", name, err))
+// MarkManagedExpected changes only the pending identity captured at creation and
+// verifies the complete passwd entry afterward. The underlying usermod remains a
+// name-scoped system helper, so these checks detect and contain replacement; they
+// cannot make the helper itself an atomic compare-and-swap.
+func (m *Manager) MarkManagedExpected(name, generation string, expected Passwd) (Passwd, error) {
+ pending, err := pendingGECOSForGeneration(generation)
+ if err != nil {
+ return Passwd{}, err
+ }
+ if expected.Name != name || !validate.AccountID(expected.UID) || !validate.AccountID(expected.GID) || expected.Home != managedHome(name) || expected.Shell == "" || gecosFullName(expected.GECOS) != pending {
+ return Passwd{}, fmt.Errorf("invalid pending account identity for %q", name)
+ }
+ absent, err := m.deletionState(name, &expected)
+ if err != nil {
+ return Passwd{}, fmt.Errorf("verify pending account before marking managed: %w", err)
+ }
+ if absent {
+ return Passwd{}, fmt.Errorf("pending account %s disappeared before it could be marked managed", name)
+ }
+ if err := m.MarkManaged(name, generation); err != nil {
+ return Passwd{}, err
+ }
+ current, exists, err := m.lookup(name)
+ if err != nil {
+ return Passwd{}, fmt.Errorf("verify managed account marker: %w", err)
+ }
+ want := expected
+ want.GECOS, err = ManagedGECOSForGeneration(generation)
+ if err != nil {
+ return Passwd{}, err
+ }
+ if !exists || current != want {
+ return Passwd{}, fmt.Errorf("account identity changed while marking it managed")
}
- return cause
+ return current, nil
}
// keyOnlyPasswordHash is deliberately not a valid crypt(3) result. Traditional
@@ -462,8 +730,8 @@ const keyOnlyPasswordHash = "linux-temp-admin-key-only-password-disabled"
// builds that reject a shadow-locked account even when its authorized key is
// valid (notably Alpine's default configuration).
func (m *Manager) DisablePasswordForKeyLogin(name string) error {
- if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+ if err := validateMutationName(name); err != nil {
+ return err
}
return m.Runner.Run("usermod", "-p", keyOnlyPasswordHash, name)
}
@@ -471,8 +739,8 @@ func (m *Manager) DisablePasswordForKeyLogin(name string) error {
// LockPassword locks name during revocation. Revoke also expires the account,
// so rejecting every authentication method is intentional here.
func (m *Manager) LockPassword(name string) error {
- if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+ if err := validateMutationName(name); err != nil {
+ return err
}
return m.Runner.Run("usermod", "-L", name)
}
@@ -481,8 +749,8 @@ func (m *Manager) LockPassword(name string) error {
// host whose sshd will not take a key. The password goes to chpasswd on stdin,
// never in argv, so it cannot be read out of the process table.
func (m *Manager) SetPassword(name, password string) error {
- if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+ if err := validateMutationName(name); err != nil {
+ return err
}
if !m.Runner.Look("chpasswd") {
return fmt.Errorf("chpasswd not available")
@@ -497,17 +765,33 @@ func (m *Manager) SetPassword(name, password string) error {
// SetExpiry sets the account expiry date (YYYY-MM-DD) via chage.
func (m *Manager) SetExpiry(name, date string) error {
- if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+ if err := validateMutationName(name); err != nil {
+ return err
}
return m.Runner.Run("chage", "-E", date, name)
}
+// ClearExpiry restores a permanent account after the credential-less safety
+// drain temporarily expired it. chage documents -1 as "never expires"; using a
+// dedicated method keeps that sentinel out of ordinary date-setting call sites.
+func (m *Manager) ClearExpiry(name string) error {
+ if err := validateMutationName(name); err != nil {
+ return err
+ }
+ return m.Runner.Run("chage", "-E", "-1", name)
+}
+
// expiredDate is a date safely in the past; chage -E it to make an account
// expired as of now. A literal date is used rather than "0" because chage's
// numeric form is days-since-epoch and reads ambiguously next to -E -1 ("never").
const expiredDate = "1970-01-01"
+// initialLockedPasswordHash is passed to useradd as an encrypted hash. It is not
+// a valid crypt(3) result, and the leading '!' has the conventional shadow meaning
+// of a locked password. Account expiry remains the authentication-method-neutral
+// gate; this value independently closes password authentication at creation.
+const initialLockedPasswordHash = "!"
+
// DisableLogin shuts the account's door before revoke starts taking it apart:
// it expires the account (chage), which sshd and PAM both refuse regardless of
// how the invitee authenticates, and locks the password for good measure.
@@ -532,68 +816,694 @@ func (m *Manager) DisableLogin(name string) error {
return errors.Join(m.SetExpiry(name, expiredDate), m.LockPassword(name))
}
-// Delete removes the account and its home directory.
-//
-// userdel gets -f. Without it, shadow's userdel exits 8 ("user currently logged
-// in") whenever a session exists — so an invitee who simply reconnects in a loop
-// could make every revoke fail. The caller disables the login before reaching
-// here, which closes that race at the source; -f closes what is left of it, and
-// makes the delete succeed against a stale utmp entry too. Deleting an account
-// out from under a live session is exactly what a revoke is asking for.
-func (m *Manager) Delete(name string) error {
- if !validate.Username(name) {
- return fmt.Errorf("invalid username %q", name)
+// DeleteExpected removes name only while its complete passwd entry still
+// matches expected. The caller has already disabled login and reached an initial
+// cron/at/process fixed point. beforeDelete is mandatory and runs after controlled
+// Home/mail cleanup but immediately before the name-scoped account helper, so the
+// caller can repeat its scheduled-work and process checks at the last point where
+// the UID is still bound. System helpers are invoked without recursive Home/mail
+// options. In particular, userdel must not receive -f: on shadow-utils that flag
+// may delete the same-name group even while another account still uses it as a
+// primary group. Distro deluser and arbitrary BusyBox builds are not fallbacks:
+// their configuration and compiled account-database semantics cannot be proven
+// equivalent to shadow-utils userdel.
+func (m *Manager) DeleteExpected(name string, expected Passwd, beforeDelete func() error) error {
+ if err := validateMutationName(name); err != nil {
+ return err
+ }
+ if expected.Name != name || !validate.AccountID(expected.UID) || !validate.AccountID(expected.GID) || !isManagedHome(name, expected.Home) {
+ return fmt.Errorf("invalid expected account identity for %q", name)
+ }
+ if beforeDelete == nil {
+ return fmt.Errorf("final account quiescence check is not configured")
}
- var delErr error
- if m.Runner.Look("deluser") {
- if delErr = m.Runner.Run("deluser", "--remove-home", name); delErr == nil {
- absent, err := accountConfirmedAbsent(name)
+ return m.delete(name, &expected, beforeDelete)
+}
+
+func (m *Manager) delete(name string, expected *Passwd, beforeDelete func() error) error {
+ absent, err := m.deletionState(name, expected)
+ if err != nil {
+ return fmt.Errorf("verify account identity before deletion: %w", err)
+ }
+ if absent {
+ // Once passwd no longer binds the name and UID, the captured snapshot cannot
+ // authorize recursive Home removal: Linux may already have reused the UID and
+ // a replacement could have populated the deterministic path. Mail cleanup is
+ // narrower and independently owner-checked, so it remains recoverable. Sweep
+ // twice around an absence recheck to catch an in-flight delivery without ever
+ // touching Home, jobs, processes, or an account helper.
+ if err := m.removeManagedMail(*expected); err != nil {
+ return err
+ }
+ if err := m.removeManagedMail(*expected); err != nil {
+ return err
+ }
+ absent, err = m.deletionState(name, expected)
+ if err != nil {
+ return fmt.Errorf("verify account remained absent after artifact cleanup: %w", err)
+ }
+ if !absent {
+ return fmt.Errorf("account %s reappeared during artifact cleanup", name)
+ }
+ return nil
+ }
+ type helper struct {
+ name string
+ args []string
+ }
+ var helpers []helper
+ if m.Runner.Look("userdel") {
+ helpers = append(helpers, helper{name: "userdel", args: []string{"--", name}})
+ }
+ if len(helpers) == 0 {
+ return fmt.Errorf("userdel not available")
+ }
+ // Keep the account and registry witness if artifact cleanup cannot be proved
+ // safe. Once a helper removes the passwd entry, a later retry no longer has the
+ // complete snapshot needed to validate an orphaned mail spool or home.
+ if err := m.removeManagedMail(*expected); err != nil {
+ return err
+ }
+ if err := m.removeManagedHome(*expected); err != nil {
+ return err
+ }
+ if err := beforeDelete(); err != nil {
+ return fmt.Errorf("final account quiescence check before userdel: %w", err)
+ }
+ var attemptErrs []error
+ for _, helper := range helpers {
+ absent, err := m.deletionState(name, expected)
+ if err != nil {
+ return errors.Join(errors.Join(attemptErrs...), fmt.Errorf("verify account before %s: %w", helper.name, err))
+ }
+ if absent {
+ if err := m.removeManagedMail(*expected); err != nil {
+ return fmt.Errorf("final managed mail cleanup after account disappearance: %w", err)
+ }
+ absent, err = m.deletionState(name, expected)
if err != nil {
- return fmt.Errorf("verify deluser removed %s: %w", name, err)
+ return fmt.Errorf("verify account remained absent after account disappearance: %w", err)
+ }
+ if !absent {
+ return fmt.Errorf("account %s reappeared during final managed mail cleanup", name)
}
- if absent {
- return nil
+ return nil
+ }
+ runErr := m.Runner.Run(helper.name, helper.args...)
+ absent, stateErr := m.deletionState(name, expected)
+ if stateErr != nil {
+ return errors.Join(errors.Join(attemptErrs...), runErr, fmt.Errorf("verify %s removed %s: %w", helper.name, name, stateErr))
+ }
+ if absent {
+ // Mail can be recreated while the account still exists and controlled Home
+ // cleanup is in progress. Once the helper has made the account absent, sweep
+ // one final time before reporting success or releasing the registry witness.
+ mailErr := m.removeManagedMail(*expected)
+ stillAbsent, finalStateErr := m.deletionState(name, expected)
+ if finalStateErr == nil && !stillAbsent {
+ finalStateErr = fmt.Errorf("account %s reappeared during final managed mail cleanup", name)
+ }
+ if runErr != nil {
+ return errors.Join(errors.Join(attemptErrs...),
+ fmt.Errorf("%s removed the account but reported incomplete cleanup: %w", helper.name, runErr),
+ mailErr,
+ finalStateErr)
+ }
+ if finalErr := errors.Join(mailErr, finalStateErr); finalErr != nil {
+ return errors.Join(errors.Join(attemptErrs...), fmt.Errorf("final cleanup after %s: %w", helper.name, finalErr))
}
- delErr = fmt.Errorf("deluser reported success but account %s still exists", name)
+ return nil
+ }
+ if runErr != nil {
+ attemptErrs = append(attemptErrs, fmt.Errorf("%s: %w", helper.name, runErr))
+ } else {
+ attemptErrs = append(attemptErrs, fmt.Errorf("%s reported success but account %s still exists", helper.name, name))
}
}
- if m.Runner.Look("userdel") {
- if err := m.Runner.Run("userdel", "-r", "-f", "--", name); err != nil {
- return errors.Join(delErr, fmt.Errorf("userdel: %w", err))
+ return errors.Join(append(attemptErrs, fmt.Errorf("account %s still exists after every available deletion helper", name))...)
+}
+
+func (m *Manager) lookup(name string) (Passwd, bool, error) {
+ lookup := m.LookupUser
+ if lookup == nil {
+ lookup = Lookup
+ }
+ return lookup(name)
+}
+
+func (m *Manager) removeManagedHome(expected Passwd) error {
+ remove := m.RemoveManagedHome
+ if remove == nil {
+ remove = removeManagedHome
+ }
+ return remove(expected)
+}
+
+func (m *Manager) removeManagedMail(expected Passwd) error {
+ remove := m.RemoveManagedMail
+ if remove == nil {
+ remove = removeManagedMail
+ }
+ return remove(expected)
+}
+
+// ClearManagedMailExpected removes a same-name spool only while the complete
+// live passwd entry still matches expected. It is used during account creation,
+// before credentials are installed, so a reused UID cannot inherit old mail.
+func (m *Manager) ClearManagedMailExpected(name string, expected Passwd) error {
+ if err := validateMutationName(name); err != nil {
+ return err
+ }
+ if expected.Name != name || !validate.AccountID(expected.UID) ||
+ !validate.AccountID(expected.GID) || !isManagedHome(name, expected.Home) || expected.Shell == "" {
+ return fmt.Errorf("invalid expected account identity for mail cleanup")
+ }
+ absent, err := m.deletionState(name, &expected)
+ if err != nil {
+ return fmt.Errorf("verify account before managed mail cleanup: %w", err)
+ }
+ if absent {
+ return fmt.Errorf("account %s disappeared before managed mail cleanup", name)
+ }
+ if err := m.removeManagedMail(expected); err != nil {
+ return err
+ }
+ absent, err = m.deletionState(name, &expected)
+ if err != nil {
+ return fmt.Errorf("verify account after managed mail cleanup: %w", err)
+ }
+ if absent {
+ return fmt.Errorf("account %s disappeared during managed mail cleanup", name)
+ }
+ return nil
+}
+
+// ReconcileManagedMailAfterDeletion retries the final mail-only sweep after a
+// deletion whose intent was durably recorded while the account still existed.
+// The caller owns that authorization decision. This method independently requires
+// the name to remain absent before and after cleanup; it never touches Home.
+func (m *Manager) ReconcileManagedMailAfterDeletion(name string, uid int) error {
+ if err := validateMutationName(name); err != nil {
+ return err
+ }
+ if !validate.AccountID(uid) {
+ return fmt.Errorf("invalid expected account UID for post-deletion mail cleanup")
+ }
+ if _, exists, err := m.lookup(name); err != nil {
+ return fmt.Errorf("verify account absence before managed mail cleanup: %w", err)
+ } else if exists {
+ return fmt.Errorf("account %s exists; refusing post-deletion mail cleanup", name)
+ }
+ if err := m.removeManagedMail(Passwd{Name: name, UID: uid}); err != nil {
+ return err
+ }
+ if _, exists, err := m.lookup(name); err != nil {
+ return fmt.Errorf("verify account absence after managed mail cleanup: %w", err)
+ } else if exists {
+ return fmt.Errorf("account %s reappeared during post-deletion mail cleanup", name)
+ }
+ return nil
+}
+
+func validateHomeRemoval(expected Passwd) error {
+ if !isManagedHome(expected.Name, expected.Home) {
+ return fmt.Errorf("account home %q is not a dedicated managed path", expected.Home)
+ }
+ if err := fsutil.RootSafeDir(managedHomeRoot); err != nil {
+ return fmt.Errorf("managed home parent is unsafe: %w", err)
+ }
+ fi, err := os.Lstat(expected.Home)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("inspect account home %s: %w", expected.Home, err)
+ }
+ if err == nil {
+ if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
+ return fmt.Errorf("account home %s is not a real directory", expected.Home)
}
- absent, err := accountConfirmedAbsent(name)
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok || int64(st.Uid) != int64(expected.UID) || int64(st.Gid) != int64(expected.GID) {
+ return fmt.Errorf("account home %s owner does not match uid/gid %d:%d", expected.Home, expected.UID, expected.GID)
+ }
+ }
+ if err := refuseMountsUnder(expected.Home); err != nil {
+ return err
+ }
+ return nil
+}
+
+func isManagedHome(name, home string) bool {
+ return validate.Username(name) && home == managedHome(name)
+}
+
+func prepareManagedHome(name string) error {
+ if !validate.Username(name) {
+ return fmt.Errorf("invalid username %q", name)
+ }
+ if err := fsutil.RootSafeDir(managedHomeRoot); err != nil {
+ return fmt.Errorf("managed home parent is unsafe: %w", err)
+ }
+ home := managedHome(name)
+ if _, err := os.Lstat(home); err == nil {
+ return fmt.Errorf("managed home %s already exists", home)
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("inspect managed home %s: %w", home, err)
+ }
+ return nil
+}
+
+// createManagedHome creates an empty deterministic Home only after the account's
+// selected UID has been proved idle. It never copies /etc/skel: a host-local
+// skeleton can contain authorized_keys or other authentication material that is
+// inappropriate for a one-time account. All mutation is relative to a pinned,
+// root-owned parent directory and metadata is applied through the opened fd.
+func createManagedHome(expected Passwd) error {
+ if !validate.Username(expected.Name) || !validate.AccountID(expected.UID) ||
+ !validate.AccountID(expected.GID) || expected.Home != managedHome(expected.Name) {
+ return fmt.Errorf("invalid managed account identity for Home creation")
+ }
+ parentFD, err := unix.Open(managedHomeRoot, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open managed home parent %s: %w", managedHomeRoot, err)
+ }
+ parent := os.NewFile(uintptr(parentFD), managedHomeRoot)
+ defer parent.Close()
+
+ var parentStat unix.Stat_t
+ if err := unix.Fstat(parentFD, &parentStat); err != nil {
+ return fmt.Errorf("stat managed home parent: %w", err)
+ }
+ if parentStat.Mode&unix.S_IFMT != unix.S_IFDIR || parentStat.Uid != 0 || parentStat.Gid != 0 || parentStat.Mode&0o022 != 0 {
+ return fmt.Errorf("managed home parent is not a root-owned non-writable directory")
+ }
+ var namedParent unix.Stat_t
+ if err := unix.Lstat(managedHomeRoot, &namedParent); err != nil {
+ return fmt.Errorf("recheck managed home parent: %w", err)
+ }
+ if namedParent.Dev != parentStat.Dev || namedParent.Ino != parentStat.Ino || namedParent.Mode&unix.S_IFMT != unix.S_IFDIR {
+ return fmt.Errorf("managed home parent was replaced during account creation")
+ }
+
+ if err := unix.Mkdirat(parentFD, expected.Name, 0o700); err != nil {
+ if errors.Is(err, unix.EEXIST) {
+ return fmt.Errorf("managed home %s already exists", expected.Home)
+ }
+ return fmt.Errorf("create managed home %s: %w", expected.Home, err)
+ }
+ homeFD, err := unix.Openat(parentFD, expected.Name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open newly created managed home: %w", err)
+ }
+ home := os.NewFile(uintptr(homeFD), expected.Home)
+ defer home.Close()
+
+ var initial unix.Stat_t
+ if err := unix.Fstat(homeFD, &initial); err != nil {
+ return fmt.Errorf("stat newly created managed home: %w", err)
+ }
+ if initial.Mode&unix.S_IFMT != unix.S_IFDIR || initial.Uid != 0 || initial.Gid != 0 {
+ return fmt.Errorf("new managed home did not begin as a root-owned directory")
+ }
+ if err := home.Chown(expected.UID, expected.GID); err != nil {
+ return fmt.Errorf("set managed home owner: %w", err)
+ }
+ if err := home.Chmod(0o700); err != nil {
+ return fmt.Errorf("set managed home mode: %w", err)
+ }
+ if err := syncCreatedHomeMetadata(home); err != nil {
+ return &fsutil.DurabilityError{Operation: "managed home metadata update", Err: err}
+ }
+ if err := syncCreatedHomeParent(parent); err != nil {
+ return &fsutil.DurabilityError{Operation: "managed home creation", Err: err}
+ }
+
+ var final, namedHome, finalParent unix.Stat_t
+ if err := unix.Fstat(homeFD, &final); err != nil {
+ return fmt.Errorf("verify managed home metadata: %w", err)
+ }
+ if final.Mode&unix.S_IFMT != unix.S_IFDIR || int64(final.Uid) != int64(expected.UID) ||
+ int64(final.Gid) != int64(expected.GID) || final.Mode&0o7777 != 0o700 {
+ return fmt.Errorf("managed home metadata remains unsafe after creation")
+ }
+ if err := unix.Fstatat(parentFD, expected.Name, &namedHome, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+ return fmt.Errorf("recheck managed home entry: %w", err)
+ }
+ if namedHome.Dev != final.Dev || namedHome.Ino != final.Ino || namedHome.Mode&unix.S_IFMT != unix.S_IFDIR {
+ return fmt.Errorf("managed home was replaced during account creation")
+ }
+ if err := unix.Lstat(managedHomeRoot, &finalParent); err != nil {
+ return fmt.Errorf("final recheck of managed home parent: %w", err)
+ }
+ if finalParent.Dev != parentStat.Dev || finalParent.Ino != parentStat.Ino || finalParent.Mode&unix.S_IFMT != unix.S_IFDIR {
+ return fmt.Errorf("managed home parent was replaced during account creation")
+ }
+ return nil
+}
+
+func validateCreatedHome(expected Passwd) error {
+ if !validate.AccountID(expected.UID) || !validate.AccountID(expected.GID) {
+ return fmt.Errorf("invalid account owner %d:%d", expected.UID, expected.GID)
+ }
+ if err := validateHomeRemoval(expected); err != nil {
+ return err
+ }
+ if _, err := os.Lstat(expected.Home); os.IsNotExist(err) {
+ return fmt.Errorf("account home %s was not created", expected.Home)
+ } else if err != nil {
+ return fmt.Errorf("inspect account home %s: %w", expected.Home, err)
+ }
+ return nil
+}
+
+func removeManagedHome(expected Passwd) error {
+ if err := validateHomeRemoval(expected); err != nil {
+ return fmt.Errorf("refusing managed home cleanup: %w", err)
+ }
+ if err := removeHomeTree(expected); err != nil {
+ return fmt.Errorf("remove managed home %s: %w", expected.Home, err)
+ }
+ return nil
+}
+
+var managedMailRoots = []string{"/var/mail", "/var/spool/mail"}
+
+var syncRemovalDirectory = func(dir *os.File) error { return dir.Sync() }
+
+// unlinkManagedMailAt is indirected so a unit test can force the stat/unlink
+// disappearance race without relying on scheduler timing.
+var unlinkManagedMailAt = unix.Unlinkat
+
+func syncRemovalParent(dir *os.File, operation string) error {
+ if err := syncRemovalDirectory(dir); err != nil {
+ return &fsutil.DurabilityError{Operation: operation, Err: err}
+ }
+ return nil
+}
+
+// removeManagedMail removes only a conventional single-file system mailbox
+// still owned by the captured account UID. Account helpers are intentionally
+// invoked without recursive-home flags, so this preserves the mail-spool part of
+// userdel -r without delegating Home traversal to a name-scoped helper.
+func removeManagedMail(expected Passwd) error {
+ if !validate.Username(expected.Name) || !validate.AccountID(expected.UID) {
+ return fmt.Errorf("invalid expected account identity for mail cleanup")
+ }
+ allowed := make(map[string]bool, len(managedMailRoots))
+ for _, root := range managedMailRoots {
+ clean := filepath.Clean(root)
+ if root == "" || !filepath.IsAbs(root) || clean != root || clean == string(filepath.Separator) {
+ return fmt.Errorf("unsafe managed mail root %q", root)
+ }
+ allowed[clean] = true
+ }
+ seen := make(map[string]bool, len(managedMailRoots))
+ for _, root := range managedMailRoots {
+ if _, err := os.Lstat(root); os.IsNotExist(err) {
+ continue
+ } else if err != nil {
+ return fmt.Errorf("inspect managed mail root %s: %w", root, err)
+ }
+ resolved, err := filepath.EvalSymlinks(root)
if err != nil {
- return fmt.Errorf("verify userdel removed %s: %w", name, err)
+ return fmt.Errorf("resolve managed mail root %s: %w", root, err)
}
- if !absent {
- return errors.Join(delErr, fmt.Errorf("userdel reported success but account %s still exists", name))
+ resolved = filepath.Clean(resolved)
+ if !allowed[resolved] {
+ return fmt.Errorf("managed mail root %s resolves outside the accepted spool directories", root)
+ }
+ if seen[resolved] {
+ continue
+ }
+ seen[resolved] = true
+ if err := removeManagedMailAt(resolved, expected); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func removeManagedMailAt(root string, expected Passwd) error {
+ dir, err := os.OpenFile(root, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open managed mail root %s: %w", root, err)
+ }
+ defer dir.Close()
+ fi, err := dir.Stat()
+ if err != nil {
+ return fmt.Errorf("stat managed mail root %s: %w", root, err)
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok || !fi.IsDir() || st.Uid != 0 || fi.Mode().Perm()&0o002 != 0 {
+ return fmt.Errorf("managed mail root %s is not a root-owned, non-world-writable directory", root)
+ }
+
+ var spool unix.Stat_t
+ err = unix.Fstatat(int(dir.Fd()), expected.Name, &spool, unix.AT_SYMLINK_NOFOLLOW)
+ if errors.Is(err, unix.ENOENT) {
+ // This can be a retry after unlink succeeded but the previous directory sync
+ // failed. Re-sync the observed absence before allowing the UID to be released.
+ return syncRemovalParent(dir, "managed mail spool absence confirmation")
+ }
+ if err != nil {
+ return fmt.Errorf("inspect managed mail spool %s/%s: %w", root, expected.Name, err)
+ }
+ if spool.Mode&unix.S_IFMT != unix.S_IFREG {
+ return fmt.Errorf("managed mail spool %s/%s is not a regular file", root, expected.Name)
+ }
+ if int64(spool.Uid) != int64(expected.UID) {
+ return fmt.Errorf("managed mail spool %s/%s owner does not match uid %d", root, expected.Name, expected.UID)
+ }
+ if err := unlinkManagedMailAt(int(dir.Fd()), expected.Name, 0); err != nil {
+ if errors.Is(err, unix.ENOENT) {
+ return syncRemovalParent(dir, "managed mail spool absence confirmation")
+ }
+ return fmt.Errorf("remove managed mail spool %s/%s: %w", root, expected.Name, err)
+ }
+ if err := unix.Fstatat(int(dir.Fd()), expected.Name, &spool, unix.AT_SYMLINK_NOFOLLOW); !errors.Is(err, unix.ENOENT) {
+ if err == nil {
+ return fmt.Errorf("managed mail spool %s/%s reappeared during cleanup", root, expected.Name)
+ }
+ return fmt.Errorf("verify managed mail spool removal %s/%s: %w", root, expected.Name, err)
+ }
+ return syncRemovalParent(dir, "managed mail spool removal")
+}
+
+const (
+ maxManagedHomeEntries = 100_000
+ maxManagedHomeDepth = 128
+ managedHomeRemovalTimeout = 2 * time.Minute
+ managedHomeReadBatch = 128
+)
+
+type homeRemovalBudget struct {
+ remaining int
+ maxDepth int
+ deadline time.Time
+ now func() time.Time
+ device uint64
+}
+
+func (b *homeRemovalBudget) check(path string, depth int) error {
+ now := b.now
+ if now == nil {
+ now = time.Now
+ }
+ if !now().Before(b.deadline) {
+ return fmt.Errorf("managed home cleanup exceeded its time limit at %s", path)
+ }
+ if depth > b.maxDepth {
+ return fmt.Errorf("managed home cleanup exceeded its depth limit at %s", path)
+ }
+ return nil
+}
+
+func (b *homeRemovalBudget) consume(path string, depth int) error {
+ if err := b.check(path, depth); err != nil {
+ return err
+ }
+ if b.remaining <= 0 {
+ return fmt.Errorf("managed home cleanup exceeded its entry limit at %s", path)
+ }
+ b.remaining--
+ return nil
+}
+
+// removeHomeTreeBounded removes a managed Home through directory-relative file
+// descriptors. It never follows a symlink and checks fixed entry/depth limits and
+// a cooperative deadline between filesystem calls. The deadline cannot interrupt
+// one blocked call. A limit failure may leave a partially cleaned tree; callers
+// retain the disabled account and registry witness, so a later retry can continue
+// without freeing the UID or username.
+func removeHomeTreeBounded(expected Passwd) error {
+ budget := &homeRemovalBudget{
+ remaining: maxManagedHomeEntries,
+ maxDepth: maxManagedHomeDepth,
+ deadline: time.Now().Add(managedHomeRemovalTimeout),
+ }
+ return removeHomeTreeWithin(expected, budget)
+}
+
+func removeHomeTreeWithin(expected Passwd, budget *homeRemovalBudget) error {
+ if budget == nil || budget.remaining <= 0 || budget.maxDepth < 0 || budget.deadline.IsZero() {
+ return fmt.Errorf("invalid managed home cleanup budget")
+ }
+ if !isManagedHome(expected.Name, expected.Home) || !validate.AccountID(expected.UID) || !validate.AccountID(expected.GID) {
+ return fmt.Errorf("invalid expected account identity for managed home cleanup")
+ }
+ parentPath := filepath.Dir(expected.Home)
+ if parentPath != managedHomeRoot || filepath.Base(expected.Home) != expected.Name {
+ return fmt.Errorf("managed home %q is not directly beneath %q", expected.Home, managedHomeRoot)
+ }
+ parentFD, err := unix.Open(parentPath, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open managed home parent %s: %w", parentPath, err)
+ }
+ parent := os.NewFile(uintptr(parentFD), parentPath)
+ if parent == nil {
+ _ = unix.Close(parentFD)
+ return fmt.Errorf("adopt managed home parent descriptor")
+ }
+ defer parent.Close()
+
+ var parentStat unix.Stat_t
+ if err := unix.Fstat(parentFD, &parentStat); err != nil {
+ return fmt.Errorf("stat managed home parent %s: %w", parentPath, err)
+ }
+ if parentStat.Mode&unix.S_IFMT != unix.S_IFDIR || parentStat.Uid != 0 || parentStat.Gid != 0 || parentStat.Mode&0o022 != 0 {
+ return fmt.Errorf("managed home parent %s is not a root-owned, non-writable directory", parentPath)
+ }
+ if err := refuseMountsUnder(expected.Home); err != nil {
+ return err
+ }
+
+ var rootStat unix.Stat_t
+ err = unix.Fstatat(parentFD, expected.Name, &rootStat, unix.AT_SYMLINK_NOFOLLOW)
+ if errors.Is(err, unix.ENOENT) {
+ // A previous attempt may have removed the root but failed to sync /home.
+ // Confirm the already-visible absence durably before account deletion.
+ return syncRemovalParent(parent, "managed home absence confirmation")
+ }
+ if err != nil {
+ return fmt.Errorf("inspect managed home %s: %w", expected.Home, err)
+ }
+ if rootStat.Mode&unix.S_IFMT != unix.S_IFDIR {
+ return fmt.Errorf("managed home %s is not a real directory", expected.Home)
+ }
+ if int64(rootStat.Uid) != int64(expected.UID) || int64(rootStat.Gid) != int64(expected.GID) {
+ return fmt.Errorf("managed home %s owner does not match uid/gid %d:%d", expected.Home, expected.UID, expected.GID)
+ }
+ budget.device = uint64(rootStat.Dev)
+ if err := removeHomeEntryAt(parentFD, expected.Name, expected.Home, 0, rootStat, budget); err != nil {
+ return err
+ }
+ return syncRemovalParent(parent, "managed home removal")
+}
+
+func removeHomeEntryAt(parentFD int, name, displayPath string, depth int, inspected unix.Stat_t, budget *homeRemovalBudget) error {
+ if err := budget.consume(displayPath, depth); err != nil {
+ return err
+ }
+ if uint64(inspected.Dev) != budget.device {
+ return fmt.Errorf("refusing managed home cleanup across a filesystem boundary at %s", displayPath)
+ }
+ if inspected.Mode&unix.S_IFMT != unix.S_IFDIR {
+ if err := unix.Unlinkat(parentFD, name, 0); err != nil && !errors.Is(err, unix.ENOENT) {
+ return fmt.Errorf("remove managed home entry %s: %w", displayPath, err)
}
return nil
}
- // deluser ran and failed but there is no userdel to fall back to: return the
- // REAL deluser error, not a generic "no tool available". On BusyBox (deluser,
- // no userdel) the true cause — a live session, say — was being hidden behind a
- // false "the tool is missing" that sent the operator debugging the wrong thing.
- if delErr != nil {
- return fmt.Errorf("deluser: %w", delErr)
+
+ dirFD, err := unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open managed home directory %s: %w", displayPath, err)
+ }
+ dir := os.NewFile(uintptr(dirFD), displayPath)
+ if dir == nil {
+ _ = unix.Close(dirFD)
+ return fmt.Errorf("adopt managed home directory descriptor for %s", displayPath)
+ }
+
+ var opened unix.Stat_t
+ if err := unix.Fstat(dirFD, &opened); err != nil {
+ _ = dir.Close()
+ return fmt.Errorf("stat opened managed home directory %s: %w", displayPath, err)
+ }
+ if opened.Dev != inspected.Dev || opened.Ino != inspected.Ino {
+ _ = dir.Close()
+ return fmt.Errorf("managed home directory changed while opening %s", displayPath)
}
- return fmt.Errorf("no userdel/deluser available")
+
+ for {
+ if err := budget.check(displayPath, depth); err != nil {
+ _ = dir.Close()
+ return err
+ }
+ entries, readErr := dir.ReadDir(managedHomeReadBatch)
+ for _, entry := range entries {
+ childName := entry.Name()
+ if childName == "" || childName == "." || childName == ".." || filepath.Base(childName) != childName {
+ _ = dir.Close()
+ return fmt.Errorf("unsafe managed home entry name %q under %s", childName, displayPath)
+ }
+ var childStat unix.Stat_t
+ if err := unix.Fstatat(dirFD, childName, &childStat, unix.AT_SYMLINK_NOFOLLOW); errors.Is(err, unix.ENOENT) {
+ continue
+ } else if err != nil {
+ _ = dir.Close()
+ return fmt.Errorf("inspect managed home entry %s: %w", filepath.Join(displayPath, childName), err)
+ }
+ if err := removeHomeEntryAt(dirFD, childName, filepath.Join(displayPath, childName), depth+1, childStat, budget); err != nil {
+ _ = dir.Close()
+ return err
+ }
+ }
+ if readErr != nil && !errors.Is(readErr, io.EOF) {
+ _ = dir.Close()
+ return fmt.Errorf("read managed home directory %s: %w", displayPath, readErr)
+ }
+ if errors.Is(readErr, io.EOF) {
+ break
+ }
+ if len(entries) == 0 {
+ _ = dir.Close()
+ return fmt.Errorf("read managed home directory %s made no progress", displayPath)
+ }
+ }
+ if err := dir.Close(); err != nil {
+ return fmt.Errorf("close managed home directory %s: %w", displayPath, err)
+ }
+ if err := unix.Unlinkat(parentFD, name, unix.AT_REMOVEDIR); err != nil && !errors.Is(err, unix.ENOENT) {
+ return fmt.Errorf("remove managed home directory %s: %w", displayPath, err)
+ }
+ return nil
}
-func accountConfirmedAbsent(name string) (bool, error) {
- exists, err := Exists(name)
+var (
+ refuseMountsUnder = mountinfo.RefuseUnder
+ removeHomeTree = removeHomeTreeBounded
+)
+
+func (m *Manager) deletionState(name string, expected *Passwd) (absent bool, err error) {
+ current, exists, err := m.lookup(name)
if err != nil {
return false, err
}
- return !exists, nil
+ if !exists {
+ return true, nil
+ }
+ if expected != nil && current != *expected {
+ return false, fmt.Errorf("account identity changed during deletion; refusing a name-scoped fallback")
+ }
+ return false, nil
}
var (
- procRoot = "/proc"
- pidfdOpen = unix.PidfdOpen
- pidfdSendSignal = unix.PidfdSendSignal
- closeFD = unix.Close
- terminateSleep = time.Sleep
+ procRoot = "/proc"
+ readProcDirectory = os.ReadDir
+ pidfdOpen = unix.PidfdOpen
+ pidfdSendSignal = unix.PidfdSendSignal
+ closeFD = unix.Close
+ terminateSleep = time.Sleep
+ processScanSleep = time.Sleep
)
// terminateSweeps bounds the SIGKILL retry loop. A handful of passes clears any
@@ -601,6 +1511,20 @@ var (
// uninterruptible-sleep task) from spinning here forever while holding up revoke.
const terminateSweeps = 5
+const (
+ // processScanAttempts bounds retries when a numeric /proc entry disappears
+ // between the directory snapshot and its credential read. Such a task may have
+ // forked a child that was not present in the old snapshot, so an unstable empty
+ // scan cannot prove that a UID is free.
+ processScanAttempts = 10
+
+ // A PID can exit, fork a child, and be reused before its status is read. The
+ // replacement then makes one old directory snapshot look stable even though the
+ // child was not listed in it. A second stable empty scan catches that descendant.
+ processEmptyConfirmations = 2
+ processScanRetryDelay = time.Millisecond
+)
+
// CheckPidfd verifies that this kernel and sandbox allow pidfd operations. The
// revoke path relies on pidfds so a PID reused between inspection and signalling
// can never redirect a root-issued signal at an unrelated process.
@@ -625,12 +1549,12 @@ func CheckPidfd() error {
// process owned by uid. It no-ops for a non-positive uid (never root/all). Done
// natively via /proc (no pkill dependency).
//
-// The SIGKILL pass repeats until a scan finds nothing left (or the bound is hit),
+// The SIGKILL pass repeats until stable scans find nothing left (or the bound is hit),
// because one snapshot-then-signal pass loses to a process that is actively
// forking: a child created after the scan is never in the list, and would survive
// the revoke as an orphan owned by a uid that is about to be recycled. Re-scanning
-// after each kill closes that window — each pass strictly shrinks the survivors,
-// since a killed parent cannot fork again.
+// after each kill reaches newly visible descendants; a lineage that keeps escaping
+// the bounded sweeps makes revoke fail closed instead of releasing the UID.
func TerminateProcesses(uid int) error {
if uid < 1 {
return nil
@@ -646,84 +1570,89 @@ func TerminateProcesses(uid int) error {
if len(pids) != 0 {
terminateSleep(2 * time.Second)
}
+ var survivors []int
for i := 0; i < terminateSweeps; i++ {
- pids, err = signalUID(unix.SIGKILL, uid)
+ _, err = signalUID(unix.SIGKILL, uid)
if err != nil {
errs = append(errs, fmt.Errorf("signal UID %d processes with SIGKILL: %w", uid, err))
}
- if len(pids) == 0 {
+ // signalUID reports only tasks reached from its directory snapshot. A task
+ // can fork and exit between that snapshot and pidfdOpen, leaving no signalled
+ // PID while its child was never in the snapshot. Only consecutive stable
+ // per-thread credential scans can confirm that the UID is now empty.
+ survivors, err = processesForUID(uid)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("scan for UID %d after SIGKILL: %w", uid, err))
+ } else if len(survivors) == 0 {
return errors.Join(errs...)
}
- terminateSleep(100 * time.Millisecond)
+ if i+1 < terminateSweeps {
+ terminateSleep(100 * time.Millisecond)
+ }
}
- survivors, err := processesForUID(uid)
- if err != nil {
- errs = append(errs, fmt.Errorf("final scan for UID %d: %w", uid, err))
- } else if len(survivors) != 0 {
+ if len(survivors) != 0 {
errs = append(errs, fmt.Errorf("UID %d still has surviving processes %v after SIGKILL", uid, survivors))
}
return errors.Join(errs...)
}
-// signalUID first filters by credentials, then opens a pidfd and rechecks those
-// credentials before signalling through the descriptor. The first filter avoids
-// requiring pidfd access to every unrelated host process; the second check plus
-// the pidfd means PID reuse can never redirect a signal at an unrelated process.
+// signalUID first filters every live thread by credentials, then opens a pidfd for
+// its thread-group leader and rechecks the group before signalling through the
+// descriptor. Linux credentials are per-thread, and a leader can be a zombie while
+// another thread still runs. The first filter avoids requiring pidfd access to
+// every unrelated host process; the second check plus the pidfd prevents PID reuse
+// from redirecting a signal at an unrelated process.
func signalUID(sig unix.Signal, uid int) ([]int, error) {
- entries, err := os.ReadDir(procRoot)
+ entries, err := readProcDirectory(procRoot)
if err != nil {
return nil, fmt.Errorf("scan %s: %w", procRoot, err)
}
var signalled []int
var errs []error
for _, e := range entries {
- pid, err := strconv.Atoi(e.Name())
+ tgid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
- status, uidErr := readProcStatus(pid)
- if uidErr != nil {
- if !errors.Is(uidErr, os.ErrNotExist) && !errors.Is(uidErr, unix.ESRCH) {
- errs = append(errs, fmt.Errorf("read credentials for pid %d: %w", pid, uidErr))
- }
+ matched, _, inspectErr := processGroupHasUID(tgid, uid)
+ if inspectErr != nil {
+ errs = append(errs, fmt.Errorf("read thread credentials for process %d: %w", tgid, inspectErr))
continue
}
- if status.inactive || !containsUID(status.uids, uid) {
+ if !matched {
continue
}
- fd, err := pidfdOpen(pid, 0)
+ fd, err := pidfdOpen(tgid, 0)
if err == unix.ESRCH || err == unix.ENOENT {
continue
}
if err != nil {
- errs = append(errs, fmt.Errorf("open pidfd for pid %d: %w", pid, err))
+ errs = append(errs, fmt.Errorf("open pidfd for process %d: %w", tgid, err))
continue
}
- status, uidErr = readProcStatus(pid)
- if uidErr != nil {
- _ = closeFD(fd)
- if !errors.Is(uidErr, os.ErrNotExist) && !errors.Is(uidErr, unix.ESRCH) {
- errs = append(errs, fmt.Errorf("read credentials for pid %d: %w", pid, uidErr))
+ matched, _, inspectErr = processGroupHasUID(tgid, uid)
+ if inspectErr != nil {
+ errs = append(errs, fmt.Errorf("recheck thread credentials for process %d: %w", tgid, inspectErr))
+ if closeErr := closeFD(fd); closeErr != nil {
+ errs = append(errs, fmt.Errorf("close pidfd for process %d: %w", tgid, closeErr))
}
continue
}
- // Zombies and already-dead tasks cannot execute, fork, or retain a usable
- // credential. They are reaped only by their parent (or init), so repeatedly
- // SIGKILLing them would make every revoke fail forever without improving
- // isolation.
- if status.inactive || !containsUID(status.uids, uid) {
- _ = closeFD(fd)
+ if !matched {
+ if closeErr := closeFD(fd); closeErr != nil {
+ errs = append(errs, fmt.Errorf("close pidfd for process %d: %w", tgid, closeErr))
+ }
continue
}
signalErr := pidfdSendSignal(fd, sig, nil, 0)
closeErr := closeFD(fd)
if signalErr == nil {
- signalled = append(signalled, pid)
+ signalled = append(signalled, tgid)
} else if signalErr != unix.ESRCH {
- errs = append(errs, fmt.Errorf("signal pid %d: %w", pid, signalErr))
+ errs = append(errs, fmt.Errorf("signal process %d: %w", tgid, signalErr))
}
if closeErr != nil {
- errs = append(errs, fmt.Errorf("close pidfd for pid %d: %w", pid, closeErr))
+ errs = append(errs, fmt.Errorf("close pidfd for process %d: %w", tgid, closeErr))
}
}
sort.Ints(signalled)
@@ -731,29 +1660,98 @@ func signalUID(sig unix.Signal, uid int) ([]int, error) {
}
func processesForUID(uid int) ([]int, error) {
- entries, err := os.ReadDir(procRoot)
+ stableEmpty := 0
+ for attempt := 0; attempt < processScanAttempts; attempt++ {
+ pids, stable, err := processSnapshotForUID(uid)
+ if err != nil {
+ return nil, err
+ }
+ // Finding even one live task is conclusive. Empty results need consecutive
+ // stable scans because PID reuse can hide an old snapshotted parent without
+ // producing ENOENT while its new child was absent from that old snapshot.
+ if len(pids) != 0 {
+ return pids, nil
+ }
+ if !stable {
+ stableEmpty = 0
+ continue
+ }
+ stableEmpty++
+ if stableEmpty == processEmptyConfirmations {
+ return nil, nil
+ }
+ processScanSleep(processScanRetryDelay)
+ }
+ return nil, fmt.Errorf("scan %s: no consecutive stable empty process snapshots after %d attempts", procRoot, processScanAttempts)
+}
+
+func processSnapshotForUID(uid int) ([]int, bool, error) {
+ entries, err := readProcDirectory(procRoot)
if err != nil {
- return nil, fmt.Errorf("scan %s: %w", procRoot, err)
+ return nil, false, fmt.Errorf("scan %s: %w", procRoot, err)
}
var pids []int
+ stable := true
for _, entry := range entries {
- pid, err := strconv.Atoi(entry.Name())
+ tgid, err := strconv.Atoi(entry.Name())
+ if err != nil {
+ continue
+ }
+ matched, groupStable, err := processGroupHasUID(tgid, uid)
if err != nil {
+ return nil, false, fmt.Errorf("read thread credentials for process %d: %w", tgid, err)
+ }
+ if !groupStable {
+ stable = false
+ }
+ if matched {
+ pids = append(pids, tgid)
+ }
+ }
+ sort.Ints(pids)
+ return pids, stable, nil
+}
+
+// processGroupHasUID inspects every thread because Linux credentials are
+// per-thread and the thread-group leader may already be a zombie while workers
+// remain executable. It reports an unstable snapshot when a listed group or task
+// disappears before its status can be read; callers may act on a positive match,
+// but must never use an unstable negative result as proof that the UID is absent.
+func processGroupHasUID(tgid, uid int) (matched, stable bool, err error) {
+ taskRoot := filepath.Join(procRoot, strconv.Itoa(tgid), "task")
+ entries, err := readProcDirectory(taskRoot)
+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) {
+ return false, false, nil
+ }
+ if err != nil {
+ return false, false, fmt.Errorf("scan %s: %w", taskRoot, err)
+ }
+ stable = true
+ numericTasks := 0
+ for _, entry := range entries {
+ tid, parseErr := strconv.Atoi(entry.Name())
+ if parseErr != nil {
continue
}
- status, err := readProcStatus(pid)
- if errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) {
+ numericTasks++
+ status, readErr := readProcTaskStatus(tgid, tid)
+ if errors.Is(readErr, os.ErrNotExist) || errors.Is(readErr, unix.ESRCH) {
+ stable = false
continue
}
- if err != nil {
- return nil, fmt.Errorf("read credentials for pid %d: %w", pid, err)
+ if readErr != nil {
+ return false, false, fmt.Errorf("read credentials for task %d/%d: %w", tgid, tid, readErr)
}
+ // Zombie/dead threads cannot execute or fork. A zombie leader does not make
+ // the group inactive: another task entry may still describe a live worker.
if !status.inactive && containsUID(status.uids, uid) {
- pids = append(pids, pid)
+ return true, stable, nil
}
}
- sort.Ints(pids)
- return pids, nil
+ if numericTasks == 0 {
+ stable = false
+ }
+ return false, stable, nil
}
func containsUID(uids [4]int, uid int) bool {
@@ -770,12 +1768,12 @@ type processStatus struct {
inactive bool
}
-// readProcStatus returns Linux's real, effective, saved-set, and filesystem
-// UIDs and whether the task is already a zombie/dead process awaiting reaping.
-func readProcStatus(pid int) (processStatus, error) {
+// readProcTaskStatus returns Linux's real, effective, saved-set, and filesystem
+// UIDs and whether one task is already a zombie/dead thread awaiting reaping.
+func readProcTaskStatus(tgid, tid int) (processStatus, error) {
// Whole-file read: a scanner that errored before the Uid: line would drop this
- // pid from the SIGKILL sweep silently. /proc//status is tiny.
- data, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "status"))
+ // task from the SIGKILL sweep silently. /proc//task//status is tiny.
+ data, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(tgid), "task", strconv.Itoa(tid), "status"))
if err != nil {
return processStatus{}, err
}
diff --git a/internal/user/user_root_test.go b/internal/user/user_root_test.go
index 2b3cc51..dde646c 100644
--- a/internal/user/user_root_test.go
+++ b/internal/user/user_root_test.go
@@ -49,7 +49,7 @@ func TestUserLifecycle(t *testing.T) {
if err := m.SetExpiry(name, "2999-01-01"); err != nil {
t.Errorf("SetExpiry: %v", err)
}
- if err := m.Delete(name); err != nil {
+ if err := m.DeleteExpected(name, pw, func() error { return nil }); err != nil {
t.Fatalf("Delete: %v", err)
}
if exists, err := Exists(name); err != nil || exists {
diff --git a/internal/user/user_test.go b/internal/user/user_test.go
index 4cc3012..5fdcc07 100644
--- a/internal/user/user_test.go
+++ b/internal/user/user_test.go
@@ -15,6 +15,7 @@ import (
"github.com/xxvcc/linux-temp-admin/internal/config"
"github.com/xxvcc/linux-temp-admin/internal/executil"
+ "github.com/xxvcc/linux-temp-admin/internal/fsutil"
"golang.org/x/sys/unix"
)
@@ -41,6 +42,8 @@ func writeUserCommand(t *testing.T, dir, name, body string) string {
const testGeneration = "0123456789abcdef0123456789abcdef"
+var noOpBeforeDelete = func() error { return nil }
+
const samplePasswd = `root:x:0:0:root:/root:/bin/bash
svc:x:200:200::/var/lib/svc:/usr/sbin/nologin
human:x:1000:1000:A Human:/home/human:/bin/bash
@@ -77,15 +80,17 @@ func TestGenerationBoundManagedMarkers(t *testing.T) {
name string
gecos string
managed bool
+ lifecycle bool
legacy bool
matchesGen bool
}{
- {name: "legacy", gecos: config.ManagedGECOS + ",,,", managed: true, legacy: true},
- {name: "bound", gecos: config.ManagedGenerationGECOSPrefix + testGeneration + ",,,", managed: true, matchesGen: true},
- {name: "other generation", gecos: config.ManagedGenerationGECOSPrefix + otherGeneration, managed: true},
+ {name: "legacy", gecos: config.ManagedGECOS + ",,,", managed: true, lifecycle: true, legacy: true},
+ {name: "bound", gecos: config.ManagedGenerationGECOSPrefix + testGeneration + ",,,", managed: true, lifecycle: true, matchesGen: true},
+ {name: "other generation", gecos: config.ManagedGenerationGECOSPrefix + otherGeneration, managed: true, lifecycle: true},
{name: "malformed generation", gecos: config.ManagedGenerationGECOSPrefix + "short"},
{name: "substring", gecos: "prefix " + config.ManagedGECOS},
- {name: "pending", gecos: config.PendingGenerationGECOSPrefix + testGeneration},
+ {name: "pending", gecos: config.PendingGenerationGECOSPrefix + testGeneration, lifecycle: true},
+ {name: "malformed pending", gecos: config.PendingGenerationGECOSPrefix + "short"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -93,6 +98,9 @@ func TestGenerationBoundManagedMarkers(t *testing.T) {
if got := IsManagedEntry(pw); got != tc.managed {
t.Errorf("IsManagedEntry = %v, want %v", got, tc.managed)
}
+ if got := HasLifecycleMarker(pw); got != tc.lifecycle {
+ t.Errorf("HasLifecycleMarker = %v, want %v", got, tc.lifecycle)
+ }
if got := IsLegacyManagedEntry(pw); got != tc.legacy {
t.Errorf("IsLegacyManagedEntry = %v, want %v", got, tc.legacy)
}
@@ -106,6 +114,33 @@ func TestGenerationBoundManagedMarkers(t *testing.T) {
}
}
+func TestLifecycleMarkerAccountsFindsOnlyExactMarkers(t *testing.T) {
+ setPasswd(t, strings.Join([]string{
+ "human:x:1000:1000:A Human:/home/human:/bin/bash",
+ "managed:x:1001:1001:" + config.ManagedGenerationGECOSPrefix + testGeneration + ",,,:/home/managed:/bin/sh",
+ "legacy:x:1002:1002:" + config.ManagedGECOS + ":/home/legacy:/bin/sh",
+ "pending:x:1003:1003:" + config.PendingGenerationGECOSPrefix + testGeneration + ":/home/pending:/bin/sh",
+ "substring:x:1004:1004:prefix " + config.ManagedGECOS + ":/home/substring:/bin/sh",
+ "malformed:x:1005:1005:" + config.ManagedGenerationGECOSPrefix + "short:/home/malformed:/bin/sh",
+ }, "\n")+"\n")
+
+ got, err := LifecycleMarkerAccounts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"legacy", "managed", "pending"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("LifecycleMarkerAccounts = %v, want %v", got, want)
+ }
+}
+
+func TestLifecycleMarkerAccountsFailsClosedOnMalformedPasswd(t *testing.T) {
+ setPasswd(t, "broken:x:not-a-uid\nmanaged:x:1001:1001:"+config.ManagedGenerationGECOSPrefix+testGeneration+":/home/managed:/bin/sh\n")
+ if _, err := LifecycleMarkerAccounts(); err == nil || !strings.Contains(err.Error(), "passwd line 1") {
+ t.Fatalf("LifecycleMarkerAccounts error = %v, want malformed passwd refusal", err)
+ }
+}
+
func TestLookupRejectsReservedKernelIDs(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot represent the reserved uint32 uid/gid sentinel")
@@ -123,6 +158,30 @@ func TestLookupRejectsReservedKernelIDs(t *testing.T) {
}
}
+func TestLookupRejectsMalformedOrDuplicateTargetRows(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ content string
+ want string
+ }{
+ {name: "truncated", content: "alice:x:1000\n", want: "malformed passwd entry"},
+ {name: "extra field", content: "alice:x:1000:1000::/home/alice:/bin/sh:extra\n", want: "malformed passwd entry"},
+ {name: "duplicate", content: "alice:x:1000:1000::/home/alice:/bin/sh\nalice:x:1001:1001::/home/alice:/bin/bash\n", want: "duplicate passwd entries"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ setPasswd(t, tc.content)
+ if _, ok, err := Lookup("alice"); err == nil || ok || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("Lookup malformed target = ok %v, err %v; want %q", ok, err, tc.want)
+ }
+ })
+ }
+
+ setPasswd(t, "broken:x:uid\nalice:x:1000:1000::/home/alice:/bin/sh\n")
+ if pw, ok, err := Lookup("alice"); err != nil || !ok || pw.UID != 1000 {
+ t.Fatalf("unrelated malformed row affected Lookup: pw=%+v ok=%v err=%v", pw, ok, err)
+ }
+}
+
func TestReadPasswdDatabaseIsBoundedAndRejectsFIFO(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "passwd")
@@ -311,6 +370,8 @@ func TestIsProtectedRevokeTarget(t *testing.T) {
{"human", false, 0, "", false, true}, // real uid, unregistered human
{"human", true, 0, testGeneration, false, true}, // real uid, registered but NOT managed -> protected
{"tmp1000", false, 0, "", false, false}, // managed real uid -> explicit unregistered recovery may delete
+ {"legacy", false, 0, "", false, true}, // a fixed marker alone is not unattended deletion authority
+ {"legacy", false, 0, "", true, false}, // live explicit recovery may accept a marker-only legacy account
{"legacy", true, 1004, "", false, true}, // fixed legacy marker is not identity proof
{"legacy", true, 1004, "", true, false}, // direct force recovery may accept it
@@ -365,10 +426,14 @@ type fakeRunner struct {
failOn map[string]bool
calls [][]string
stdin []string // what each RunInput call was fed
+ onRun func(name string)
}
func (f *fakeRunner) Run(name string, args ...string) error {
f.calls = append(f.calls, append([]string{name}, args...))
+ if f.onRun != nil {
+ f.onRun(name)
+ }
if f.failOn[name] {
return errForced
}
@@ -381,6 +446,25 @@ func (f *fakeRunner) RunInput(stdin string, name string, args ...string) error {
func (f *fakeRunner) Look(name string) bool { return f.available[name] }
+func managerWithStubbedHomeChecks(r Runner) *Manager {
+ return &Manager{
+ Runner: r,
+ PrepareManagedHome: func(string) error { return nil },
+ CreateManagedHome: func(Passwd) error { return nil },
+ ValidateManagedHome: func(Passwd) error { return nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ }
+}
+
+func managerWithStubbedHomeRemoval(r Runner) *Manager {
+ return &Manager{
+ Runner: r,
+ RemoveManagedMail: func(Passwd) error { return nil },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ }
+}
+
func TestAccountMutationsRejectInvalidUsernameBeforeRunningHelpers(t *testing.T) {
tests := []struct {
name string
@@ -393,12 +477,13 @@ func TestAccountMutationsRejectInvalidUsernameBeforeRunningHelpers(t *testing.T)
{name: "lock password", run: func(m *Manager) error { return m.LockPassword("bad:user") }},
{name: "set password", run: func(m *Manager) error { return m.SetPassword("bad:user", "secret") }},
{name: "set expiry", run: func(m *Manager) error { return m.SetExpiry("bad:user", "2026-07-09") }},
- {name: "delete", run: func(m *Manager) error { return m.Delete("bad:user") }},
+ {name: "clear expiry", run: func(m *Manager) error { return m.ClearExpiry("bad:user") }},
+ {name: "delete", run: func(m *Manager) error { return m.DeleteExpected("bad:user", Passwd{}, noOpBeforeDelete) }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
f := &fakeRunner{available: map[string]bool{
- "useradd": true, "adduser": true, "usermod": true,
+ "useradd": true, "busybox": true, "usermod": true,
"chpasswd": true, "chage": true, "deluser": true, "userdel": true,
}}
if err := tc.run(&Manager{Runner: f}); err == nil || !strings.Contains(err.Error(), "invalid username") {
@@ -411,6 +496,45 @@ func TestAccountMutationsRejectInvalidUsernameBeforeRunningHelpers(t *testing.T)
}
}
+func TestAccountMutationsRejectReservedUsernameBeforeRunningHelpers(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(*Manager) error
+ }{
+ {name: "create", run: func(m *Manager) error { return m.Create("nobody", "/bin/sh", testGeneration) }},
+ {name: "create pending", run: func(m *Manager) error { return m.CreatePending("systemd-test", "/bin/sh", testGeneration) }},
+ {name: "mark managed", run: func(m *Manager) error { return m.MarkManaged("nobody", testGeneration) }},
+ {name: "disable key password", run: func(m *Manager) error { return m.DisablePasswordForKeyLogin("nobody") }},
+ {name: "lock password", run: func(m *Manager) error { return m.LockPassword("nobody") }},
+ {name: "set password", run: func(m *Manager) error { return m.SetPassword("nobody", "secret") }},
+ {name: "set expiry", run: func(m *Manager) error { return m.SetExpiry("nobody", "2026-07-09") }},
+ {name: "clear expiry", run: func(m *Manager) error { return m.ClearExpiry("nobody") }},
+ {name: "disable login", run: func(m *Manager) error { return m.DisableLogin("nobody") }},
+ {name: "delete", run: func(m *Manager) error {
+ return m.DeleteExpected("nobody", Passwd{Name: "nobody", UID: 65534, GID: 65534, Home: "/home/nobody", Shell: "/bin/sh"}, noOpBeforeDelete)
+ }},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ f := &fakeRunner{available: map[string]bool{
+ "useradd": true, "usermod": true, "chpasswd": true,
+ "chage": true, "userdel": true,
+ }}
+ m := managerWithStubbedHomeChecks(f)
+ m.LookupUser = func(string) (Passwd, bool, error) {
+ t.Fatal("reserved username reached an account lookup")
+ return Passwd{}, false, nil
+ }
+ if err := tc.run(m); err == nil || !strings.Contains(err.Error(), "reserved username") {
+ t.Fatalf("mutation error = %v, want reserved-username refusal", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("reserved username reached helper commands: %v", f.calls)
+ }
+ })
+ }
+}
+
var errForced = &forcedErr{}
type forcedErr struct{}
@@ -462,11 +586,12 @@ func TestCreateArgvUseradd(t *testing.T) {
setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/bash\n")
setProcRoot(t, map[int]string{})
f := &fakeRunner{available: map[string]bool{"useradd": true, "adduser": true}}
- m := &Manager{Runner: f}
+ m := managerWithStubbedHomeChecks(f)
if err := m.Create("xxvcc-a1", "/bin/bash", testGeneration); err != nil {
t.Fatal(err)
}
- want := []string{"useradd", "-m", "-s", "/bin/bash", "-c", marker, "xxvcc-a1"}
+ want := []string{"useradd", "-M", "-d", "/home/xxvcc-a1", "-s", "/bin/bash", "-c", marker,
+ "-e", expiredDate, "-p", initialLockedPasswordHash, "xxvcc-a1"}
if len(f.calls) != 1 || !reflect.DeepEqual(f.calls[0], want) {
t.Errorf("useradd argv = %v, want %v", f.calls, want)
}
@@ -478,15 +603,28 @@ func TestCreatePendingAndMarkManagedArgv(t *testing.T) {
setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n")
setProcRoot(t, map[int]string{})
f := &fakeRunner{available: map[string]bool{"useradd": true, "usermod": true}}
- m := &Manager{Runner: f}
- if err := m.CreatePending("xxvcc-a1", "/bin/bash", testGeneration); err != nil {
+ f.onRun = func(name string) {
+ if name == "usermod" {
+ if err := os.WriteFile(passwdPath, []byte("xxvcc-a1:x:2345:2345:"+managedMarker+":/home/xxvcc-a1:/bin/bash\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ m := managerWithStubbedHomeChecks(f)
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/bash", testGeneration)
+ if err != nil {
t.Fatal(err)
}
- if err := m.MarkManaged("xxvcc-a1", testGeneration); err != nil {
+ managed, err := m.MarkManagedExpected("xxvcc-a1", testGeneration, pending)
+ if err != nil {
t.Fatal(err)
}
+ if managed.GECOS != managedMarker || managed.UID != pending.UID || managed.Home != pending.Home {
+ t.Fatalf("managed identity = %+v, pending = %+v", managed, pending)
+ }
want := [][]string{
- {"useradd", "-m", "-s", "/bin/bash", "-c", pendingMarker, "xxvcc-a1"},
+ {"useradd", "-M", "-d", "/home/xxvcc-a1", "-s", "/bin/bash", "-c", pendingMarker,
+ "-e", expiredDate, "-p", initialLockedPasswordHash, "xxvcc-a1"},
{"usermod", "-c", managedMarker, "xxvcc-a1"},
}
if !reflect.DeepEqual(f.calls, want) {
@@ -494,207 +632,1101 @@ func TestCreatePendingAndMarkManagedArgv(t *testing.T) {
}
}
-func TestMarkManagedRequiresUsermod(t *testing.T) {
- if err := (&Manager{Runner: &fakeRunner{}}).MarkManaged("xxvcc-a1", testGeneration); err == nil {
- t.Fatal("MarkManaged accepted a host without usermod")
+func TestCreatePendingDefersHomeUntilExpectedIdentityCall(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n")
+ setProcRoot(t, map[int]string{})
+ f := &fakeRunner{available: map[string]bool{"useradd": true}}
+ var order []string
+ m := &Manager{
+ Runner: f,
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(got Passwd) error {
+ order = append(order, "mail")
+ if got.Name != "xxvcc-a1" || got.UID != 2345 {
+ t.Fatalf("mail cleanup identity = %+v", got)
+ }
+ return nil
+ },
+ CreateManagedHome: func(Passwd) error {
+ order = append(order, "home")
+ return nil
+ },
+ ValidateManagedHome: func(Passwd) error {
+ order = append(order, "validate")
+ return nil
+ },
+ }
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/bash", testGeneration)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(order, []string{"mail"}) {
+ t.Fatalf("pending account artifact order = %v, want mail cleanup with no Home creation", order)
+ }
+ if err := m.CreateManagedHomeExpected("xxvcc-a1", pending); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(order, []string{"mail", "home", "validate"}) {
+ t.Fatalf("completed account artifact order = %v, want deferred Home creation and validation", order)
}
}
-func TestCreateArgvAdduserBusybox(t *testing.T) {
+func TestCreateStillClearsMatchingMailBeforeCreatingHome(t *testing.T) {
marker := config.ManagedGenerationGECOSPrefix + testGeneration
- setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n")
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/bash\n")
setProcRoot(t, map[int]string{})
- f := &fakeRunner{available: map[string]bool{"adduser": true}} // no useradd
- m := &Manager{Runner: f}
- if err := m.Create("xxvcc-a1", "/bin/sh", testGeneration); err != nil {
- t.Fatal(err)
+ var order []string
+ m := &Manager{
+ Runner: &fakeRunner{available: map[string]bool{"useradd": true}},
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(Passwd) error {
+ order = append(order, "mail")
+ return nil
+ },
+ CreateManagedHome: func(Passwd) error {
+ order = append(order, "home")
+ return nil
+ },
+ ValidateManagedHome: func(Passwd) error {
+ order = append(order, "validate")
+ return nil
+ },
}
- want := []string{"adduser", "-D", "-s", "/bin/sh", "-g", marker, "xxvcc-a1"}
- if !reflect.DeepEqual(f.calls[0], want) {
- t.Errorf("adduser argv = %v, want %v", f.calls[0], want)
+ if err := m.Create("xxvcc-a1", "/bin/bash", testGeneration); err != nil {
+ t.Fatal(err)
}
-}
-
-func setProcRoot(t *testing.T, statuses map[int]string) {
- t.Helper()
- dir := t.TempDir()
- for pid, status := range statuses {
- pidDir := filepath.Join(dir, fmt.Sprint(pid))
- if err := os.Mkdir(pidDir, 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(pidDir, "status"), []byte(status), 0o600); err != nil {
- t.Fatal(err)
- }
+ if !reflect.DeepEqual(order, []string{"mail", "home", "validate"}) {
+ t.Fatalf("account artifact order = %v, want mail cleanup before Home creation", order)
}
- old := procRoot
- procRoot = dir
- t.Cleanup(func() { procRoot = old })
}
-func TestCreateRejectsUIDWithResidualProcess(t *testing.T) {
- marker := config.ManagedGenerationGECOSPrefix + testGeneration
- setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n")
- // The target UID appears only in the saved-set UID column. Checking only real
- // and effective UIDs would miss this process, which can switch back to 2345.
- setProcRoot(t, map[int]string{77: "Name:\tleftover\nUid:\t1000\t1000\t2345\t1000\n"})
- f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}}
- err := (&Manager{Runner: f}).Create("xxvcc-a1", "/bin/sh", testGeneration)
- if err == nil || !strings.Contains(err.Error(), "UID 2345") || !strings.Contains(err.Error(), "77") {
- t.Fatalf("Create error = %v, want residual-UID process refusal", err)
+func TestCreatePendingCompatibilityStillCreatesHome(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n")
+ setProcRoot(t, map[int]string{})
+ var order []string
+ m := &Manager{
+ Runner: &fakeRunner{available: map[string]bool{"useradd": true}},
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(Passwd) error {
+ order = append(order, "mail")
+ return nil
+ },
+ CreateManagedHome: func(Passwd) error {
+ order = append(order, "home")
+ return nil
+ },
+ ValidateManagedHome: func(Passwd) error {
+ order = append(order, "validate")
+ return nil
+ },
}
- want := [][]string{
- {"useradd", "-m", "-s", "/bin/sh", "-c", marker, "xxvcc-a1"},
- {"userdel", "-r", "-f", "--", "xxvcc-a1"},
+ if err := m.CreatePending("xxvcc-a1", "/bin/bash", testGeneration); err != nil {
+ t.Fatal(err)
}
- if !reflect.DeepEqual(f.calls, want) {
- t.Fatalf("Create calls = %v, want create followed by rollback %v", f.calls, want)
+ if !reflect.DeepEqual(order, []string{"mail", "home", "validate"}) {
+ t.Fatalf("compatibility CreatePending artifact order = %v, want complete Home creation", order)
}
}
-func TestCreateFailsClosedWhenProcCannotBeScanned(t *testing.T) {
- setPasswd(t, "xxvcc-a1:x:2345:2345:"+config.ManagedGenerationGECOSPrefix+testGeneration+":/home/xxvcc-a1:/bin/sh\n")
- old := procRoot
- procRoot = filepath.Join(t.TempDir(), "missing")
- t.Cleanup(func() { procRoot = old })
- f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}}
- if err := (&Manager{Runner: f}).Create("xxvcc-a1", "/bin/sh", testGeneration); err == nil || !strings.Contains(err.Error(), "scan") {
- t.Fatalf("Create error = %v, want proc scan failure", err)
+func TestCreateManagedHomeExpectedRefusesReplacementBeforeCreation(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/sh\n")
+ setProcRoot(t, map[int]string{})
+ homeCalls := 0
+ m := &Manager{
+ Runner: &fakeRunner{available: map[string]bool{"useradd": true}},
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ CreateManagedHome: func(Passwd) error {
+ homeCalls++
+ return nil
+ },
+ ValidateManagedHome: func(Passwd) error {
+ t.Fatal("replacement identity reached Home validation")
+ return nil
+ },
+ }
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/sh", testGeneration)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(passwdPath, []byte("xxvcc-a1:x:3456:3456:replacement:/home/xxvcc-a1:/bin/sh\n"), 0o644); err != nil {
+ t.Fatal(err)
}
- if len(f.calls) != 2 || f.calls[1][0] != "userdel" {
- t.Fatalf("failed safety check did not roll back account: calls=%v", f.calls)
+ err = m.CreateManagedHomeExpected("xxvcc-a1", pending)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("replacement Home creation error = %v, want identity-change refusal", err)
+ }
+ if homeCalls != 0 {
+ t.Fatalf("replacement identity reached Home creation %d time(s)", homeCalls)
}
}
-func TestProcessesForUIDChecksAllFourUIDColumns(t *testing.T) {
- setProcRoot(t, map[int]string{
- 11: "Uid:\t1111\t2000\t2000\t2000\n",
- 12: "Uid:\t2000\t1111\t2000\t2000\n",
- 13: "Uid:\t2000\t2000\t1111\t2000\n",
- 14: "Uid:\t2000\t2000\t2000\t1111\n",
- })
- pids, err := processesForUID(1111)
+func TestCreateManagedHomeExpectedRefusesIdentityChangeAfterCreation(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/sh\n")
+ setProcRoot(t, map[int]string{})
+ var order []string
+ m := &Manager{
+ Runner: &fakeRunner{available: map[string]bool{"useradd": true}},
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ CreateManagedHome: func(Passwd) error {
+ order = append(order, "home")
+ return os.WriteFile(passwdPath, []byte("xxvcc-a1:x:3456:3456:replacement:/home/xxvcc-a1:/bin/sh\n"), 0o644)
+ },
+ ValidateManagedHome: func(Passwd) error {
+ t.Fatal("replacement identity reached Home validation")
+ return nil
+ },
+ }
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/sh", testGeneration)
if err != nil {
t.Fatal(err)
}
- if want := []int{11, 12, 13, 14}; !reflect.DeepEqual(pids, want) {
- t.Fatalf("processesForUID = %v, want %v", pids, want)
+ err = m.CreateManagedHomeExpected("xxvcc-a1", pending)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("post-create replacement error = %v, want identity-change refusal", err)
+ }
+ if !reflect.DeepEqual(order, []string{"home"}) {
+ t.Fatalf("Home hook order = %v, want identity check immediately after creation", order)
}
}
-func TestProcessesForUIDIgnoresZombieAndDeadTasks(t *testing.T) {
- setProcRoot(t, map[int]string{
- 11: "State:\tZ (zombie)\nUid:\t1111\t1111\t1111\t1111\n",
- 12: "State:\tX (dead)\nUid:\t1111\t1111\t1111\t1111\n",
- 13: "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n",
- })
- pids, err := processesForUID(1111)
+func TestCreateManagedHomeExpectedRefusesIdentityChangeDuringValidation(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/sh\n")
+ setProcRoot(t, map[int]string{})
+ var order []string
+ m := &Manager{
+ Runner: &fakeRunner{available: map[string]bool{"useradd": true}},
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ CreateManagedHome: func(Passwd) error {
+ order = append(order, "home")
+ return nil
+ },
+ ValidateManagedHome: func(Passwd) error {
+ order = append(order, "validate")
+ return os.WriteFile(passwdPath, []byte("xxvcc-a1:x:3456:3456:replacement:/home/xxvcc-a1:/bin/sh\n"), 0o644)
+ },
+ }
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/sh", testGeneration)
if err != nil {
t.Fatal(err)
}
- if want := []int{13}; !reflect.DeepEqual(pids, want) {
- t.Fatalf("processesForUID = %v, want only live tasks %v", pids, want)
+ err = m.CreateManagedHomeExpected("xxvcc-a1", pending)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("validation replacement error = %v, want identity-change refusal", err)
+ }
+ if !reflect.DeepEqual(order, []string{"home", "validate"}) {
+ t.Fatalf("Home hook order = %v, want creation then validation", order)
}
}
-func withFakePidfds(t *testing.T, send func(int, unix.Signal, *unix.Siginfo, int) error) {
- t.Helper()
- oldOpen, oldSend, oldClose, oldSleep := pidfdOpen, pidfdSendSignal, closeFD, terminateSleep
- pidfdOpen = func(pid, flags int) (int, error) { return pid + 10000, nil }
- pidfdSendSignal = send
- closeFD = func(int) error { return nil }
- terminateSleep = func(time.Duration) {}
- t.Cleanup(func() {
- pidfdOpen, pidfdSendSignal, closeFD, terminateSleep = oldOpen, oldSend, oldClose, oldSleep
- })
+func TestCreateManagedHomeExpectedChecksIdentityAfterHookErrors(t *testing.T) {
+ for _, stage := range []string{"create", "validate"} {
+ t.Run(stage, func(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/sh\n")
+ setProcRoot(t, map[int]string{})
+ wantErr := errors.New(stage + " failed")
+ m := &Manager{
+ Runner: &fakeRunner{available: map[string]bool{"useradd": true}},
+ PrepareManagedHome: func(string) error { return nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ CreateManagedHome: func(Passwd) error {
+ if stage == "create" {
+ return wantErr
+ }
+ return nil
+ },
+ ValidateManagedHome: func(Passwd) error {
+ if stage == "validate" {
+ return wantErr
+ }
+ t.Fatal("validation ran after failed Home creation")
+ return nil
+ },
+ }
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/sh", testGeneration)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lookups := 0
+ m.LookupUser = func(name string) (Passwd, bool, error) {
+ lookups++
+ return Lookup(name)
+ }
+ err = m.CreateManagedHomeExpected("xxvcc-a1", pending)
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("Home %s error = %v, want injected error", stage, err)
+ }
+ wantLookups := 2
+ if stage == "validate" {
+ wantLookups = 3
+ }
+ if lookups != wantLookups {
+ t.Fatalf("identity lookups after %s error = %d, want %d", stage, lookups, wantLookups)
+ }
+ })
+ }
}
-func TestCheckPidfdReportsKernelOrSandboxFailure(t *testing.T) {
- oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD
- pidfdOpen = func(pid, flags int) (int, error) {
- if pid != os.Getpid() || flags != 0 {
- t.Fatalf("PidfdOpen(%d, %d), want self", pid, flags)
- }
- return -1, syscall.ENOSYS
- }
- pidfdSendSignal = func(int, unix.Signal, *unix.Siginfo, int) error {
- t.Fatal("signal called after failed pidfd open")
- return nil
- }
- closeFD = func(int) error { t.Fatal("close called after failed pidfd probe"); return nil }
- t.Cleanup(func() { pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose })
- if err := CheckPidfd(); err == nil || !errors.Is(err, syscall.ENOSYS) {
- t.Fatalf("CheckPidfd error=%v, want ENOSYS", err)
+func TestCreateManagedHomeExpectedRejectsLegacyMarker(t *testing.T) {
+ expected := Passwd{
+ Name: "xxvcc-a1", UID: 2345, GID: 2345, GECOS: config.ManagedGECOS,
+ Home: "/home/xxvcc-a1", Shell: "/bin/sh",
+ }
+ m := &Manager{
+ LookupUser: func(string) (Passwd, bool, error) {
+ t.Fatal("legacy marker reached identity lookup")
+ return Passwd{}, false, nil
+ },
+ CreateManagedHome: func(Passwd) error {
+ t.Fatal("legacy marker reached Home creation")
+ return nil
+ },
+ }
+ err := m.CreateManagedHomeExpected(expected.Name, expected)
+ if err == nil || !strings.Contains(err.Error(), "invalid expected account identity") {
+ t.Fatalf("legacy marker error = %v, want input refusal", err)
}
}
-func TestCheckPidfdReportsSignalFailureAndClosesDescriptor(t *testing.T) {
- oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD
- pidfdOpen = func(int, int) (int, error) { return 42, nil }
- pidfdSendSignal = func(fd int, sig unix.Signal, info *unix.Siginfo, flags int) error {
- if fd != 42 || sig != 0 || info != nil || flags != 0 {
- t.Fatalf("PidfdSendSignal(%d, %d, %v, %d), want harmless self probe", fd, sig, info, flags)
+func TestReconcileManagedMailAfterDeletionRequiresContinuousAbsence(t *testing.T) {
+ const name = "xxvcc-mail-recovery"
+ replacement := Passwd{Name: name, UID: 2002, GID: 2002, Home: "/home/" + name, Shell: "/bin/sh"}
+
+ t.Run("absent mail-only cleanup", func(t *testing.T) {
+ mailCalls := 0
+ m := &Manager{
+ LookupUser: func(string) (Passwd, bool, error) { return Passwd{}, false, nil },
+ RemoveManagedMail: func(got Passwd) error {
+ mailCalls++
+ if got.Name != name || got.UID != 1001 || got.GID != 0 || got.Home != "" {
+ t.Fatalf("post-deletion mail identity = %+v", got)
+ }
+ return nil
+ },
+ RemoveManagedHome: func(Passwd) error {
+ t.Fatal("post-deletion reconciliation touched Home")
+ return nil
+ },
}
- return syscall.EPERM
- }
- closed := false
- closeFD = func(fd int) error {
- if fd != 42 {
- t.Fatalf("close(%d), want 42", fd)
+ if err := m.ReconcileManagedMailAfterDeletion(name, 1001); err != nil {
+ t.Fatal(err)
}
- closed = true
- return nil
+ if mailCalls != 1 {
+ t.Fatalf("mail cleanup calls = %d, want 1", mailCalls)
+ }
+ })
+
+ t.Run("replacement appears", func(t *testing.T) {
+ lookups := 0
+ m := &Manager{
+ LookupUser: func(string) (Passwd, bool, error) {
+ lookups++
+ if lookups == 1 {
+ return Passwd{}, false, nil
+ }
+ return replacement, true, nil
+ },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ }
+ err := m.ReconcileManagedMailAfterDeletion(name, 1001)
+ if err == nil || !strings.Contains(err.Error(), "reappeared") {
+ t.Fatalf("reconciliation error = %v, want replacement refusal", err)
+ }
+ })
+}
+
+func TestMarkManagedExpectedRefusesReplacementBeforeUsermod(t *testing.T) {
+ pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/sh\n")
+ setProcRoot(t, map[int]string{})
+ f := &fakeRunner{available: map[string]bool{"useradd": true, "usermod": true}}
+ m := managerWithStubbedHomeChecks(f)
+ pending, err := m.CreatePendingIdentity("xxvcc-a1", "/bin/sh", testGeneration)
+ if err != nil {
+ t.Fatal(err)
}
- t.Cleanup(func() { pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose })
- if err := CheckPidfd(); err == nil || !errors.Is(err, syscall.EPERM) {
- t.Fatalf("CheckPidfd signal error=%v, want EPERM", err)
+ if err := os.WriteFile(passwdPath, []byte("xxvcc-a1:x:3456:3456:replacement:/home/xxvcc-a1:/bin/sh\n"), 0o644); err != nil {
+ t.Fatal(err)
}
- if !closed {
- t.Fatal("pidfd was not closed after the signalling probe failed")
+ if _, err := m.MarkManagedExpected("xxvcc-a1", testGeneration, pending); err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("replacement MarkManagedExpected error = %v", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "useradd" {
+ t.Fatalf("replacement reached usermod: calls=%v", f.calls)
}
}
-func TestTerminateProcessesDoesNotOpenPidfdsForUnrelatedUIDs(t *testing.T) {
- setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n"})
- oldOpen := pidfdOpen
- opened := 0
- pidfdOpen = func(int, int) (int, error) {
- opened++
- return -1, syscall.ENOSYS
- }
- t.Cleanup(func() { pidfdOpen = oldOpen })
- if err := TerminateProcesses(2345); err != nil {
- t.Fatalf("no target processes should need no pidfd: %v", err)
- }
- if opened != 0 {
- t.Fatalf("opened %d pidfds for unrelated processes, want 0", opened)
+func TestMarkManagedRequiresUsermod(t *testing.T) {
+ if err := (&Manager{Runner: &fakeRunner{}}).MarkManaged("xxvcc-a1", testGeneration); err == nil {
+ t.Fatal("MarkManaged accepted a host without usermod")
}
}
-func TestTerminateProcessesFailsClosedWhenTargetNeedsUnavailablePidfd(t *testing.T) {
- setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"})
- oldOpen := pidfdOpen
- pidfdOpen = func(int, int) (int, error) { return -1, syscall.ENOSYS }
- t.Cleanup(func() { pidfdOpen = oldOpen })
- if err := TerminateProcesses(2345); err == nil || !errors.Is(err, syscall.ENOSYS) {
- t.Fatalf("target process without pidfd support error=%v, want ENOSYS", err)
+func TestCreateRequiresUseradd(t *testing.T) {
+ for _, helper := range []string{"adduser", "busybox"} {
+ t.Run(helper, func(t *testing.T) {
+ f := &fakeRunner{available: map[string]bool{helper: true}}
+ err := managerWithStubbedHomeChecks(f).Create("xxvcc-a1", "/bin/sh", testGeneration)
+ if err == nil || !strings.Contains(err.Error(), "useradd not available") {
+ t.Fatalf("Create error = %v, want useradd refusal", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("unapproved account helper was invoked: %v", f.calls)
+ }
+ })
}
}
-func TestTerminateProcessesReportsScanAndSignalFailures(t *testing.T) {
- t.Run("scan", func(t *testing.T) {
- old := procRoot
- procRoot = filepath.Join(t.TempDir(), "missing")
- t.Cleanup(func() { procRoot = old })
- if err := TerminateProcesses(2345); err == nil || !strings.Contains(err.Error(), "scan") {
- t.Fatalf("TerminateProcesses error = %v, want scan error", err)
+func TestCreateEnforcesManagedHomeChecks(t *testing.T) {
+ marker := config.ManagedGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n")
+ setProcRoot(t, map[int]string{})
+
+ t.Run("preflight before helper", func(t *testing.T) {
+ f := &fakeRunner{available: map[string]bool{"useradd": true}}
+ wantErr := errors.New("pre-existing home")
+ m := &Manager{
+ Runner: f,
+ PrepareManagedHome: func(string) error { return wantErr },
+ ValidateManagedHome: func(Passwd) error { t.Fatal("post-create check ran"); return nil },
+ }
+ if err := m.Create("xxvcc-a1", "/bin/sh", testGeneration); !errors.Is(err, wantErr) {
+ t.Fatalf("Create error = %v, want %v", err, wantErr)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("unsafe home reached useradd: %v", f.calls)
}
})
- t.Run("signal", func(t *testing.T) {
- setProcRoot(t, map[int]string{77: "Uid:\t2345\t2345\t2345\t2345\n"})
- withFakePidfds(t, func(int, unix.Signal, *unix.Siginfo, int) error { return syscall.EPERM })
- if err := TerminateProcesses(2345); err == nil || !errors.Is(err, syscall.EPERM) {
+ t.Run("post-create identity", func(t *testing.T) {
+ f := &fakeRunner{available: map[string]bool{"useradd": true}}
+ wantErr := errors.New("wrong home owner")
+ m := &Manager{
+ Runner: f,
+ PrepareManagedHome: func(string) error { return nil },
+ CreateManagedHome: func(Passwd) error { return nil },
+ ValidateManagedHome: func(Passwd) error { return wantErr },
+ }
+ if err := m.Create("xxvcc-a1", "/bin/sh", testGeneration); !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "retained") {
+ t.Fatalf("Create error = %v, want retained-account home failure", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "useradd" {
+ t.Fatalf("post-create check call order = %v", f.calls)
+ }
+ })
+
+ t.Run("create empty home before validation", func(t *testing.T) {
+ f := &fakeRunner{available: map[string]bool{"useradd": true}}
+ wantErr := errors.New("home creation failed")
+ m := &Manager{
+ Runner: f,
+ PrepareManagedHome: func(string) error { return nil },
+ CreateManagedHome: func(Passwd) error { return wantErr },
+ ValidateManagedHome: func(Passwd) error {
+ t.Fatal("validation ran after failed Home creation")
+ return nil
+ },
+ }
+ if err := m.Create("xxvcc-a1", "/bin/sh", testGeneration); !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "retained") {
+ t.Fatalf("Create error = %v, want retained-account Home creation failure", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "useradd" {
+ t.Fatalf("Home creation failure call order = %v", f.calls)
+ }
+ })
+}
+
+func useTemporaryManagedHomeRoot(t *testing.T) string {
+ t.Helper()
+ if os.Geteuid() != 0 {
+ t.Skip("managed-home safety checks require root ownership")
+ }
+ root := t.TempDir()
+ if err := os.Chown(root, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(root, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ old := managedHomeRoot
+ managedHomeRoot = root
+ t.Cleanup(func() { managedHomeRoot = old })
+ return root
+}
+
+func managedHomeFixture(t *testing.T, name string) (Passwd, string) {
+ t.Helper()
+ root := useTemporaryManagedHomeRoot(t)
+ home := filepath.Join(root, name)
+ if err := os.Mkdir(home, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ const uid, gid = 2345, 2346
+ if err := os.Chown(home, uid, gid); err != nil {
+ t.Fatal(err)
+ }
+ return Passwd{Name: name, UID: uid, GID: gid, Home: home, Shell: "/bin/sh"}, home
+}
+
+func TestPrepareManagedHomeRejectsExistingTargetAndUnsafeParent(t *testing.T) {
+ root := useTemporaryManagedHomeRoot(t)
+ if err := prepareManagedHome("xxvcc-u"); err != nil {
+ t.Fatalf("absent home rejected: %v", err)
+ }
+ home := filepath.Join(root, "xxvcc-u")
+ if err := os.Mkdir(home, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := prepareManagedHome("xxvcc-u"); err == nil || !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("pre-existing home error = %v", err)
+ }
+ if err := os.Remove(home); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(root, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ if err := prepareManagedHome("xxvcc-u"); err == nil || !strings.Contains(err.Error(), "unsafe") {
+ t.Fatalf("writable home parent error = %v", err)
+ }
+}
+
+func TestCreateManagedHomeCreatesAnEmptyPinnedDirectory(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("managed Home ownership requires root")
+ }
+ root := useTemporaryManagedHomeRoot(t)
+ expected := Passwd{
+ Name: "xxvcc-emptyhome", UID: 2345, GID: 2346,
+ Home: filepath.Join(root, "xxvcc-emptyhome"), Shell: "/bin/sh",
+ }
+ if err := createManagedHome(expected); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(expected.Home)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("new managed Home inherited unexpected entries: %v", entries)
+ }
+ fi, err := os.Lstat(expected.Home)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok || st.Uid != uint32(expected.UID) || st.Gid != uint32(expected.GID) || fi.Mode().Perm() != 0o700 {
+ t.Fatalf("managed Home metadata = owner %v:%v mode %o, want %d:%d 700", st.Uid, st.Gid, fi.Mode().Perm(), expected.UID, expected.GID)
+ }
+ if err := createManagedHome(expected); err == nil || !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("second Home creation error = %v, want existing-target refusal", err)
+ }
+}
+
+func TestCreateManagedHomeFailsClosedWhenParentIsReplaced(t *testing.T) {
+ root := useTemporaryManagedHomeRoot(t)
+ expected := Passwd{
+ Name: "xxvcc-parent-swap", UID: 2345, GID: 2346,
+ Home: filepath.Join(root, "xxvcc-parent-swap"), Shell: "/bin/sh",
+ }
+ oldSync := syncCreatedHomeMetadata
+ syncCreatedHomeMetadata = func(home *os.File) error {
+ oldRoot := root + ".replaced"
+ if err := os.Rename(root, oldRoot); err != nil {
+ return err
+ }
+ if err := os.Mkdir(root, 0o700); err != nil {
+ return err
+ }
+ if err := os.Chown(root, 0, 0); err != nil {
+ return err
+ }
+ return home.Sync()
+ }
+ t.Cleanup(func() { syncCreatedHomeMetadata = oldSync })
+
+ if err := createManagedHome(expected); err == nil || !strings.Contains(err.Error(), "parent was replaced") {
+ t.Fatalf("parent replacement error = %v", err)
+ }
+}
+
+func TestCreateManagedHomeReportsDurabilityFailures(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ operation string
+ inject func(error)
+ }{
+ {
+ name: "home metadata",
+ operation: "managed home metadata update",
+ inject: func(want error) {
+ syncCreatedHomeMetadata = func(*os.File) error { return want }
+ },
+ },
+ {
+ name: "parent entry",
+ operation: "managed home creation",
+ inject: func(want error) {
+ syncCreatedHomeParent = func(*os.File) error { return want }
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ root := useTemporaryManagedHomeRoot(t)
+ oldHomeSync, oldParentSync := syncCreatedHomeMetadata, syncCreatedHomeParent
+ t.Cleanup(func() {
+ syncCreatedHomeMetadata, syncCreatedHomeParent = oldHomeSync, oldParentSync
+ })
+ wantErr := errors.New("injected fsync failure")
+ tc.inject(wantErr)
+ expected := Passwd{
+ Name: "xxvcc-sync-fail", UID: 2345, GID: 2346,
+ Home: filepath.Join(root, "xxvcc-sync-fail"), Shell: "/bin/sh",
+ }
+ err := createManagedHome(expected)
+ var durability *fsutil.DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != tc.operation {
+ t.Fatalf("createManagedHome error = %v, want %q durability failure", err, tc.operation)
+ }
+ })
+ }
+}
+
+func TestValidateCreatedHomeRequiresNonRootOwnedRealDirectory(t *testing.T) {
+ expected, home := managedHomeFixture(t, "xxvcc-u")
+ if err := validateCreatedHome(expected); err != nil {
+ t.Fatalf("valid created home rejected: %v", err)
+ }
+ expected.GID = 0
+ if err := validateCreatedHome(expected); err == nil || !strings.Contains(err.Error(), "invalid account owner") {
+ t.Fatalf("root primary group error = %v", err)
+ }
+ expected.GID = 2346
+ if err := os.Remove(home); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateCreatedHome(expected); err == nil || !strings.Contains(err.Error(), "was not created") {
+ t.Fatalf("missing created home error = %v", err)
+ }
+}
+
+func setProcRoot(t *testing.T, statuses map[int]string) {
+ t.Helper()
+ dir := t.TempDir()
+ old := procRoot
+ procRoot = dir
+ t.Cleanup(func() { procRoot = old })
+ for pid, status := range statuses {
+ writeProcProcess(t, pid, status)
+ }
+}
+
+func writeProcProcess(t *testing.T, tgid int, status string) {
+ t.Helper()
+ writeProcTask(t, tgid, tgid, status)
+}
+
+func writeProcTask(t *testing.T, tgid, tid int, status string) {
+ t.Helper()
+ pidDir := filepath.Join(procRoot, fmt.Sprint(tgid))
+ taskDir := filepath.Join(pidDir, "task", fmt.Sprint(tid))
+ if err := os.MkdirAll(taskDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(taskDir, "status"), []byte(status), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tid == tgid {
+ if err := os.WriteFile(filepath.Join(pidDir, "status"), []byte(status), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestCreateRejectsUIDWithResidualProcess(t *testing.T) {
+ marker := config.ManagedGenerationGECOSPrefix + testGeneration
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n")
+ // The target UID appears only in the saved-set UID column. Checking only real
+ // and effective UIDs would miss this process, which can switch back to 2345.
+ setProcRoot(t, map[int]string{77: "Name:\tleftover\nUid:\t1000\t1000\t2345\t1000\n"})
+ f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}}
+ err := managerWithStubbedHomeChecks(f).Create("xxvcc-a1", "/bin/sh", testGeneration)
+ if err == nil || !strings.Contains(err.Error(), "UID 2345") || !strings.Contains(err.Error(), "77") {
+ t.Fatalf("Create error = %v, want residual-UID process refusal", err)
+ }
+ want := [][]string{{"useradd", "-M", "-d", "/home/xxvcc-a1", "-s", "/bin/sh", "-c", marker,
+ "-e", expiredDate, "-p", initialLockedPasswordHash, "xxvcc-a1"}}
+ if !reflect.DeepEqual(f.calls, want) {
+ t.Fatalf("Create calls = %v, want the pending account retained to occupy the reused UID: %v", f.calls, want)
+ }
+}
+
+func TestCreateFailsClosedWhenProcCannotBeScanned(t *testing.T) {
+ setPasswd(t, "xxvcc-a1:x:2345:2345:"+config.ManagedGenerationGECOSPrefix+testGeneration+":/home/xxvcc-a1:/bin/sh\n")
+ old := procRoot
+ procRoot = filepath.Join(t.TempDir(), "missing")
+ t.Cleanup(func() { procRoot = old })
+ f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}}
+ if err := managerWithStubbedHomeChecks(f).Create("xxvcc-a1", "/bin/sh", testGeneration); err == nil || !strings.Contains(err.Error(), "scan") {
+ t.Fatalf("Create error = %v, want proc scan failure", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "useradd" {
+ t.Fatalf("inconclusive UID scan freed the pending UID: calls=%v", f.calls)
+ }
+}
+
+func TestCreateRollbackRefusesReplacementIdentity(t *testing.T) {
+ const original = "xxvcc-a1:x:2345:2345:original:/home/xxvcc-a1:/bin/sh\n"
+ setPasswd(t, original)
+ old := procRoot
+ procRoot = filepath.Join(t.TempDir(), "missing")
+ t.Cleanup(func() { procRoot = old })
+ replacement := Passwd{Name: "xxvcc-a1", UID: 3456, GID: 3456, GECOS: "replacement", Home: "/srv/xxvcc-a1", Shell: "/bin/bash"}
+ f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}}
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) {
+ return replacement, true, nil
+ },
+ PrepareManagedHome: func(string) error { return nil },
+ ValidateManagedHome: func(Passwd) error { return nil },
+ }
+ err := m.Create("xxvcc-a1", "/bin/sh", testGeneration)
+ if err == nil || !strings.Contains(err.Error(), "identity does not match") {
+ t.Fatalf("Create error = %v, want replacement refusal", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "useradd" {
+ t.Fatalf("replacement identity reached name-scoped delete: calls=%v", f.calls)
+ }
+}
+
+func TestCreateDoesNotRollBackAnUnsafeOrUnreadableIdentityByName(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ passwd string
+ }{
+ {name: "uid zero", passwd: "xxvcc-a1:x:0:0:unsafe:/root:/bin/sh\n"},
+ {name: "missing", passwd: ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ setPasswd(t, tc.passwd)
+ f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}}
+ err := managerWithStubbedHomeChecks(f).Create("xxvcc-a1", "/bin/sh", testGeneration)
+ if err == nil {
+ t.Fatal("Create accepted an unsafe or missing post-create identity")
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "useradd" {
+ t.Fatalf("unverified identity reached name-scoped delete: calls=%v", f.calls)
+ }
+ })
+ }
+}
+
+func TestProcessesForUIDChecksAllFourUIDColumns(t *testing.T) {
+ setProcRoot(t, map[int]string{
+ 11: "Uid:\t1111\t2000\t2000\t2000\n",
+ 12: "Uid:\t2000\t1111\t2000\t2000\n",
+ 13: "Uid:\t2000\t2000\t1111\t2000\n",
+ 14: "Uid:\t2000\t2000\t2000\t1111\n",
+ })
+ pids, err := processesForUID(1111)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []int{11, 12, 13, 14}; !reflect.DeepEqual(pids, want) {
+ t.Fatalf("processesForUID = %v, want %v", pids, want)
+ }
+}
+
+func TestProcessesForUIDIgnoresZombieAndDeadTasks(t *testing.T) {
+ setProcRoot(t, map[int]string{
+ 11: "State:\tZ (zombie)\nUid:\t1111\t1111\t1111\t1111\n",
+ 12: "State:\tX (dead)\nUid:\t1111\t1111\t1111\t1111\n",
+ 13: "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n",
+ })
+ pids, err := processesForUID(1111)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []int{13}; !reflect.DeepEqual(pids, want) {
+ t.Fatalf("processesForUID = %v, want only live tasks %v", pids, want)
+ }
+}
+
+func TestProcessScanAndSignalFindLiveWorkerBehindZombieLeader(t *testing.T) {
+ const uid = 1111
+ setProcRoot(t, map[int]string{
+ 77: "State:\tZ (zombie)\nUid:\t1111\t1111\t1111\t1111\n",
+ })
+ writeProcTask(t, 77, 78, "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n")
+
+ pids, err := processesForUID(uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []int{77}; !reflect.DeepEqual(pids, want) {
+ t.Fatalf("processesForUID = %v, want thread group with live worker %v", pids, want)
+ }
+
+ var signalled []int
+ withFakePidfds(t, func(fd int, sig unix.Signal, _ *unix.Siginfo, flags int) error {
+ if sig != unix.SIGKILL || flags != 0 {
+ t.Fatalf("pidfd signal = (%d, %d), want SIGKILL with flags 0", sig, flags)
+ }
+ signalled = append(signalled, fd-10000)
+ return nil
+ })
+ got, err := signalUID(unix.SIGKILL, uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []int{77}; !reflect.DeepEqual(got, want) || !reflect.DeepEqual(signalled, want) {
+ t.Fatalf("signalUID = %v, pidfds=%v; want zombie-leader group %v", got, signalled, want)
+ }
+}
+
+func TestProcessesForUIDRetriesWhenSnapshotTaskForksThenExits(t *testing.T) {
+ const uid = 1111
+ setProcRoot(t, map[int]string{
+ 77: "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n",
+ })
+ oldReadDir := readProcDirectory
+ readCalls := 0
+ readProcDirectory = func(path string) ([]os.DirEntry, error) {
+ entries, err := oldReadDir(path)
+ if err != nil {
+ return nil, err
+ }
+ if path != procRoot {
+ return entries, nil
+ }
+ readCalls++
+ if readCalls == 1 {
+ // The old snapshot contains only the parent. It forks a child and exits
+ // before its status is read, so the child exists only in the next snapshot.
+ if err := os.RemoveAll(filepath.Join(procRoot, "77")); err != nil {
+ t.Fatal(err)
+ }
+ status := "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n"
+ writeProcProcess(t, 78, status)
+ }
+ return entries, nil
+ }
+ t.Cleanup(func() { readProcDirectory = oldReadDir })
+
+ pids, err := processesForUID(uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []int{78}; !reflect.DeepEqual(pids, want) {
+ t.Fatalf("processesForUID = %v, want forked child %v", pids, want)
+ }
+ if readCalls != 2 {
+ t.Fatalf("process directory reads = %d, want unstable snapshot plus retry", readCalls)
+ }
+}
+
+func TestProcessesForUIDDoesNotLetPIDReuseMaskForkedChild(t *testing.T) {
+ const uid = 1111
+ setProcRoot(t, map[int]string{
+ 77: "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n",
+ })
+ oldReadDir := readProcDirectory
+ rootReads := 0
+ readProcDirectory = func(path string) ([]os.DirEntry, error) {
+ entries, err := oldReadDir(path)
+ if err != nil {
+ return nil, err
+ }
+ if path != procRoot {
+ return entries, nil
+ }
+ rootReads++
+ if rootReads == 1 {
+ // The old target parent exits after forking 78, but PID 77 is reused
+ // before its status is read. Reading the unrelated replacement produces
+ // no ENOENT, so only a second stable empty scan can discover the child.
+ if err := os.RemoveAll(filepath.Join(procRoot, "77")); err != nil {
+ t.Fatal(err)
+ }
+ writeProcProcess(t, 77, "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n")
+ writeProcProcess(t, 78, "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n")
+ }
+ return entries, nil
+ }
+ t.Cleanup(func() { readProcDirectory = oldReadDir })
+
+ pids, err := processesForUID(uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []int{78}; !reflect.DeepEqual(pids, want) {
+ t.Fatalf("processesForUID = %v, want child hidden by PID reuse %v", pids, want)
+ }
+ if rootReads != 2 {
+ t.Fatalf("top-level process scans = %d, want two-scan empty confirmation", rootReads)
+ }
+}
+
+func TestProcessesForUIDFailsClosedWhenSnapshotsNeverStabilize(t *testing.T) {
+ setProcRoot(t, map[int]string{
+ 77: "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n",
+ })
+ oldReadDir := readProcDirectory
+ readCalls := 0
+ readProcDirectory = func(path string) ([]os.DirEntry, error) {
+ entries, err := oldReadDir(path)
+ if err != nil {
+ return nil, err
+ }
+ if path != procRoot {
+ return entries, nil
+ }
+ readCalls++
+ oldPID := 76 + readCalls
+ newPID := oldPID + 1
+ if err := os.RemoveAll(filepath.Join(procRoot, strconv.Itoa(oldPID))); err != nil {
+ t.Fatal(err)
+ }
+ status := "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n"
+ writeProcProcess(t, newPID, status)
+ return entries, nil
+ }
+ t.Cleanup(func() { readProcDirectory = oldReadDir })
+
+ if pids, err := processesForUID(1111); err == nil || !strings.Contains(err.Error(), "no consecutive stable empty process snapshots") {
+ t.Fatalf("processesForUID = %v, %v; want unstable-snapshot refusal", pids, err)
+ }
+ if readCalls != processScanAttempts {
+ t.Fatalf("process directory reads = %d, want bounded %d attempts", readCalls, processScanAttempts)
+ }
+}
+
+func withFakePidfds(t *testing.T, send func(int, unix.Signal, *unix.Siginfo, int) error) {
+ t.Helper()
+ oldOpen, oldSend, oldClose, oldSleep := pidfdOpen, pidfdSendSignal, closeFD, terminateSleep
+ pidfdOpen = func(pid, flags int) (int, error) { return pid + 10000, nil }
+ pidfdSendSignal = send
+ closeFD = func(int) error { return nil }
+ terminateSleep = func(time.Duration) {}
+ t.Cleanup(func() {
+ pidfdOpen, pidfdSendSignal, closeFD, terminateSleep = oldOpen, oldSend, oldClose, oldSleep
+ })
+}
+
+func TestCheckPidfdReportsKernelOrSandboxFailure(t *testing.T) {
+ oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD
+ pidfdOpen = func(pid, flags int) (int, error) {
+ if pid != os.Getpid() || flags != 0 {
+ t.Fatalf("PidfdOpen(%d, %d), want self", pid, flags)
+ }
+ return -1, syscall.ENOSYS
+ }
+ pidfdSendSignal = func(int, unix.Signal, *unix.Siginfo, int) error {
+ t.Fatal("signal called after failed pidfd open")
+ return nil
+ }
+ closeFD = func(int) error { t.Fatal("close called after failed pidfd probe"); return nil }
+ t.Cleanup(func() { pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose })
+ if err := CheckPidfd(); err == nil || !errors.Is(err, syscall.ENOSYS) {
+ t.Fatalf("CheckPidfd error=%v, want ENOSYS", err)
+ }
+}
+
+func TestCheckPidfdReportsSignalFailureAndClosesDescriptor(t *testing.T) {
+ oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD
+ pidfdOpen = func(int, int) (int, error) { return 42, nil }
+ pidfdSendSignal = func(fd int, sig unix.Signal, info *unix.Siginfo, flags int) error {
+ if fd != 42 || sig != 0 || info != nil || flags != 0 {
+ t.Fatalf("PidfdSendSignal(%d, %d, %v, %d), want harmless self probe", fd, sig, info, flags)
+ }
+ return syscall.EPERM
+ }
+ closed := false
+ closeFD = func(fd int) error {
+ if fd != 42 {
+ t.Fatalf("close(%d), want 42", fd)
+ }
+ closed = true
+ return nil
+ }
+ t.Cleanup(func() { pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose })
+ if err := CheckPidfd(); err == nil || !errors.Is(err, syscall.EPERM) {
+ t.Fatalf("CheckPidfd signal error=%v, want EPERM", err)
+ }
+ if !closed {
+ t.Fatal("pidfd was not closed after the signalling probe failed")
+ }
+}
+
+func TestTerminateProcessesDoesNotOpenPidfdsForUnrelatedUIDs(t *testing.T) {
+ setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n"})
+ oldOpen := pidfdOpen
+ opened := 0
+ pidfdOpen = func(int, int) (int, error) {
+ opened++
+ return -1, syscall.ENOSYS
+ }
+ t.Cleanup(func() { pidfdOpen = oldOpen })
+ if err := TerminateProcesses(2345); err != nil {
+ t.Fatalf("no target processes should need no pidfd: %v", err)
+ }
+ if opened != 0 {
+ t.Fatalf("opened %d pidfds for unrelated processes, want 0", opened)
+ }
+}
+
+func TestTerminateProcessesFailsClosedWhenTargetNeedsUnavailablePidfd(t *testing.T) {
+ setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"})
+ oldOpen := pidfdOpen
+ pidfdOpen = func(int, int) (int, error) { return -1, syscall.ENOSYS }
+ t.Cleanup(func() { pidfdOpen = oldOpen })
+ if err := TerminateProcesses(2345); err == nil || !errors.Is(err, syscall.ENOSYS) {
+ t.Fatalf("target process without pidfd support error=%v, want ENOSYS", err)
+ }
+}
+
+func TestTerminateProcessesRescansAfterSnapshotTaskForksThenExits(t *testing.T) {
+ const uid = 2345
+ setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"})
+ oldOpen, oldSend, oldClose, oldSleep := pidfdOpen, pidfdSendSignal, closeFD, terminateSleep
+ openCalls := 0
+ pidfdOpen = func(pid, flags int) (int, error) {
+ if flags != 0 {
+ t.Fatalf("PidfdOpen flags = %d, want 0", flags)
+ }
+ openCalls++
+ if openCalls == 2 {
+ // The first SIGKILL snapshot contained only pid 77. Before its pidfd
+ // opens, it forks pid 78 and exits. pid 78 was never in that snapshot.
+ if err := os.RemoveAll(filepath.Join(procRoot, "77")); err != nil {
+ t.Fatal(err)
+ }
+ status := "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"
+ writeProcProcess(t, 78, status)
+ return -1, unix.ESRCH
+ }
+ return pid + 10000, nil
+ }
+ kills := 0
+ pidfdSendSignal = func(fd int, sig unix.Signal, _ *unix.Siginfo, flags int) error {
+ if flags != 0 {
+ t.Fatalf("PidfdSendSignal flags = %d, want 0", flags)
+ }
+ if sig == unix.SIGKILL {
+ kills++
+ if fd != 10078 {
+ t.Fatalf("SIGKILL fd = %d, want child pidfd 10078", fd)
+ }
+ if err := os.RemoveAll(filepath.Join(procRoot, "78")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ closeFD = func(int) error { return nil }
+ terminateSleep = func(time.Duration) {}
+ t.Cleanup(func() {
+ pidfdOpen, pidfdSendSignal, closeFD, terminateSleep = oldOpen, oldSend, oldClose, oldSleep
+ })
+
+ if err := TerminateProcesses(uid); err != nil {
+ t.Fatalf("TerminateProcesses missed forked child: %v", err)
+ }
+ if openCalls != 3 || kills != 1 {
+ t.Fatalf("pidfd opens=%d SIGKILLs=%d, want TERM parent, raced parent, then killed child", openCalls, kills)
+ }
+}
+
+func TestTerminateProcessesDoesNotAcceptUnstableFinalEmptyScan(t *testing.T) {
+ const uid = 2345
+ setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"})
+ oldReadDir := readProcDirectory
+ readCalls := 0
+ readProcDirectory = func(path string) ([]os.DirEntry, error) {
+ entries, err := oldReadDir(path)
+ if err != nil {
+ return nil, err
+ }
+ if path != procRoot {
+ return entries, nil
+ }
+ readCalls++
+ if readCalls == 3 {
+ // Calls one and two are the TERM and first KILL signal snapshots. This
+ // third call is the final credential check: its parent exits after the
+ // directory snapshot and leaves a child absent from that old listing.
+ if err := os.RemoveAll(filepath.Join(procRoot, "77")); err != nil {
+ t.Fatal(err)
+ }
+ status := "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"
+ writeProcProcess(t, 78, status)
+ }
+ return entries, nil
+ }
+
+ oldOpen, oldSend, oldClose, oldSleep := pidfdOpen, pidfdSendSignal, closeFD, terminateSleep
+ openCalls := 0
+ pidfdOpen = func(pid, flags int) (int, error) {
+ openCalls++
+ if openCalls == 2 {
+ return -1, unix.ESRCH
+ }
+ return pid + 10000, nil
+ }
+ kills := 0
+ pidfdSendSignal = func(fd int, sig unix.Signal, _ *unix.Siginfo, _ int) error {
+ if sig == unix.SIGKILL {
+ kills++
+ if fd != 10078 {
+ t.Fatalf("SIGKILL fd = %d, want forked child pidfd 10078", fd)
+ }
+ if err := os.RemoveAll(filepath.Join(procRoot, "78")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ closeFD = func(int) error { return nil }
+ terminateSleep = func(time.Duration) {}
+ t.Cleanup(func() {
+ readProcDirectory = oldReadDir
+ pidfdOpen, pidfdSendSignal, closeFD, terminateSleep = oldOpen, oldSend, oldClose, oldSleep
+ })
+
+ if err := TerminateProcesses(uid); err != nil {
+ t.Fatalf("TerminateProcesses accepted no stable final scan: %v", err)
+ }
+ if kills != 1 {
+ t.Fatalf("SIGKILL calls = %d, want forked child killed on the retry", kills)
+ }
+ if _, err := os.Lstat(filepath.Join(procRoot, "78")); !os.IsNotExist(err) {
+ t.Fatalf("forked child survived unstable final scan: %v", err)
+ }
+}
+
+func TestTerminateProcessesReportsScanAndSignalFailures(t *testing.T) {
+ t.Run("scan", func(t *testing.T) {
+ old := procRoot
+ procRoot = filepath.Join(t.TempDir(), "missing")
+ t.Cleanup(func() { procRoot = old })
+ if err := TerminateProcesses(2345); err == nil || !strings.Contains(err.Error(), "scan") {
+ t.Fatalf("TerminateProcesses error = %v, want scan error", err)
+ }
+ })
+
+ t.Run("signal", func(t *testing.T) {
+ setProcRoot(t, map[int]string{77: "Uid:\t2345\t2345\t2345\t2345\n"})
+ withFakePidfds(t, func(int, unix.Signal, *unix.Siginfo, int) error { return syscall.EPERM })
+ if err := TerminateProcesses(2345); err == nil || !errors.Is(err, syscall.EPERM) {
t.Fatalf("TerminateProcesses error = %v, want EPERM", err)
}
})
@@ -729,7 +1761,12 @@ func TestLockExpiryArgv(t *testing.T) {
m := &Manager{Runner: f}
_ = m.LockPassword("xxvcc-u")
_ = m.SetExpiry("xxvcc-u", "2026-07-09")
- want := [][]string{{"usermod", "-L", "xxvcc-u"}, {"chage", "-E", "2026-07-09", "xxvcc-u"}}
+ _ = m.ClearExpiry("xxvcc-u")
+ want := [][]string{
+ {"usermod", "-L", "xxvcc-u"},
+ {"chage", "-E", "2026-07-09", "xxvcc-u"},
+ {"chage", "-E", "-1", "xxvcc-u"},
+ }
if !reflect.DeepEqual(f.calls, want) {
t.Errorf("calls = %v, want %v", f.calls, want)
}
@@ -753,31 +1790,667 @@ func TestDisablePasswordForKeyLoginUsesUnmatchableUnlockedShadowValue(t *testing
}
}
-func TestDeleteFallsBackToUserdel(t *testing.T) {
- // deluser present but fails -> userdel is tried.
- //
- // The -f is load-bearing, not decoration: without it shadow's userdel exits 8
- // whenever a session exists, so an invitee reconnecting in a loop could make
- // every revoke fail and keep the account alive.
- f := &fakeRunner{available: map[string]bool{"deluser": true, "userdel": true}, failOn: map[string]bool{"deluser": true}}
- m := &Manager{Runner: f}
- if err := m.Delete("xxvcc-u"); err != nil {
+func TestDeleteRequiresUserdel(t *testing.T) {
+ setPasswd(t, "xxvcc-u:x:1001:1001::/home/xxvcc-u:/bin/sh\n")
+ expected, ok, err := Lookup("xxvcc-u")
+ if err != nil || !ok {
+ t.Fatalf("Lookup = %+v, %v, %v", expected, ok, err)
+ }
+ f := &fakeRunner{available: map[string]bool{"deluser": true, "busybox": true}}
+ err = managerWithStubbedHomeRemoval(f).DeleteExpected(expected.Name, expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "userdel not available") {
+ t.Fatalf("DeleteExpected error = %v, want userdel refusal", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("unapproved deletion helper was invoked: %v", f.calls)
+ }
+}
+
+func TestDeleteExpectedRequiresFinalQuiescenceCallback(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ if err := (&Manager{Runner: f}).DeleteExpected(expected.Name, expected, nil); err == nil || !strings.Contains(err.Error(), "quiescence") {
+ t.Fatalf("DeleteExpected without callback error = %v, want refusal", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("missing quiescence callback reached userdel: %v", f.calls)
+ }
+}
+
+func TestDeleteExpectedRefusesNameScopedFallbackAfterReplacement(t *testing.T) {
+ const original = "xxvcc-u:x:1001:1001:original:/home/xxvcc-u:/bin/sh\n"
+ const replacement = "xxvcc-u:x:2002:2002:replacement:/srv/xxvcc-u:/bin/bash\n"
+ for _, firstSucceeded := range []bool{false, true} {
+ t.Run(fmt.Sprintf("userdel-success-%v", firstSucceeded), func(t *testing.T) {
+ setPasswd(t, original)
+ expected, ok, err := Lookup("xxvcc-u")
+ if err != nil || !ok {
+ t.Fatalf("Lookup original = %+v, %v, %v", expected, ok, err)
+ }
+ f := &fakeRunner{available: map[string]bool{"deluser": true, "userdel": true}}
+ if !firstSucceeded {
+ f.failOn = map[string]bool{"userdel": true}
+ }
+ f.onRun = func(name string) {
+ if name == "userdel" {
+ if err := os.WriteFile(passwdPath, []byte(replacement), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ err = managerWithStubbedHomeRemoval(f).DeleteExpected("xxvcc-u", expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("DeleteExpected error = %v, want replacement refusal", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "userdel" {
+ t.Fatalf("replacement reached fallback helper: calls=%v", f.calls)
+ }
+ })
+ }
+}
+
+func TestDeleteExpectedRejectsUnboundHomeBeforeHelper(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, GECOS: "managed", Home: "/srv/shared", Shell: "/bin/sh"}
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ if err := (&Manager{Runner: f}).DeleteExpected("xxvcc-u", expected, noOpBeforeDelete); err == nil || !strings.Contains(err.Error(), "invalid expected account identity") {
+ t.Fatalf("DeleteExpected with unbound home error = %v", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("unsafe home reached account helper: %v", f.calls)
+ }
+}
+
+func TestValidateHomeRemovalRequiresDedicatedOwnedRealDirectory(t *testing.T) {
+ oldRoot := managedHomeRoot
+ managedHomeRoot = t.TempDir()
+ t.Cleanup(func() { managedHomeRoot = oldRoot })
+ home := managedHome("xxvcc-u")
+ if err := os.Mkdir(home, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ expected := Passwd{Name: "xxvcc-u", UID: os.Getuid(), GID: os.Getgid(), Home: home}
+ if err := validateHomeRemoval(expected); err != nil {
+ t.Fatalf("safe dedicated home rejected: %v", err)
+ }
+
+ expected.UID++
+ if err := validateHomeRemoval(expected); err == nil || !strings.Contains(err.Error(), "owner does not match") {
+ t.Fatalf("owner mismatch error = %v", err)
+ }
+ expected.UID--
+ if err := os.Remove(home); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(t.TempDir(), home); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateHomeRemoval(expected); err == nil || !strings.Contains(err.Error(), "not a real directory") {
+ t.Fatalf("symlink home error = %v", err)
+ }
+}
+
+func TestRemoveManagedMailRequiresOwnedRegularSpool(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("mail-spool ownership checks require root")
+ }
+ root := t.TempDir()
+ if err := os.Chown(root, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(root, 0o2775); err != nil {
+ t.Fatal(err)
+ }
+ alias := filepath.Join(t.TempDir(), "mail-alias")
+ if err := os.Symlink(root, alias); err != nil {
+ t.Fatal(err)
+ }
+ oldRoots := managedMailRoots
+ managedMailRoots = []string{root, alias}
+ t.Cleanup(func() { managedMailRoots = oldRoots })
+ expected := Passwd{Name: "xxvcc-u", UID: 2345, GID: 2346, Home: "/home/xxvcc-u"}
+ spool := filepath.Join(root, expected.Name)
+ writeSpool := func() {
+ t.Helper()
+ if err := os.WriteFile(spool, []byte("mail\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(spool, expected.UID, 8); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ writeSpool()
+ if err := removeManagedMail(expected); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(spool); !os.IsNotExist(err) {
+ t.Fatalf("owned mail spool survived cleanup: %v", err)
+ }
+
+ writeSpool()
+ if err := os.Chown(spool, expected.UID+1, 8); err != nil {
+ t.Fatal(err)
+ }
+ if err := removeManagedMail(expected); err == nil || !strings.Contains(err.Error(), "owner does not match") {
+ t.Fatalf("wrong-owner spool error = %v", err)
+ }
+ if err := os.Remove(spool); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(t.TempDir(), spool); err != nil {
+ t.Fatal(err)
+ }
+ if err := removeManagedMail(expected); err == nil || !strings.Contains(err.Error(), "not a regular file") {
+ t.Fatalf("symlink spool error = %v", err)
+ }
+}
+
+func TestRemoveManagedMailSyncsParentWhenSpoolDisappearsBeforeUnlink(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("mail-spool ownership checks require root")
+ }
+ root := t.TempDir()
+ if err := os.Chown(root, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ expected := Passwd{Name: "xxvcc-u", UID: 2345, GID: 2346, Home: "/home/xxvcc-u"}
+ spool := filepath.Join(root, expected.Name)
+ if err := os.WriteFile(spool, []byte("mail\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(spool, expected.UID, 8); err != nil {
+ t.Fatal(err)
+ }
+
+ oldUnlink := unlinkManagedMailAt
+ unlinkManagedMailAt = func(dirfd int, path string, flags int) error {
+ if err := oldUnlink(dirfd, path, flags); err != nil {
+ return err
+ }
+ return unix.ENOENT
+ }
+ t.Cleanup(func() { unlinkManagedMailAt = oldUnlink })
+
+ oldSync := syncRemovalDirectory
+ syncs := 0
+ syncRemovalDirectory = func(*os.File) error {
+ syncs++
+ return nil
+ }
+ t.Cleanup(func() { syncRemovalDirectory = oldSync })
+
+ if err := removeManagedMailAt(root, expected); err != nil {
+ t.Fatalf("mail spool disappearance race: %v", err)
+ }
+ if _, err := os.Lstat(spool); !os.IsNotExist(err) {
+ t.Fatalf("mail spool still exists after simulated disappearance: %v", err)
+ }
+ if syncs != 1 {
+ t.Fatalf("mail parent sync calls = %d, want one absence confirmation", syncs)
+ }
+}
+
+func TestAbsentManagedArtifactsResyncParentBeforeSuccess(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("managed artifact durability checks require root-owned directories")
+ }
+ wantErr := errors.New("forced absence-confirmation sync failure")
+
+ t.Run("mail spool", func(t *testing.T) {
+ root := t.TempDir()
+ if err := os.Chown(root, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ expected := Passwd{Name: "xxvcc-u", UID: 2345, GID: 2346, Home: "/home/xxvcc-u"}
+ old := syncRemovalDirectory
+ calls := 0
+ syncRemovalDirectory = func(*os.File) error {
+ calls++
+ return wantErr
+ }
+ t.Cleanup(func() { syncRemovalDirectory = old })
+
+ err := removeManagedMailAt(root, expected)
+ var durability *fsutil.DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "managed mail spool absence confirmation" {
+ t.Fatalf("absent mail cleanup error = %v, want durability error", err)
+ }
+ if calls != 1 {
+ t.Fatalf("absent mail parent sync calls = %d, want 1", calls)
+ }
+ })
+
+ t.Run("Home", func(t *testing.T) {
+ root := useTemporaryManagedHomeRoot(t)
+ expected := Passwd{Name: "xxvcc-u", UID: 2345, GID: 2346, Home: filepath.Join(root, "xxvcc-u"), Shell: "/bin/sh"}
+ old := syncRemovalDirectory
+ calls := 0
+ syncRemovalDirectory = func(*os.File) error {
+ calls++
+ return wantErr
+ }
+ t.Cleanup(func() { syncRemovalDirectory = old })
+
+ err := removeManagedHome(expected)
+ var durability *fsutil.DurabilityError
+ if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "managed home absence confirmation" {
+ t.Fatalf("absent Home cleanup error = %v, want durability error", err)
+ }
+ if calls != 1 {
+ t.Fatalf("absent Home parent sync calls = %d, want 1", calls)
+ }
+ })
+}
+
+func TestDeleteExpectedRemovesManagedArtifactsThenAccountWithoutRecursiveHelper(t *testing.T) {
+ expected, home := managedHomeFixture(t, "xxvcc-u")
+ exists := true
+ var order []string
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ f.onRun = func(name string) {
+ if name == "userdel" {
+ order = append(order, "account")
+ exists = false
+ }
+ }
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) {
+ return expected, exists, nil
+ },
+ RemoveManagedMail: func(Passwd) error {
+ order = append(order, "mail")
+ return nil
+ },
+ RemoveManagedHome: func(pw Passwd) error {
+ order = append(order, "home")
+ return removeManagedHome(pw)
+ },
+ }
+ if err := m.DeleteExpected("xxvcc-u", expected, func() error {
+ order = append(order, "quiesce")
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(f.calls, [][]string{{"userdel", "--", "xxvcc-u"}}) {
+ t.Fatalf("account helper argv = %v", f.calls)
+ }
+ if !reflect.DeepEqual(order, []string{"mail", "home", "quiesce", "account", "mail"}) {
+ t.Fatalf("deletion order = %v, want artifacts then final quiescence before account helper and mail resweep", order)
+ }
+ if _, err := os.Lstat(home); !os.IsNotExist(err) {
+ t.Fatalf("managed home survived controlled cleanup: %v", err)
+ }
+}
+
+func TestDeleteExpectedStopsAfterArtifactsWhenFinalQuiescenceFails(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ wantErr := errors.New("queued work is still present")
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) { return expected, true, nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ }
+ err := m.DeleteExpected(expected.Name, expected, func() error { return wantErr })
+ if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "before userdel") {
+ t.Fatalf("DeleteExpected error = %v, want final quiescence failure", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("failed final quiescence reached userdel: %v", f.calls)
+ }
+}
+
+func TestDeleteExpectedStopsBeforeHomeAndHelperWhenMailCleanupFails(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ wantErr := errors.New("mail spool unsafe")
+ homeCalls := 0
+ f := &fakeRunner{available: map[string]bool{"userdel": true, "deluser": true}}
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) { return expected, true, nil },
+ RemoveManagedMail: func(Passwd) error { return wantErr },
+ RemoveManagedHome: func(Passwd) error { homeCalls++; return nil },
+ }
+ if err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete); !errors.Is(err, wantErr) {
+ t.Fatalf("DeleteExpected error = %v, want %v", err, wantErr)
+ }
+ if homeCalls != 0 || len(f.calls) != 0 {
+ t.Fatalf("mail cleanup failure reached home/helper: home=%d helper=%v", homeCalls, f.calls)
+ }
+}
+
+func TestDeleteExpectedAbsentAccountOnlyCleansOwnerCheckedMail(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ var order []string
+ m := &Manager{
+ LookupUser: func(string) (Passwd, bool, error) { return Passwd{}, false, nil },
+ RemoveManagedMail: func(Passwd) error {
+ order = append(order, "mail")
+ return nil
+ },
+ RemoveManagedHome: func(Passwd) error {
+ order = append(order, "home")
+ return nil
+ },
+ }
+ if err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(order, []string{"mail", "mail"}) {
+ t.Fatalf("absent-account cleanup order = %v, want two mail sweeps and no Home removal", order)
+ }
+}
+
+func TestDeleteExpectedFinalMailSweepRemovesSpoolRecreatedDuringHomeCleanup(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ exists := true
+ spool := filepath.Join(t.TempDir(), expected.Name)
+ mailCalls := 0
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ f.onRun = func(name string) {
+ if name == "userdel" {
+ exists = false
+ }
+ }
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) { return expected, exists, nil },
+ RemoveManagedMail: func(Passwd) error {
+ mailCalls++
+ if err := os.Remove(spool); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+ },
+ RemoveManagedHome: func(Passwd) error {
+ return os.WriteFile(spool, []byte("delivery raced with Home cleanup"), 0o600)
+ },
+ }
+ if err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete); err != nil {
+ t.Fatal(err)
+ }
+ if mailCalls != 2 {
+ t.Fatalf("mail cleanup calls = %d, want initial and post-account sweeps", mailCalls)
+ }
+ if _, err := os.Lstat(spool); !os.IsNotExist(err) {
+ t.Fatalf("mail spool recreated during Home cleanup survived: %v", err)
+ }
+}
+
+func TestDeleteExpectedRetainsFailureWhenFinalMailSweepFails(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ exists := true
+ wantErr := errors.New("final spool cleanup failed")
+ mailCalls := 0
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ f.onRun = func(name string) {
+ if name == "userdel" {
+ exists = false
+ }
+ }
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) { return expected, exists, nil },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ RemoveManagedMail: func(Passwd) error {
+ mailCalls++
+ if mailCalls == 2 {
+ return wantErr
+ }
+ return nil
+ },
+ }
+ err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete)
+ if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "final cleanup") {
+ t.Fatalf("DeleteExpected error = %v, want final mail failure", err)
+ }
+ if exists || mailCalls != 2 {
+ t.Fatalf("post-helper state: exists=%v mail calls=%d", exists, mailCalls)
+ }
+}
+
+func TestDeleteExpectedRefusesAccountReappearanceAtSuccessBoundary(t *testing.T) {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ replacement := expected
+ replacement.UID = 2002
+ replacement.GID = 2002
+ replacement.GECOS = "replacement"
+
+ t.Run("during cleanup of an already absent account", func(t *testing.T) {
+ exists := false
+ current := Passwd{}
+ mailCalls := 0
+ m := &Manager{
+ LookupUser: func(string) (Passwd, bool, error) { return current, exists, nil },
+ RemoveManagedMail: func(Passwd) error {
+ mailCalls++
+ if mailCalls == 2 {
+ current, exists = replacement, true
+ }
+ return nil
+ },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ }
+ err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("DeleteExpected error = %v, want reappearance refusal", err)
+ }
+ })
+
+ t.Run("during final cleanup after userdel", func(t *testing.T) {
+ exists := true
+ current := expected
+ mailCalls := 0
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ f.onRun = func(name string) {
+ if name == "userdel" {
+ exists = false
+ }
+ }
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) { return current, exists, nil },
+ RemoveManagedMail: func(Passwd) error {
+ mailCalls++
+ if mailCalls == 2 {
+ current, exists = replacement, true
+ }
+ return nil
+ },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ }
+ err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("DeleteExpected error = %v, want final reappearance refusal", err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != "userdel" {
+ t.Fatalf("account replacement reached another helper: %v", f.calls)
+ }
+ })
+
+ t.Run("after artifact cleanup but before userdel", func(t *testing.T) {
+ lookupCalls := 0
+ mailCalls := 0
+ f := &fakeRunner{available: map[string]bool{"userdel": true}}
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) {
+ lookupCalls++
+ switch lookupCalls {
+ case 1:
+ return expected, true, nil
+ case 2:
+ return Passwd{}, false, nil
+ default:
+ return replacement, true, nil
+ }
+ },
+ RemoveManagedMail: func(Passwd) error {
+ mailCalls++
+ return nil
+ },
+ RemoveManagedHome: func(Passwd) error { return nil },
+ }
+ err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "identity changed") {
+ t.Fatalf("DeleteExpected error = %v, want pre-helper reappearance refusal", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("account replacement reached helper: %v", f.calls)
+ }
+ if mailCalls != 2 {
+ t.Fatalf("mail cleanup calls = %d, want initial and disappearance sweeps", mailCalls)
+ }
+ })
+}
+
+func TestManagedHomeRemovalUnlinksInteriorSymlinkWithoutTouchingTarget(t *testing.T) {
+ expected, home := managedHomeFixture(t, "xxvcc-u")
+ outside := t.TempDir()
+ sentinel := filepath.Join(outside, "keep")
+ if err := os.WriteFile(sentinel, []byte("outside data"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(home, "outside-link")); err != nil {
+ t.Fatal(err)
+ }
+ if err := removeManagedHome(expected); err != nil {
t.Fatal(err)
}
- if len(f.calls) != 2 || f.calls[0][0] != "deluser" || !reflect.DeepEqual(f.calls[1], []string{"userdel", "-r", "-f", "--", "xxvcc-u"}) {
- t.Errorf("delete calls = %v", f.calls)
+ if _, err := os.Lstat(home); !os.IsNotExist(err) {
+ t.Fatalf("managed Home survived cleanup: %v", err)
+ }
+ if got, err := os.ReadFile(sentinel); err != nil || string(got) != "outside data" {
+ t.Fatalf("interior symlink target changed: content=%q err=%v", got, err)
+ }
+}
+
+func TestManagedHomeRemovalBudgetsFailClosed(t *testing.T) {
+ t.Run("entry limit", func(t *testing.T) {
+ expected, home := managedHomeFixture(t, "xxvcc-u")
+ for _, name := range []string{"a", "b", "c"} {
+ if err := os.WriteFile(filepath.Join(home, name), []byte(name), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ budget := &homeRemovalBudget{remaining: 2, maxDepth: 8, deadline: time.Now().Add(time.Minute)}
+ if err := removeHomeTreeWithin(expected, budget); err == nil || !strings.Contains(err.Error(), "entry limit") {
+ t.Fatalf("bounded removal error = %v, want entry-limit refusal", err)
+ }
+ if _, err := os.Lstat(home); err != nil {
+ t.Fatalf("entry-limit refusal freed managed Home: %v", err)
+ }
+ })
+
+ t.Run("depth limit", func(t *testing.T) {
+ expected, home := managedHomeFixture(t, "xxvcc-u")
+ if err := os.MkdirAll(filepath.Join(home, "one", "two"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ budget := &homeRemovalBudget{remaining: 100, maxDepth: 1, deadline: time.Now().Add(time.Minute)}
+ if err := removeHomeTreeWithin(expected, budget); err == nil || !strings.Contains(err.Error(), "depth limit") {
+ t.Fatalf("bounded removal error = %v, want depth-limit refusal", err)
+ }
+ if _, err := os.Lstat(home); err != nil {
+ t.Fatalf("depth-limit refusal freed managed Home: %v", err)
+ }
+ })
+
+ t.Run("time limit", func(t *testing.T) {
+ expected, home := managedHomeFixture(t, "xxvcc-u")
+ budget := &homeRemovalBudget{
+ remaining: 100,
+ maxDepth: 8,
+ deadline: time.Unix(2, 0),
+ now: func() time.Time { return time.Unix(2, 0) },
+ }
+ if err := removeHomeTreeWithin(expected, budget); err == nil || !strings.Contains(err.Error(), "time limit") {
+ t.Fatalf("bounded removal error = %v, want time-limit refusal", err)
+ }
+ if _, err := os.Lstat(home); err != nil {
+ t.Fatalf("time-limit refusal freed managed Home: %v", err)
+ }
+ })
+}
+
+func TestDeleteExpectedRetainsAccountWhenHomeSafetyCheckFails(t *testing.T) {
+ expected, _ := managedHomeFixture(t, "xxvcc-u")
+ f := &fakeRunner{available: map[string]bool{"userdel": true, "deluser": true}}
+ old := refuseMountsUnder
+ refuseMountsUnder = func(string) error {
+ return errors.New("nested mount")
+ }
+ t.Cleanup(func() { refuseMountsUnder = old })
+ m := &Manager{
+ Runner: f,
+ LookupUser: func(string) (Passwd, bool, error) { return expected, true, nil },
+ RemoveManagedMail: func(Passwd) error { return nil },
+ }
+ err := m.DeleteExpected("xxvcc-u", expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "nested mount") {
+ t.Fatalf("home safety error = %v", err)
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("unsafe home reached account helper: %v", f.calls)
+ }
+}
+
+func TestDeleteDoesNotHideHelperFailureAfterAccountDisappears(t *testing.T) {
+ tests := []struct {
+ name string
+ available map[string]bool
+ command string
+ }{
+ {name: "userdel", available: map[string]bool{"userdel": true}, command: "userdel"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ setPasswd(t, "xxvcc-u:x:1001:1001::/home/xxvcc-u:/bin/sh\n")
+ f := &fakeRunner{
+ available: tc.available,
+ failOn: map[string]bool{tc.command: true},
+ }
+ f.onRun = func(name string) {
+ if err := os.WriteFile(passwdPath, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ expected, ok, lookupErr := Lookup("xxvcc-u")
+ if lookupErr != nil || !ok {
+ t.Fatalf("Lookup = %+v, %v, %v", expected, ok, lookupErr)
+ }
+ err := managerWithStubbedHomeRemoval(f).DeleteExpected("xxvcc-u", expected, noOpBeforeDelete)
+ if err == nil || !strings.Contains(err.Error(), "incomplete cleanup") {
+ t.Fatalf("Delete after %s failure = %v, want incomplete-cleanup error", tc.name, err)
+ }
+ if len(f.calls) != 1 || f.calls[0][0] != tc.command {
+ t.Fatalf("Delete calls = %v, want only %s", f.calls, tc.command)
+ }
+ })
}
}
func TestDeleteRequiresConfirmedAccountRemoval(t *testing.T) {
setPasswd(t, "xxvcc-u:x:1001:1001::/home/xxvcc-u:/bin/sh\n")
- f := &fakeRunner{available: map[string]bool{"deluser": true, "userdel": true}}
- m := &Manager{Runner: f}
- if err := m.Delete("xxvcc-u"); err == nil || !strings.Contains(err.Error(), "still exists") {
+ f := &fakeRunner{available: map[string]bool{"busybox": true, "deluser": true, "userdel": true}}
+ m := managerWithStubbedHomeRemoval(f)
+ expected, ok, lookupErr := Lookup("xxvcc-u")
+ if lookupErr != nil || !ok {
+ t.Fatalf("Lookup = %+v, %v, %v", expected, ok, lookupErr)
+ }
+ if err := m.DeleteExpected("xxvcc-u", expected, noOpBeforeDelete); err == nil || !strings.Contains(err.Error(), "still exists") {
t.Fatalf("Delete error = %v, want post-delete existence failure", err)
}
- if len(f.calls) != 2 || f.calls[0][0] != "deluser" || f.calls[1][0] != "userdel" {
- t.Fatalf("Delete calls = %v, want both helpers after the first false success", f.calls)
+ if len(f.calls) != 1 || f.calls[0][0] != "userdel" {
+ t.Fatalf("Delete calls = %v, want only userdel", f.calls)
+ }
+ for _, call := range f.calls {
+ if call[0] == "deluser" {
+ t.Fatalf("distro deluser was invoked directly: %v", f.calls)
+ }
}
}
@@ -786,7 +2459,8 @@ func TestDeleteFailsClosedWhenRemovalCannotBeVerified(t *testing.T) {
passwdPath = t.TempDir()
t.Cleanup(func() { passwdPath = old })
f := &fakeRunner{available: map[string]bool{"deluser": true}}
- if err := (&Manager{Runner: f}).Delete("xxvcc-u"); err == nil || !strings.Contains(err.Error(), "verify deluser") {
+ expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"}
+ if err := (&Manager{Runner: f}).DeleteExpected("xxvcc-u", expected, noOpBeforeDelete); err == nil || !strings.Contains(err.Error(), "verify account identity before deletion") {
t.Fatalf("Delete error = %v, want passwd verification failure", err)
}
}
@@ -832,7 +2506,8 @@ func TestTerminateProcessesNeverSignalsRootOrAll(t *testing.T) {
}
}
if strconv.IntSize >= 64 {
- reserved := int(uint64(^uint32(0)))
+ reservedKernelID := uint64(^uint32(0))
+ reserved := int(reservedKernelID)
if err := TerminateProcesses(reserved); err == nil || !strings.Contains(err.Error(), "invalid Linux UID") {
t.Fatalf("TerminateProcesses(%d) error = %v, want range refusal", reserved, err)
}
diff --git a/internal/userjobs/jobs.go b/internal/userjobs/jobs.go
new file mode 100644
index 0000000..5b8b3cf
--- /dev/null
+++ b/internal/userjobs/jobs.go
@@ -0,0 +1,532 @@
+// Package userjobs removes deferred work owned by an account before its UID or
+// username can be released. Login expiry and process termination do not cancel a
+// personal crontab or at/batch jobs, and shadow-utils userdel does not guarantee
+// that cleanup unless an optional distribution hook is configured.
+package userjobs
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/xxvcc/linux-temp-admin/internal/executil"
+ "github.com/xxvcc/linux-temp-admin/internal/validate"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ commandTimeout = 15 * time.Second
+ atInventoryTimeout = 30 * time.Second
+ queueOutputLimit = int64(4 << 20)
+ atOwnerProbeLimit = int64(64 << 10)
+ maxAtJobs = 4096
+ spoolReadBatch = 256
+)
+
+var (
+ lookPath = exec.LookPath
+ combinedOutput = executil.CombinedOutput
+ output = executil.Output
+
+ cronSpoolDirectories = []string{
+ "/var/spool/cron/crontabs", // Debian, Alpine
+ "/var/spool/cron", // Cronie
+ "/var/spool/cron/tabs", // openSUSE
+ }
+ atSpoolDirectories = []string{
+ "/var/spool/cron/atjobs", // Debian
+ "/var/spool/at", // Cronie
+ "/var/spool/atjobs", // other at implementations
+ }
+)
+
+func commandOptions(maxOutput int64) executil.Options {
+ return executil.Options{
+ Timeout: commandTimeout, MaxOutput: maxOutput,
+ ExtraEnv: []string{"LC_ALL=C", "LANG=C"},
+ }
+}
+
+var (
+ drainSleep = time.Sleep
+ drainProcRoot = "/proc"
+)
+
+// WaitForDrain leaves the disabled account and UID allocated for longer than one
+// cron/at polling cycle. A daemon may already have read a due job before Clear
+// removed its spool entry but not forked it yet; callers clear and terminate once
+// more after this wait. Tests replace the sleeper, never the production duration.
+func WaitForDrain() error {
+ toolingPresent := commandAvailable("crontab") || commandAvailable("at") ||
+ commandAvailable("atq") || commandAvailable("atrm") || commandAvailable("atd") ||
+ commandAvailable("batch") || commandAvailable("cron") || commandAvailable("crond")
+ if !toolingPresent {
+ daemonPresent, scanErr := cronAtDaemonPresent(drainProcRoot)
+ // An unreadable process inventory cannot prove that no daemon has already
+ // cached a due job. Waiting is the conservative outcome and costs only the
+ // same bounded delay used when a known footprint exists.
+ if scanErr == nil && !daemonPresent {
+ return nil
+ }
+ }
+ drainSleep(65 * time.Second)
+ return nil
+}
+
+func cronAtDaemonPresent(procRoot string) (bool, error) {
+ proc, err := os.Open(procRoot)
+ if err != nil {
+ return false, fmt.Errorf("open process inventory: %w", err)
+ }
+ defer proc.Close()
+
+ for {
+ entries, readErr := proc.Readdirnames(spoolReadBatch)
+ for _, entry := range entries {
+ pid, parseErr := strconv.ParseUint(entry, 10, 31)
+ if parseErr != nil || pid == 0 {
+ continue
+ }
+ commPath := filepath.Join(procRoot, entry, "comm")
+ comm, openErr := os.OpenFile(commPath, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
+ if openErr != nil {
+ if errors.Is(openErr, os.ErrNotExist) {
+ continue
+ }
+ return false, fmt.Errorf("open process name %s: %w", commPath, openErr)
+ }
+ name, bodyErr := io.ReadAll(io.LimitReader(comm, 65))
+ closeErr := comm.Close()
+ if bodyErr != nil {
+ if errors.Is(bodyErr, os.ErrNotExist) {
+ continue
+ }
+ return false, fmt.Errorf("read process name %s: %w", commPath, bodyErr)
+ }
+ if closeErr != nil {
+ return false, fmt.Errorf("close process name %s: %w", commPath, closeErr)
+ }
+ if len(name) > 64 {
+ return false, fmt.Errorf("process name %s exceeds the expected kernel limit", commPath)
+ }
+ switch strings.TrimSpace(string(name)) {
+ case "cron", "crond", "atd":
+ return true, nil
+ }
+ }
+ if errors.Is(readErr, io.EOF) {
+ return false, nil
+ }
+ if readErr != nil {
+ return false, fmt.Errorf("read process inventory: %w", readErr)
+ }
+ }
+}
+
+// Clear removes and verifies the absence of a personal crontab and every
+// queued at/batch job whose generated owner header carries uid. Callers repeat
+// this around process termination so a process racing to enqueue new work does
+// not survive the fixed-point check.
+func Clear(name string, uid int) error {
+ if !validate.Username(name) {
+ return fmt.Errorf("invalid username %q", name)
+ }
+ if !validate.AccountID(uid) {
+ return fmt.Errorf("invalid Linux account UID %d", uid)
+ }
+ kernelUID := uint32(uid) // #nosec G115 -- AccountID proved uid is in 1..MaxUint32-1.
+ return errors.Join(clearCrontab(name), clearAtJobs(kernelUID))
+}
+
+func clearCrontab(name string) error {
+ if _, err := lookPath("crontab"); err == nil {
+ // The final inventory is authoritative. crontab -r commonly reports an
+ // error when the file was already absent, and a failed response can also
+ // follow a removal that did commit.
+ _, _ = combinedOutput("crontab", []string{"-u", name, "-r"}, commandOptions(queueOutputLimit))
+ absent, err := crontabAbsent(name)
+ if err != nil {
+ return err
+ }
+ if !absent {
+ return fmt.Errorf("personal crontab for %s still exists after removal", name)
+ }
+ }
+ artifacts, err := namedSpoolArtifacts(cronSpoolDirectories, name)
+ if err != nil {
+ return fmt.Errorf("inspect cron spool: %w", err)
+ }
+ if len(artifacts) != 0 {
+ return fmt.Errorf("personal crontab artifacts remain for %s: %v", name, artifacts)
+ }
+ return nil
+}
+
+func crontabAbsent(name string) (bool, error) {
+ // crontab implementations write their normal "no crontab" diagnostic to
+ // stderr. Keep both streams bounded, but retain stderr so absence can be
+ // distinguished from an inventory failure.
+ out, err := combinedOutput("crontab", []string{"-u", name, "-l"}, commandOptions(queueOutputLimit))
+ if err == nil {
+ return false, nil
+ }
+ message := strings.TrimSpace(string(out))
+ if message == "no crontab for "+name ||
+ message == "crontab: no crontab for "+name ||
+ message == "crontab: can't open '"+name+"': No such file or directory" {
+ return true, nil
+ }
+ return false, fmt.Errorf("inventory personal crontab for %s: %w: %s", name, err, message)
+}
+
+func clearAtJobs(uid uint32) error {
+ hasAt := commandAvailable("at")
+ hasAtq := commandAvailable("atq")
+ hasAtrm := commandAvailable("atrm")
+ hasAtd := commandAvailable("atd")
+ hasBatch := commandAvailable("batch")
+ if hasAt || hasAtq || hasAtrm || hasAtd || hasBatch {
+ if !hasAt || !hasAtq || !hasAtrm {
+ return fmt.Errorf("partial at installation cannot safely inventory and remove jobs (at=%t atq=%t atrm=%t atd=%t batch=%t)", hasAt, hasAtq, hasAtrm, hasAtd, hasBatch)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), atInventoryTimeout)
+ defer cancel()
+ jobs, err := inventoryAtJobs(ctx)
+ if err != nil {
+ return err
+ }
+ for _, job := range jobs {
+ if job.uid != uid {
+ continue
+ }
+ if err := removeAtJob(ctx, job.id, job.uid); err != nil {
+ return err
+ }
+ }
+ jobs, err = inventoryAtJobs(ctx)
+ if err != nil {
+ return fmt.Errorf("verify at jobs after removal: %w", err)
+ }
+ for _, job := range jobs {
+ if job.uid == uid {
+ return fmt.Errorf("at job %s for UID %d still exists after removal", job.id, uid)
+ }
+ }
+ }
+ artifacts, err := ownedSpoolArtifacts(atSpoolDirectories, uid)
+ if err != nil {
+ return fmt.Errorf("inspect at spool: %w", err)
+ }
+ if len(artifacts) != 0 {
+ return fmt.Errorf("at job artifacts remain for UID %d: %v", uid, artifacts)
+ }
+ return nil
+}
+
+func commandAvailable(name string) bool {
+ _, err := lookPath(name)
+ return err == nil
+}
+
+type atJob struct {
+ id string
+ uid uint32
+}
+
+func inventoryAtJobs(ctx context.Context) ([]atJob, error) {
+ ids, err := queuedAtJobIDs(ctx)
+ if err != nil {
+ return nil, err
+ }
+ jobs := make([]atJob, 0, len(ids))
+ for _, id := range ids {
+ owner, present, err := readAtJobOwner(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if !present {
+ continue
+ }
+ jobs = append(jobs, atJob{id: id, uid: owner})
+ }
+ return jobs, nil
+}
+
+func queuedAtJobIDs(ctx context.Context) ([]string, error) {
+ opts := commandOptions(queueOutputLimit)
+ opts.Context = ctx
+ out, err := output("atq", nil, opts)
+ if err != nil {
+ return nil, fmt.Errorf("atq: %w", err)
+ }
+ var ids []string
+ seen := make(map[string]bool)
+ scanner := bufio.NewScanner(strings.NewReader(string(out)))
+ scanner.Buffer(make([]byte, 1024), int(queueOutputLimit))
+ lineNo := 0
+ for scanner.Scan() {
+ lineNo++
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) == 0 || !numericJobID(fields[0]) {
+ return nil, fmt.Errorf("parse atq line %d: invalid job id in %q", lineNo, line)
+ }
+ id := fields[0]
+ if seen[id] {
+ return nil, fmt.Errorf("parse atq line %d: duplicate job id %s", lineNo, id)
+ }
+ seen[id] = true
+ ids = append(ids, id)
+ if len(ids) > maxAtJobs {
+ return nil, fmt.Errorf("at queue contains more than %d inspectable jobs", maxAtJobs)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("parse atq: %w", err)
+ }
+ return ids, nil
+}
+
+func parseAtOwner(body []byte) (uint32, error) {
+ scanner := bufio.NewScanner(strings.NewReader(string(body)))
+ scanner.Buffer(make([]byte, 1024), int(atOwnerProbeLimit))
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 2 || fields[0] != "#" || fields[1] != "atrun" {
+ continue
+ }
+ // at writes this owner header in its root-controlled prologue before the
+ // submitted command body. Return on the first atrun header: a user may put
+ // an identical-looking comment in that body, but it must not make an
+ // unrelated queue entry poison the complete root inventory.
+ if len(fields) != 4 || !strings.HasPrefix(fields[2], "uid=") ||
+ !strings.HasPrefix(fields[3], "gid=") {
+ return 0, fmt.Errorf("invalid atrun owner header")
+ }
+ uid, err := parseKernelID(strings.TrimPrefix(fields[2], "uid="))
+ if err != nil {
+ return 0, fmt.Errorf("invalid atrun UID %q", fields[2])
+ }
+ if _, err := parseKernelID(strings.TrimPrefix(fields[3], "gid=")); err != nil {
+ return 0, fmt.Errorf("invalid atrun GID %q", fields[3])
+ }
+ return uid, nil
+ }
+ if err := scanner.Err(); err != nil {
+ return 0, fmt.Errorf("scan at job: %w", err)
+ }
+ return 0, fmt.Errorf("job has no atrun owner header")
+}
+
+func parseKernelID(value string) (uint32, error) {
+ id, err := strconv.ParseUint(value, 10, 32)
+ if err != nil || id == uint64(^uint32(0)) {
+ return 0, fmt.Errorf("invalid kernel ID %q", value)
+ }
+ return uint32(id), nil
+}
+
+func removeAtJob(ctx context.Context, id string, expectedUID uint32) error {
+ if !numericJobID(id) {
+ return fmt.Errorf("invalid at job id %q", id)
+ }
+ if expectedUID == ^uint32(0) {
+ return fmt.Errorf("invalid expected owner for at job %s", id)
+ }
+ opts := commandOptions(queueOutputLimit)
+ opts.Context = ctx
+ // Inventory is only a snapshot and at job IDs are eventually reusable. Bind
+ // deletion to a fresh owner-header read immediately before atrm. If the old
+ // job fired and the ID now names another UID's job, the target job is already
+ // gone and the replacement must be left untouched.
+ owner, present, err := readAtJobOwner(ctx, id)
+ if err != nil {
+ return fmt.Errorf("revalidate at job %s before removal: %w", id, err)
+ }
+ if !present {
+ return nil
+ }
+ if owner != expectedUID {
+ return nil
+ }
+ if out, err := combinedOutput("atrm", []string{id}, opts); err != nil {
+ owner, present, inspectErr := readAtJobOwner(ctx, id)
+ if inspectErr != nil {
+ return errors.Join(
+ fmt.Errorf("remove at job %s: %w: %s", id, err, strings.TrimSpace(string(out))),
+ fmt.Errorf("recheck at job %s: %w", id, inspectErr),
+ )
+ }
+ if !present {
+ return nil
+ }
+ if owner != expectedUID {
+ return nil
+ }
+ return fmt.Errorf("remove at job %s: %w: %s", id, err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// readAtJobOwner reads only the root-controlled prologue emitted by at -c. A
+// local user may submit an arbitrarily large command body, but the first atrun
+// owner header appears near the start and is all UID-based cleanup needs.
+func readAtJobOwner(ctx context.Context, id string) (uint32, bool, error) {
+ if !numericJobID(id) {
+ return 0, false, fmt.Errorf("invalid at job id %q", id)
+ }
+ opts := commandOptions(atOwnerProbeLimit)
+ opts.Context = ctx
+ prefix, err := output("at", []string{"-c", id}, opts)
+ if err == nil || errors.Is(err, executil.ErrOutputLimit) {
+ owner, ownerErr := parseAtOwner(prefix)
+ if ownerErr == nil {
+ return owner, true, nil
+ }
+ if err != nil {
+ return 0, false, errors.Join(
+ fmt.Errorf("read owner probe for at job %s: %w", id, err),
+ fmt.Errorf("parse owner of at job %s: %w", id, ownerErr),
+ )
+ }
+ return 0, false, fmt.Errorf("parse owner of at job %s: %w", id, ownerErr)
+ }
+ queued, queueErr := atJobQueued(ctx, id)
+ if queueErr != nil {
+ return 0, false, errors.Join(
+ fmt.Errorf("read at job %s: %w", id, err),
+ fmt.Errorf("recheck at job %s: %w", id, queueErr),
+ )
+ }
+ if !queued {
+ return 0, false, nil
+ }
+ return 0, false, fmt.Errorf("read at job %s: %w", id, err)
+}
+
+func atJobQueued(ctx context.Context, id string) (bool, error) {
+ ids, err := queuedAtJobIDs(ctx)
+ if err != nil {
+ return false, err
+ }
+ for _, queued := range ids {
+ if queued == id {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func numericJobID(id string) bool {
+ // at/atq emit canonical positive decimal identifiers. Bound the textual
+ // form before it can become a helper argument, and reject aliases such as
+ // leading-zero spellings of the same queue entry.
+ if id == "" || len(id) > 20 || id[0] == '0' {
+ return false
+ }
+ for _, r := range id {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func namedSpoolArtifacts(directories []string, name string) ([]string, error) {
+ var artifacts []string
+ for _, directory := range directories {
+ fd, err := unix.Open(directory, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if errors.Is(err, unix.ENOENT) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", directory, err)
+ }
+ var st unix.Stat_t
+ err = unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW)
+ closeErr := unix.Close(fd)
+ if err == nil {
+ artifacts = append(artifacts, filepath.Join(directory, name))
+ } else if !errors.Is(err, unix.ENOENT) {
+ inspectErr := fmt.Errorf("inspect %s: %w", filepath.Join(directory, name), err)
+ if closeErr != nil {
+ return nil, errors.Join(inspectErr, fmt.Errorf("close %s: %w", directory, closeErr))
+ }
+ return nil, inspectErr
+ }
+ if closeErr != nil {
+ return nil, fmt.Errorf("close %s: %w", directory, closeErr)
+ }
+ }
+ return artifacts, nil
+}
+
+func ownedSpoolArtifacts(directories []string, uid uint32) ([]string, error) {
+ var artifacts []string
+ for _, directory := range directories {
+ fd, err := unix.Open(directory, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
+ if errors.Is(err, unix.ENOENT) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", directory, err)
+ }
+ dir := os.NewFile(uintptr(fd), directory)
+ if dir == nil {
+ _ = unix.Close(fd)
+ return nil, fmt.Errorf("adopt directory descriptor for %s", directory)
+ }
+ count := 0
+ for {
+ entries, readErr := dir.ReadDir(spoolReadBatch)
+ for _, entry := range entries {
+ count++
+ if count > maxAtJobs+1 {
+ _ = dir.Close()
+ return nil, fmt.Errorf("spool %s contains more than %d inspectable entries", directory, maxAtJobs+1)
+ }
+ name := entry.Name()
+ if name == "" || filepath.Base(name) != name {
+ _ = dir.Close()
+ return nil, fmt.Errorf("unsafe spool entry name %q in %s", name, directory)
+ }
+ var st unix.Stat_t
+ if err := unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW); errors.Is(err, unix.ENOENT) {
+ continue
+ } else if err != nil {
+ _ = dir.Close()
+ return nil, fmt.Errorf("inspect %s: %w", filepath.Join(directory, name), err)
+ }
+ if st.Uid == uid {
+ artifacts = append(artifacts, filepath.Join(directory, name))
+ }
+ }
+ if errors.Is(readErr, io.EOF) {
+ break
+ }
+ if readErr != nil {
+ _ = dir.Close()
+ return nil, fmt.Errorf("read %s: %w", directory, readErr)
+ }
+ if len(entries) == 0 {
+ _ = dir.Close()
+ return nil, fmt.Errorf("read %s made no progress", directory)
+ }
+ }
+ if err := dir.Close(); err != nil {
+ return nil, fmt.Errorf("close %s: %w", directory, err)
+ }
+ }
+ return artifacts, nil
+}
diff --git a/internal/userjobs/jobs_test.go b/internal/userjobs/jobs_test.go
new file mode 100644
index 0000000..d4a9c7f
--- /dev/null
+++ b/internal/userjobs/jobs_test.go
@@ -0,0 +1,682 @@
+package userjobs
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/xxvcc/linux-temp-admin/internal/executil"
+)
+
+func isolateJobHelpers(t *testing.T) {
+ t.Helper()
+ oldLook, oldCombinedOutput, oldOutput := lookPath, combinedOutput, output
+ oldCron, oldAt, oldSleep, oldProcRoot := cronSpoolDirectories, atSpoolDirectories, drainSleep, drainProcRoot
+ t.Cleanup(func() {
+ lookPath, combinedOutput, output = oldLook, oldCombinedOutput, oldOutput
+ cronSpoolDirectories, atSpoolDirectories, drainSleep, drainProcRoot = oldCron, oldAt, oldSleep, oldProcRoot
+ })
+ root := t.TempDir()
+ cronDir := filepath.Join(root, "cron")
+ atDir := filepath.Join(root, "at")
+ if err := os.Mkdir(cronDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(atDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ procDir := filepath.Join(root, "proc")
+ if err := os.Mkdir(procDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ cronSpoolDirectories = []string{cronDir}
+ atSpoolDirectories = []string{atDir}
+ drainProcRoot = procDir
+}
+
+func TestKnownCronSpoolLayouts(t *testing.T) {
+ want := []string{
+ "/var/spool/cron/crontabs",
+ "/var/spool/cron",
+ "/var/spool/cron/tabs",
+ }
+ if !reflect.DeepEqual(cronSpoolDirectories, want) {
+ t.Fatalf("cronSpoolDirectories = %v, want %v", cronSpoolDirectories, want)
+ }
+}
+
+func TestClearRejectsInvalidUIDBeforeInspectingJobs(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(string) (string, error) {
+ t.Fatal("invalid UID reached deferred-job tooling")
+ return "", nil
+ }
+ invalidUIDs := []int{-1, 0}
+ if strconv.IntSize >= 64 {
+ reservedKernelID := uint64(^uint32(0))
+ reserved := int(reservedKernelID)
+ invalidUIDs = append(invalidUIDs, reserved, reserved+1)
+ }
+ for _, uid := range invalidUIDs {
+ if err := Clear("xxvcc-a1", uid); err == nil || !strings.Contains(err.Error(), "invalid Linux account UID") {
+ t.Fatalf("Clear uid=%d error = %v, want account-UID refusal", uid, err)
+ }
+ }
+}
+
+func TestClearRemovesCrontabAndOnlyMatchingUIDAtJobs(t *testing.T) {
+ isolateJobHelpers(t)
+ const name = "xxvcc-a1"
+ cronPresent := true
+ jobs := map[string]int{"7": 1001, "8": 2002}
+ lookPath = func(command string) (string, error) {
+ switch command {
+ case "crontab", "at", "atq", "atrm":
+ return "/mock/" + command, nil
+ default:
+ return "", exec.ErrNotFound
+ }
+ }
+ output = func(command string, args []string, _ executil.Options) ([]byte, error) {
+ switch command {
+ case "atq":
+ ids := make([]string, 0, len(jobs))
+ for id := range jobs {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return []byte(strings.Join(ids, " queued\n") + map[bool]string{true: " queued\n", false: ""}[len(ids) > 0]), nil
+ case "at":
+ uid, ok := jobs[args[1]]
+ if !ok {
+ return nil, errors.New("job disappeared")
+ }
+ return []byte(fmt.Sprintf("#!/bin/sh\n# atrun uid=%d gid=%d\n/bin/true\n", uid, uid)), nil
+ default:
+ return nil, fmt.Errorf("unexpected output command %s", command)
+ }
+ }
+ var removed []string
+ combinedOutput = func(command string, args []string, _ executil.Options) ([]byte, error) {
+ switch command {
+ case "crontab":
+ switch args[2] {
+ case "-r":
+ cronPresent = false
+ return nil, nil
+ case "-l":
+ if cronPresent {
+ return []byte("* * * * * /bin/true\n"), nil
+ }
+ return []byte("no crontab for " + name + "\n"), errors.New("exit 1")
+ default:
+ return nil, fmt.Errorf("unexpected crontab action %q", args[2])
+ }
+ case "atrm":
+ removed = append(removed, args[0])
+ delete(jobs, args[0])
+ return nil, nil
+ default:
+ return nil, fmt.Errorf("unexpected run command %s", command)
+ }
+ }
+
+ if err := Clear(name, 1001); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(removed, []string{"7"}) {
+ t.Fatalf("removed at jobs = %v, want only target job 7", removed)
+ }
+ if !reflect.DeepEqual(jobs, map[string]int{"8": 2002}) {
+ t.Fatalf("unrelated at job changed: %v", jobs)
+ }
+}
+
+func TestClearAtJobsUsesBoundedOwnerProbeForOversizedBodies(t *testing.T) {
+ isolateJobHelpers(t)
+ jobs := map[string]int{"7": 1001, "8": 2002}
+ lookPath = func(command string) (string, error) {
+ switch command {
+ case "at", "atq", "atrm":
+ return "/mock/" + command, nil
+ default:
+ return "", exec.ErrNotFound
+ }
+ }
+ output = func(command string, args []string, opts executil.Options) ([]byte, error) {
+ switch command {
+ case "atq":
+ ids := make([]string, 0, len(jobs))
+ for id := range jobs {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return []byte(strings.Join(ids, " queued\n") + map[bool]string{true: " queued\n", false: ""}[len(ids) > 0]), nil
+ case "at":
+ if opts.MaxOutput != atOwnerProbeLimit {
+ t.Fatalf("at owner probe limit = %d, want %d", opts.MaxOutput, atOwnerProbeLimit)
+ }
+ uid, ok := jobs[args[1]]
+ if !ok {
+ return nil, errors.New("job disappeared")
+ }
+ return []byte(fmt.Sprintf("#!/bin/sh\n# atrun uid=%d gid=%d\n", uid, uid)),
+ fmt.Errorf("%w (%d bytes)", executil.ErrOutputLimit, atOwnerProbeLimit)
+ default:
+ return nil, fmt.Errorf("unexpected output command %s", command)
+ }
+ }
+ combinedOutput = func(command string, args []string, _ executil.Options) ([]byte, error) {
+ if command != "atrm" {
+ return nil, fmt.Errorf("unexpected command %s", command)
+ }
+ delete(jobs, args[0])
+ return nil, nil
+ }
+
+ if err := clearAtJobs(1001); err != nil {
+ t.Fatalf("clearAtJobs rejected an unrelated oversized body: %v", err)
+ }
+ if !reflect.DeepEqual(jobs, map[string]int{"8": 2002}) {
+ t.Fatalf("jobs after cleanup = %v, want only unrelated job 8", jobs)
+ }
+}
+
+func TestCrontabAbsentRetainsStderrDiagnostic(t *testing.T) {
+ for _, diagnostic := range []string{
+ "no crontab for xxvcc-a1",
+ "crontab: can't open 'xxvcc-a1': No such file or directory",
+ } {
+ t.Run(diagnostic, func(t *testing.T) {
+ isolateJobHelpers(t)
+ const name = "xxvcc-a1"
+ bin := t.TempDir()
+ script := filepath.Join(bin, "crontab")
+ body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' %q >&2\nexit 1\n", diagnostic)
+ if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin)
+ combinedOutput = executil.CombinedOutput
+
+ absent, err := crontabAbsent(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !absent {
+ t.Fatal("crontabAbsent = false, want stderr-only absence diagnostic to be recognized")
+ }
+ })
+ }
+}
+
+func TestClearFailsClosedOnPartialOrMalformedAtInventory(t *testing.T) {
+ t.Run("partial tools", func(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ if command == "atq" {
+ return "/mock/atq", nil
+ }
+ return "", exec.ErrNotFound
+ }
+ if err := Clear("xxvcc-a1", 1001); err == nil || !strings.Contains(err.Error(), "partial at installation") {
+ t.Fatalf("Clear error = %v, want partial-installation refusal", err)
+ }
+ })
+
+ t.Run("missing owner header", func(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ if command == "at" || command == "atq" || command == "atrm" {
+ return "/mock/" + command, nil
+ }
+ return "", exec.ErrNotFound
+ }
+ output = func(command string, _ []string, _ executil.Options) ([]byte, error) {
+ if command == "atq" {
+ return []byte("9 queued\n"), nil
+ }
+ return []byte("#!/bin/sh\n/bin/true\n"), nil
+ }
+ combinedOutput = func(string, []string, executil.Options) ([]byte, error) {
+ t.Fatal("malformed job must not be removed without an owner")
+ return nil, nil
+ }
+ if err := Clear("xxvcc-a1", 1001); err == nil || !strings.Contains(err.Error(), "no atrun owner header") {
+ t.Fatalf("Clear error = %v, want missing-owner refusal", err)
+ }
+ })
+}
+
+func TestAtInventoryIgnoresOnlyAConfirmedDisappearance(t *testing.T) {
+ tests := []struct {
+ name string
+ secondQueue string
+ wantErr bool
+ }{
+ {name: "disappeared"},
+ {name: "still queued", secondQueue: "7 queued\n", wantErr: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ isolateJobHelpers(t)
+ queueCalls := 0
+ output = func(command string, _ []string, _ executil.Options) ([]byte, error) {
+ switch command {
+ case "atq":
+ queueCalls++
+ if queueCalls == 1 {
+ return []byte("7 queued\n"), nil
+ }
+ return []byte(test.secondQueue), nil
+ case "at":
+ return nil, errors.New("job changed while reading")
+ default:
+ return nil, fmt.Errorf("unexpected command %s", command)
+ }
+ }
+
+ jobs, err := inventoryAtJobs(context.Background())
+ if test.wantErr {
+ if err == nil || !strings.Contains(err.Error(), "read at job 7") {
+ t.Fatalf("inventoryAtJobs error = %v, want surviving-job refusal", err)
+ }
+ return
+ }
+ if err != nil || len(jobs) != 0 {
+ t.Fatalf("inventoryAtJobs = (%v, %v), want empty inventory after confirmed disappearance", jobs, err)
+ }
+ })
+ }
+}
+
+func TestFailedAtRemovalRequiresConfirmedAbsence(t *testing.T) {
+ tests := []struct {
+ name string
+ queue string
+ wantErr bool
+ }{
+ {name: "gone"},
+ {name: "still queued", queue: "7 queued\n", wantErr: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ isolateJobHelpers(t)
+ atReads := 0
+ combinedOutput = func(command string, args []string, _ executil.Options) ([]byte, error) {
+ if command != "atrm" || !reflect.DeepEqual(args, []string{"7"}) {
+ t.Fatalf("unexpected removal command: %s %v", command, args)
+ }
+ return []byte("job already running\n"), errors.New("exit 1")
+ }
+ output = func(command string, _ []string, _ executil.Options) ([]byte, error) {
+ switch command {
+ case "at":
+ atReads++
+ if atReads == 1 || test.queue != "" {
+ return []byte("# atrun uid=1001 gid=1001\n"), nil
+ }
+ return nil, errors.New("job disappeared")
+ case "atq":
+ return []byte(test.queue), nil
+ default:
+ t.Fatalf("unexpected inventory command: %s", command)
+ return nil, nil
+ }
+ }
+
+ err := removeAtJob(context.Background(), "7", 1001)
+ if test.wantErr {
+ if err == nil || !strings.Contains(err.Error(), "job already running") {
+ t.Fatalf("removeAtJob error = %v, want surviving-job refusal", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("removeAtJob error after confirmed disappearance: %v", err)
+ }
+ })
+ }
+}
+
+func TestClearAtJobsFinalInventoryCatchesANewTargetJob(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ switch command {
+ case "at", "atq", "atrm":
+ return "/mock/" + command, nil
+ default:
+ return "", exec.ErrNotFound
+ }
+ }
+ queueCalls := 0
+ output = func(command string, _ []string, _ executil.Options) ([]byte, error) {
+ switch command {
+ case "atq":
+ queueCalls++
+ if queueCalls == 1 {
+ return nil, nil
+ }
+ return []byte("9 queued\n"), nil
+ case "at":
+ return []byte("# atrun uid=1001 gid=1001\n"), nil
+ default:
+ return nil, fmt.Errorf("unexpected command %s", command)
+ }
+ }
+ combinedOutput = func(string, []string, executil.Options) ([]byte, error) {
+ t.Fatal("a job absent from the initial inventory must not reach removal")
+ return nil, nil
+ }
+
+ err := clearAtJobs(1001)
+ if err == nil || !strings.Contains(err.Error(), "still exists after removal") {
+ t.Fatalf("clearAtJobs error = %v, want final-inventory refusal", err)
+ }
+}
+
+func TestClearAtJobsDoesNotRemoveAReusedJobID(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ switch command {
+ case "at", "atq", "atrm":
+ return "/mock/" + command, nil
+ default:
+ return "", exec.ErrNotFound
+ }
+ }
+ atReads := 0
+ output = func(command string, _ []string, _ executil.Options) ([]byte, error) {
+ switch command {
+ case "atq":
+ return []byte("7 queued\n"), nil
+ case "at":
+ atReads++
+ uid := 1001
+ if atReads > 1 {
+ uid = 2002
+ }
+ return []byte(fmt.Sprintf("# atrun uid=%d gid=%d\n", uid, uid)), nil
+ default:
+ return nil, fmt.Errorf("unexpected command %s", command)
+ }
+ }
+ combinedOutput = func(command string, _ []string, _ executil.Options) ([]byte, error) {
+ if command == "atrm" {
+ t.Fatal("reused at job ID reached atrm after its owner changed")
+ }
+ return nil, fmt.Errorf("unexpected command %s", command)
+ }
+ if err := clearAtJobs(1001); err != nil {
+ t.Fatalf("clearAtJobs treated another UID's replacement job as the target: %v", err)
+ }
+}
+
+func TestClearRefusesAStaleNamedCronSpoolWithoutCrontabTool(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(string) (string, error) { return "", exec.ErrNotFound }
+ path := filepath.Join(cronSpoolDirectories[0], "xxvcc-a1")
+ if err := os.WriteFile(path, []byte("* * * * * /bin/true\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Clear("xxvcc-a1", 1001); err == nil || !strings.Contains(err.Error(), path) {
+ t.Fatalf("Clear error = %v, want stale cron spool refusal", err)
+ }
+}
+
+func TestAtJobIDMustBeCanonicalAndBounded(t *testing.T) {
+ tests := []struct {
+ id string
+ want bool
+ }{
+ {id: "1", want: true},
+ {id: "18446744073709551615", want: true},
+ {id: ""},
+ {id: "0"},
+ {id: "01"},
+ {id: "+1"},
+ {id: "1a"},
+ {id: "184467440737095516150"},
+ }
+ for _, test := range tests {
+ if got := numericJobID(test.id); got != test.want {
+ t.Errorf("numericJobID(%q) = %t, want %t", test.id, got, test.want)
+ }
+ }
+}
+
+func TestParseAtOwnerFailsClosedOnAmbiguousOrInvalidHeaders(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want uint32
+ err string
+ }{
+ {
+ name: "valid",
+ body: "#!/bin/sh\n# atrun uid=1001 gid=2002\n/bin/true\n",
+ want: 1001,
+ },
+ {
+ name: "valid high kernel IDs",
+ body: "# atrun uid=4294967294 gid=4294967294\n",
+ want: 4294967294,
+ },
+ {
+ name: "missing",
+ body: "#!/bin/sh\n/bin/true\n",
+ err: "no atrun owner header",
+ },
+ {
+ name: "user body may repeat owner-shaped comment",
+ body: "# atrun uid=1001 gid=1001\n# atrun uid=2002 gid=2002\n",
+ want: 1001,
+ },
+ {
+ name: "malformed first owner header",
+ body: "# atrun owner is unknown\n# atrun uid=1001 gid=1001\n",
+ err: "invalid atrun owner header",
+ },
+ {
+ name: "negative UID",
+ body: "# atrun uid=-1 gid=1001\n",
+ err: "invalid atrun UID",
+ },
+ {
+ name: "reserved GID",
+ body: "# atrun uid=1001 gid=4294967295\n",
+ err: "invalid atrun GID",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := parseAtOwner([]byte(test.body))
+ if test.err == "" {
+ if err != nil || got != test.want {
+ t.Fatalf("parseAtOwner() = (%d, %v), want (%d, nil)", got, err, test.want)
+ }
+ return
+ }
+ if err == nil || !strings.Contains(err.Error(), test.err) {
+ t.Fatalf("parseAtOwner() error = %v, want %q", err, test.err)
+ }
+ })
+ }
+}
+
+func TestSpoolInspectionRefusesSymlinkedDirectory(t *testing.T) {
+ root := t.TempDir()
+ realDirectory := filepath.Join(root, "real")
+ if err := os.Mkdir(realDirectory, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(root, "link")
+ if err := os.Symlink(realDirectory, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := namedSpoolArtifacts([]string{link}, "xxvcc-a1"); err == nil {
+ t.Fatal("namedSpoolArtifacts accepted a symlinked spool directory")
+ }
+ if _, err := ownedSpoolArtifacts([]string{link}, 1001); err == nil {
+ t.Fatal("ownedSpoolArtifacts accepted a symlinked spool directory")
+ }
+}
+
+func TestNamedSpoolInspectionDoesNotFollowEntrySymlink(t *testing.T) {
+ directory := t.TempDir()
+ entry := filepath.Join(directory, "xxvcc-a1")
+ if err := os.Symlink(filepath.Join(directory, "missing-target"), entry); err != nil {
+ t.Fatal(err)
+ }
+ artifacts, err := namedSpoolArtifacts([]string{directory}, "xxvcc-a1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(artifacts, []string{entry}) {
+ t.Fatalf("namedSpoolArtifacts = %v, want broken symlink reported as %s", artifacts, entry)
+ }
+}
+
+func TestOwnedSpoolInspectionDoesNotFollowEntrySymlink(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("ownership distinction requires root")
+ }
+ root := t.TempDir()
+ directory := filepath.Join(root, "spool")
+ if err := os.Mkdir(directory, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(root, "target")
+ if err := os.WriteFile(target, []byte("job\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(target, 1001, 1001); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, filepath.Join(directory, "job")); err != nil {
+ t.Fatal(err)
+ }
+ owned := filepath.Join(directory, "owned-job")
+ if err := os.WriteFile(owned, []byte("job\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(owned, 1001, 1001); err != nil {
+ t.Fatal(err)
+ }
+ artifacts, err := ownedSpoolArtifacts([]string{directory}, 1001)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(artifacts, []string{owned}) {
+ t.Fatalf("ownedSpoolArtifacts = %v, want only direct UID-owned entry %s", artifacts, owned)
+ }
+}
+
+func TestWaitForDrainCoversACompleteDaemonPollingCycle(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ if command == "crontab" {
+ return "/mock/crontab", nil
+ }
+ return "", exec.ErrNotFound
+ }
+ var slept time.Duration
+ drainSleep = func(delay time.Duration) { slept = delay }
+ if err := WaitForDrain(); err != nil {
+ t.Fatal(err)
+ }
+ if slept != 65*time.Second {
+ t.Fatalf("drain wait = %s, want 65s", slept)
+ }
+}
+
+func TestWaitForDrainReturnsImmediatelyWithoutCronOrAtTools(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(string) (string, error) { return "", exec.ErrNotFound }
+ drainSleep = func(time.Duration) { t.Fatal("WaitForDrain slept without cron or at tooling") }
+ if err := WaitForDrain(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWaitForDrainRecognizesDaemonAndBatchFootprints(t *testing.T) {
+ for _, available := range []string{"cron", "crond", "batch"} {
+ t.Run(available, func(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ if command == available {
+ return "/mock/" + command, nil
+ }
+ return "", exec.ErrNotFound
+ }
+ var slept time.Duration
+ drainSleep = func(delay time.Duration) { slept = delay }
+ if err := WaitForDrain(); err != nil {
+ t.Fatal(err)
+ }
+ if slept != 65*time.Second {
+ t.Fatalf("drain wait = %s, want 65s for %s footprint", slept, available)
+ }
+ })
+ }
+}
+
+func TestWaitForDrainRecognizesRunningDaemonWithoutInstalledTools(t *testing.T) {
+ for _, daemon := range []string{"cron", "crond", "atd"} {
+ t.Run(daemon, func(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(string) (string, error) { return "", exec.ErrNotFound }
+ pidDir := filepath.Join(drainProcRoot, "123")
+ if err := os.Mkdir(pidDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte(daemon+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var slept time.Duration
+ drainSleep = func(delay time.Duration) { slept = delay }
+ if err := WaitForDrain(); err != nil {
+ t.Fatal(err)
+ }
+ if slept != 65*time.Second {
+ t.Fatalf("drain wait = %s, want 65s for running %s", slept, daemon)
+ }
+ })
+ }
+}
+
+func TestWaitForDrainWaitsWhenProcessInventoryIsUnreliable(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(string) (string, error) { return "", exec.ErrNotFound }
+ drainProcRoot = filepath.Join(t.TempDir(), "missing")
+ var slept time.Duration
+ drainSleep = func(delay time.Duration) { slept = delay }
+ if err := WaitForDrain(); err != nil {
+ t.Fatal(err)
+ }
+ if slept != 65*time.Second {
+ t.Fatalf("drain wait = %s, want conservative 65s after process inventory failure", slept)
+ }
+}
+
+func TestClearTreatsBatchOnlyAsAnUnsafePartialAtInstallation(t *testing.T) {
+ isolateJobHelpers(t)
+ lookPath = func(command string) (string, error) {
+ if command == "batch" {
+ return "/mock/batch", nil
+ }
+ return "", exec.ErrNotFound
+ }
+ if err := Clear("xxvcc-a1", 1001); err == nil || !strings.Contains(err.Error(), "partial at installation") {
+ t.Fatalf("Clear with batch-only backend error = %v, want partial-installation refusal", err)
+ }
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 3bceae5..efecfc0 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -47,6 +47,14 @@ func KernelID(id int) bool {
// and every unattended action tied to one require this stronger form.
func AccountID(id int) bool { return id > 0 && KernelID(id) }
+// ManagedHome reports whether home is the exact dedicated path assigned to a
+// newly created temporary account. Keep this check textual and exact: accepting
+// a cleaned or symlink-resolved equivalent would weaken the pathname binding
+// used before recursive cleanup.
+func ManagedHome(user, home string) bool {
+ return Username(user) && home == "/home/"+user
+}
+
// Prefix reports whether s is a valid username prefix.
func Prefix(s string) bool {
return prefixRe.MatchString(s) && !strings.HasSuffix(s, "-") && !strings.HasSuffix(s, "_")
diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go
index d8a382f..3c2b2ab 100644
--- a/internal/validate/validate_test.go
+++ b/internal/validate/validate_test.go
@@ -2,6 +2,7 @@ package validate
import (
"strconv"
+ "strings"
"testing"
)
@@ -59,7 +60,8 @@ func TestKernelAndAccountID(t *testing.T) {
}
}
if strconv.IntSize >= 64 {
- reserved := int(uint64(^uint32(0)))
+ reservedKernelID := uint64(^uint32(0))
+ reserved := int(reservedKernelID)
for _, id := range []int{reserved, reserved + 1} {
if KernelID(id) || AccountID(id) {
t.Errorf("out-of-range/reserved id %d was accepted", id)
@@ -249,3 +251,22 @@ func TestPortAndHours(t *testing.T) {
}
}
}
+
+func TestManagedHomeRequiresExactDedicatedPath(t *testing.T) {
+ for _, home := range []string{"/home/xxvcc-u", "/home/_x"} {
+ user := strings.TrimPrefix(home, "/home/")
+ if !ManagedHome(user, home) {
+ t.Errorf("ManagedHome(%q, %q) = false", user, home)
+ }
+ }
+ for _, tc := range []struct{ user, home string }{
+ {"xxvcc-u", "/srv/xxvcc-u"},
+ {"xxvcc-u", "/home/xxvcc-u/"},
+ {"xxvcc-u", "/home/../home/xxvcc-u"},
+ {"bad:user", "/home/bad:user"},
+ } {
+ if ManagedHome(tc.user, tc.home) {
+ t.Errorf("ManagedHome(%q, %q) accepted an unsafe path", tc.user, tc.home)
+ }
+ }
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index 48c36e4..7317a46 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -1,6 +1,7 @@
// Package version compares X.Y.Z[suffix] version strings, reproducing the bash
// version_gt semantics: numeric major/minor/patch, then a final release ranks
-// above a prerelease (suffix), then suffixes compare lexicographically.
+// above a prerelease (suffix), then suffixes compare naturally with digit runs
+// ordered numerically.
package version
import (
@@ -78,9 +79,10 @@ func compareDecimal(a, b string) int {
return 0
}
-// naturalCompare orders two strings so that runs of digits compare by numeric value
-// (with leading zeros ignored) and everything else compares byte-wise. It returns
-// -1, 0, or 1. This gives "-rc2" < "-rc10" while keeping a stable total order.
+// naturalCompare orders two strings so that runs of digits compare by numeric
+// value and everything else compares byte-wise. Numerically equal digit runs use
+// their original bytes as a tie-breaker, so distinct suffixes such as rc01 and
+// rc1 do not collapse into the same version. It returns -1, 0, or 1.
func naturalCompare(a, b string) int {
ia, ib := 0, 0
for ia < len(a) && ib < len(b) {
@@ -106,6 +108,13 @@ func naturalCompare(a, b string) int {
}
return 1
}
+ rawA, rawB := a[ia:ja], b[ib:jb]
+ if rawA != rawB { // equal numeric value: retain a deterministic total order
+ if rawA < rawB {
+ return -1
+ }
+ return 1
+ }
ia, ib = ja, jb
continue
}
diff --git a/internal/version/version_test.go b/internal/version/version_test.go
index 004d919..116be39 100644
--- a/internal/version/version_test.go
+++ b/internal/version/version_test.go
@@ -17,6 +17,8 @@ func TestGreater(t *testing.T) {
{"1.2.0", "nope", false}, // unparseable older => false
{"1.2.3-rc10", "1.2.3-rc9", true}, // numeric-aware suffix: rc10 > rc9
{"1.2.3-rc9", "1.2.3-rc10", false},
+ {"1.2.3-rc1", "1.2.3-rc01", true}, // equal numeric runs remain distinct and byte-ordered
+ {"1.2.3-rc01", "1.2.3-rc1", false},
{"1.2.3-rc2", "1.2.3-rc2", false}, // identical prerelease is not greater
{"1.2.10", "1.2.9", true}, // numeric core, not lexical
{"1.0.0-beta", "1.0.0-alpha", true}, // non-numeric suffix still compares
diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh
index a9f419d..5de938b 100755
--- a/scripts/publish-release.sh
+++ b/scripts/publish-release.sh
@@ -417,59 +417,272 @@ current_latest_tag() {
return 1
}
-require_latest_exact() {
- local expected=$1 context=$2 actual expected_display actual_display
- actual="$(current_latest_tag)" || return 1
- expected_display=${expected:-}
- actual_display=${actual:-}
- [[ "$actual" == "$expected" ]] \
- || { echo "$context: Latest is $actual_display, expected exactly $expected_display" >&2; return 1; }
+current_latest_release() {
+ local latest state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ latest="$(current_latest_tag)" || return 1
+ if [[ -z "$latest" ]]; then
+ printf '\n'
+ return 0
+ fi
+ state="$(gh_with_timeout api "repos/${REPO}/releases/latest" \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')" \
+ || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_tag" == "$latest" \
+ && "$actual_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ \
+ && "$actual_draft" == false && "$actual_prerelease" == false \
+ && "$actual_immutable" == true && "$actual_id" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "Latest is not one immutable published stable Release: $state" >&2; return 1; }
+ printf '%s %s\n' "$actual_tag" "$actual_id"
+}
+
+require_latest_release_exact() {
+ local expected_tag=$1 expected_id=$2 context=$3 state actual_tag="" actual_id="" extra
+ if [[ -n "$expected_tag" ]]; then
+ [[ "$expected_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ \
+ && "$expected_id" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "$context: invalid expected Latest identity" >&2; return 1; }
+ else
+ [[ -z "$expected_id" ]] \
+ || { echo "$context: an empty Latest tag cannot have a Release identity" >&2; return 1; }
+ fi
+ state="$(current_latest_release)" || return 1
+ if [[ -n "$state" ]]; then
+ read -r actual_tag actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_id" ]] \
+ || { echo "$context: malformed Latest identity: $state" >&2; return 1; }
+ fi
+ [[ "$actual_tag" == "$expected_tag" && "$actual_id" == "$expected_id" ]] \
+ || { echo "$context: Latest is ${actual_tag:-} (${actual_id:-no-id}), expected exactly ${expected_tag:-} (${expected_id:-no-id})" >&2; return 1; }
}
restore_latest_after_failed_promotion() {
- local expected
+ local expected expected_id="" current current_tag current_id extra confirmed_highest
expected="$(highest_stable_release_excluding "$TAG")" \
|| { echo "could not enumerate the stable release to restore" >&2; return 1; }
if [[ -n "$expected" ]]; then
- gh_with_timeout release edit "$expected" --repo "$REPO" --latest \
+ expected_id="$(resolve_published_stable_release_id "$expected")" \
+ || { echo "could not bind $expected to a numeric GitHub Release identity" >&2; return 1; }
+ set_latest_by_release_id "$expected_id" "$expected" true \
|| { echo "could not restore $expected as Latest" >&2; return 1; }
else
- gh_with_timeout release edit "$TAG" --repo "$REPO" --latest=false \
- || { echo "could not clear Latest when no other stable release exists" >&2; return 1; }
+ current="$(current_latest_release)" \
+ || { echo "could not determine whether Latest needs to be cleared" >&2; return 1; }
+ if [[ -n "$current" ]]; then
+ read -r current_tag current_id extra <<<"$current"
+ [[ -z "$extra" && "$current" == "$current_tag $current_id" \
+ && "$current_tag" == "$TAG" && "$current_id" == "$EXPECTED_RELEASE_ID" ]] \
+ || { echo "Latest unexpectedly points to $current while no stable fallback was enumerated" >&2; return 1; }
+ set_latest_by_release_id "$EXPECTED_RELEASE_ID" "$TAG" false \
+ || { echo "could not clear Latest when no other stable release exists" >&2; return 1; }
+ fi
fi
- require_latest_exact "$expected" "Latest restoration failed" || return 1
+ require_latest_release_exact "$expected" "$expected_id" "Latest restoration failed" || return 1
+ confirmed_highest="$(highest_stable_release_excluding "$TAG")" \
+ || { echo "could not re-enumerate stable releases after Latest restoration" >&2; return 1; }
+ [[ "$confirmed_highest" == "$expected" ]] \
+ || { echo "the highest stable release changed during Latest restoration: now ${confirmed_highest:-}, restored ${expected:-}" >&2; return 1; }
echo "restored Latest to ${expected:-}" >&2
}
-release_state() {
- gh_with_timeout release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,tagName \
- --jq '. | (.isDraft|tostring) + " " + (.isPrerelease|tostring) + " " + .tagName'
+initial_release_state() {
+ local records actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ local match_count=0 match_state=""
+ # The tag-specific REST endpoint exposes published releases only. The
+ # authenticated list endpoint also exposes drafts to this write-capable token,
+ # and lets us reject an ambiguous duplicate tag before binding its numeric ID.
+ records="$(gh_with_timeout api --paginate "repos/${REPO}/releases?per_page=100" \
+ --jq '.[] | [.tag_name, (.draft|tostring), (.prerelease|tostring), (.immutable|tostring), (.id|tostring)] | @tsv')" \
+ || return 1
+ if [[ -n "$records" ]]; then
+ while IFS=$'\t' read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra; do
+ [[ -n "$actual_tag" ]] || continue
+ [[ -z "$extra" && "$actual_id" =~ ^[1-9][0-9]*$ \
+ && ( "$actual_draft" == true || "$actual_draft" == false ) \
+ && ( "$actual_prerelease" == true || "$actual_prerelease" == false ) \
+ && ( "$actual_immutable" == true || "$actual_immutable" == false ) ]] \
+ || { echo "GitHub returned a malformed Release while binding tag $TAG" >&2; return 1; }
+ [[ "$actual_tag" == "$TAG" ]] || continue
+ match_count=$((match_count + 1))
+ match_state="$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id"
+ done <<<"$records"
+ fi
+ (( match_count == 1 )) \
+ || { echo "tag $TAG does not resolve to exactly one visible GitHub Release" >&2; return 1; }
+ printf '%s\n' "$match_state"
+}
+
+bound_release_state() {
+ [[ "${EXPECTED_RELEASE_ID:-}" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "initial GitHub Release identity was not bound" >&2; return 1; }
+ gh_with_timeout api "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)'
+}
+
+bind_initial_release() {
+ local state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ state="$(initial_release_state)" || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_id" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "GitHub Release has no valid numeric identity: $state" >&2; return 1; }
+ case "$actual_tag $actual_draft $actual_prerelease $actual_immutable" in
+ "$TAG true false false"|"$TAG true true false") RELEASE_WAS_DRAFT=1 ;;
+ "$TAG false $expected_prerelease true") RELEASE_WAS_DRAFT=0 ;;
+ *)
+ echo "release is neither the expected mutable draft nor the expected immutable published release: $state" >&2
+ return 1
+ ;;
+ esac
+ EXPECTED_RELEASE_ID=$actual_id
+}
+
+require_immutable_release() {
+ local state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ state="$(bound_release_state)" || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_tag" == "$TAG" && "$actual_draft" == false \
+ && "$actual_prerelease" == "$expected_prerelease" && "$actual_immutable" == true \
+ && "$actual_id" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "published release is not the expected immutable GitHub Release: $state" >&2; return 1; }
+ if [[ "$actual_id" != "$EXPECTED_RELEASE_ID" ]]; then
+ echo "immutable GitHub Release identity changed: $actual_id, expected $EXPECTED_RELEASE_ID" >&2
+ return 1
+ fi
}
require_draft() {
- [[ "$(gh_with_timeout release view "$TAG" --repo "$REPO" --json isDraft,tagName --jq '. | (.isDraft|tostring) + " " + .tagName')" == "true $TAG" ]] \
- || { echo "release is no longer the expected draft" >&2; exit 1; }
+ local state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ state="$(bound_release_state)" || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_draft" == true && "$actual_immutable" == false \
+ && "$actual_tag" == "$TAG" && "$actual_id" =~ ^[1-9][0-9]*$ \
+ && ( "$actual_prerelease" == true || "$actual_prerelease" == false ) ]] \
+ || { echo "release is no longer the expected mutable draft: $state" >&2; return 1; }
+ [[ "$actual_id" == "$EXPECTED_RELEASE_ID" ]] \
+ || { echo "draft GitHub Release identity changed: $actual_id, expected $EXPECTED_RELEASE_ID" >&2; return 1; }
+}
+
+publish_bound_release() {
+ local state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ state="$(gh_with_timeout api --method PATCH "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" \
+ -F draft=false -F "prerelease=${expected_prerelease}" -f make_latest=false \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')" \
+ || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_tag" == "$TAG" && "$actual_draft" == false \
+ && "$actual_prerelease" == "$expected_prerelease" \
+ && ( "$actual_immutable" == true || "$actual_immutable" == false ) \
+ && "$actual_id" == "$EXPECTED_RELEASE_ID" ]] \
+ || { echo "GitHub returned an unexpected state after publishing bound Release $EXPECTED_RELEASE_ID: $state" >&2; return 1; }
+}
+
+secure_failed_publication_state() {
+ local state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra rollback_state
+ state="$(bound_release_state)" \
+ || { echo "cannot confirm whether bound Release $EXPECTED_RELEASE_ID became public" >&2; return 1; }
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_tag" == "$TAG" && "$actual_id" == "$EXPECTED_RELEASE_ID" \
+ && ( "$actual_draft" == true || "$actual_draft" == false ) \
+ && ( "$actual_prerelease" == true || "$actual_prerelease" == false ) \
+ && ( "$actual_immutable" == true || "$actual_immutable" == false ) ]] \
+ || { echo "cannot safely classify bound Release after publication failure: $state" >&2; return 1; }
+
+ if [[ "$actual_draft" == true && "$actual_immutable" == false ]]; then
+ echo "bound Release $EXPECTED_RELEASE_ID remained a mutable draft" >&2
+ return 0
+ fi
+ if [[ "$actual_draft" == false && "$actual_immutable" == true \
+ && "$actual_prerelease" == "$expected_prerelease" ]]; then
+ echo "bound Release $EXPECTED_RELEASE_ID is already immutable; leaving it unannounced" >&2
+ return 0
+ fi
+ if [[ "$actual_draft" != false || "$actual_immutable" != false ]]; then
+ echo "bound Release has an unsafe state that cannot be rolled back automatically: $state" >&2
+ return 1
+ fi
+
+ rollback_state="$(gh_with_timeout api --method PATCH "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" \
+ -F draft=true -F "prerelease=${expected_prerelease}" -f make_latest=false \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')" \
+ || { echo "could not return mutable Release $EXPECTED_RELEASE_ID to draft" >&2; return 1; }
+ [[ "$rollback_state" == "$TAG true $expected_prerelease false $EXPECTED_RELEASE_ID" ]] \
+ || { echo "GitHub returned an unexpected state after mutable-release rollback: $rollback_state" >&2; return 1; }
+ require_draft || return 1
+ echo "returned mutable Release $EXPECTED_RELEASE_ID to draft after publication failure" >&2
+}
+
+resolve_published_stable_release_id() {
+ local tag=$1 state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \
+ || { echo "cannot resolve non-canonical stable tag: $tag" >&2; return 1; }
+ state="$(gh_with_timeout api "repos/${REPO}/releases/tags/${tag}" \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')" \
+ || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_tag" == "$tag" && "$actual_draft" == false && "$actual_prerelease" == false \
+ && "$actual_immutable" == true \
+ && "$actual_id" =~ ^[1-9][0-9]*$ ]] \
+ || { echo "stable tag $tag does not resolve to one published GitHub Release: $state" >&2; return 1; }
+ printf '%s\n' "$actual_id"
+}
+
+set_latest_by_release_id() {
+ local release_id=$1 tag=$2 make_latest=$3 state actual_tag actual_draft actual_prerelease actual_immutable actual_id extra
+ [[ "$release_id" =~ ^[1-9][0-9]*$ && "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ \
+ && ( "$make_latest" == true || "$make_latest" == false ) ]] \
+ || { echo "invalid bound Release identity for Latest update" >&2; return 1; }
+ state="$(gh_with_timeout api --method PATCH "repos/${REPO}/releases/${release_id}" \
+ -f "make_latest=${make_latest}" \
+ --jq '. | (.tag_name|tostring) + " " + (.draft|tostring) + " " + (.prerelease|tostring) + " " + (.immutable|tostring) + " " + (.id|tostring)')" \
+ || return 1
+ read -r actual_tag actual_draft actual_prerelease actual_immutable actual_id extra <<<"$state"
+ [[ -z "$extra" && "$state" == "$actual_tag $actual_draft $actual_prerelease $actual_immutable $actual_id" \
+ && "$actual_tag" == "$tag" && "$actual_draft" == false && "$actual_prerelease" == false \
+ && "$actual_immutable" == true \
+ && "$actual_id" == "$release_id" ]] \
+ || { echo "GitHub returned an unexpected state after Latest update: $state" >&2; return 1; }
}
require_remote_tag_object() {
[[ "$(gh_with_timeout api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')" == "$tag_object" ]] \
|| { echo "GitHub tag object changed during publication" >&2; exit 1; }
}
remote_asset_names() {
- gh_with_timeout release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort
+ gh_with_timeout api --paginate "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?per_page=100" \
+ --jq '.[].name' | LC_ALL=C sort
}
-require_initial_remote_assets() {
+require_recoverable_draft_assets() {
local got
- got="$(remote_asset_names)"
+ got="$(remote_asset_names)" || return 1
printf '%s\n' "$got" | awk '
$0 == "SHA256SUMS" || $0 == "linux-temp-admin-linux-amd64" ||
$0 == "linux-temp-admin-linux-amd64.sig" || $0 == "linux-temp-admin-linux-arm64" ||
- $0 == "linux-temp-admin-linux-arm64.sig" { seen[$0]=1; next }
+ $0 == "linux-temp-admin-linux-arm64.sig" {
+ if (seen[$0]++) invalid=1
+ next
+ }
{ invalid=1 }
END {
- if (invalid || !seen["SHA256SUMS"] || !seen["linux-temp-admin-linux-amd64"] ||
- !seen["linux-temp-admin-linux-arm64"]) exit 1
+ core=seen["SHA256SUMS"] + seen["linux-temp-admin-linux-amd64"]
+ core += seen["linux-temp-admin-linux-arm64"]
+ signatures=seen["linux-temp-admin-linux-amd64.sig"]
+ signatures += seen["linux-temp-admin-linux-arm64.sig"]
+ total=core + signatures
+ staged=(total == 3 && core == 3)
+ interrupted=(total == 4 || total == 5)
+ if (invalid || (!staged && !interrupted)) exit 1
}
- ' || { echo "release is missing a core unsigned asset or contains an unexpected asset" >&2; printf '%s\n' "$got" >&2; exit 1; }
+ ' || {
+ echo "draft assets are neither the staged set nor a recoverable one-asset interruption" >&2
+ printf '%s\n' "$got" >&2
+ return 1
+ }
}
require_exact_signed_assets() {
local got expected
@@ -480,8 +693,8 @@ require_exact_signed_assets() {
}
require_remote_asset_digests() {
local got expected name digest size
- got="$(gh_with_timeout release view "$TAG" --repo "$REPO" --json assets \
- --jq '.assets[] | [.name, (.digest // ""), (.size|tostring)] | @tsv' | LC_ALL=C sort)"
+ got="$(gh_with_timeout api --paginate "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?per_page=100" \
+ --jq '.[] | [.name, (.digest // ""), (.size|tostring)] | @tsv' | LC_ALL=C sort)"
expected="$({
for name in SHA256SUMS linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \
linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig; do
@@ -495,8 +708,8 @@ require_remote_asset_digests() {
}
download_draft_asset() {
local name=$1 max=$2 out=$3 record advertised_size api_url blocks actual_size
- record="$(gh_with_timeout release view "$TAG" --repo "$REPO" --json assets \
- --jq ".assets[] | select(.name == \"$name\") | [(.size|tostring), .apiUrl] | @tsv")"
+ record="$(gh_with_timeout api --paginate "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?per_page=100" \
+ --jq ".[] | select(.name == \"$name\") | [(.size|tostring), .url] | @tsv")"
IFS=$'\t' read -r advertised_size api_url <<<"$record"
[[ "$advertised_size" =~ ^[0-9]+$ && "$advertised_size" -gt 0 && "$advertised_size" -le "$max" ]] \
|| { echo "invalid or oversized advertised draft asset: $name" >&2; return 1; }
@@ -509,32 +722,121 @@ download_draft_asset() {
[[ "$actual_size" -eq "$advertised_size" && "$actual_size" -le "$max" ]] \
|| { echo "draft asset size changed during download: $name" >&2; return 1; }
}
+
+replace_bound_draft_assets() {
+ local records name asset_id api_url extra upload_template upload_endpoint result
+ local actual_id actual_name actual_size actual_url expected_size record_count=0 core_count=0
+ local -A seen_names=() seen_ids=()
+ records="$(gh_with_timeout api --paginate "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets?per_page=100" \
+ --jq '.[] | [.name, (.id|tostring), .url] | @tsv' | LC_ALL=C sort)" || return 1
+ if [[ -n "$records" ]]; then
+ while IFS=$'\t' read -r name asset_id api_url extra; do
+ case "$name" in
+ SHA256SUMS|linux-temp-admin-linux-amd64|linux-temp-admin-linux-arm64)
+ core_count=$((core_count + 1))
+ ;;
+ linux-temp-admin-linux-amd64.sig|linux-temp-admin-linux-arm64.sig) ;;
+ *) echo "bound draft contains an unexpected asset: $name" >&2; return 1 ;;
+ esac
+ [[ -z "$extra" && "$asset_id" =~ ^[1-9][0-9]*$ \
+ && "$api_url" == "https://api.github.com/repos/${REPO}/releases/assets/${asset_id}" ]] \
+ || { echo "bound draft asset has invalid identity: $name $asset_id $api_url" >&2; return 1; }
+ [[ -z "${seen_names[$name]+present}" && -z "${seen_ids[$asset_id]+present}" ]] \
+ || { echo "bound draft repeats an asset name or identity: $name $asset_id" >&2; return 1; }
+ seen_names[$name]=1
+ seen_ids[$asset_id]=1
+ record_count=$((record_count + 1))
+ done <<<"$records"
+ fi
+ (( (record_count == 3 && core_count == 3) || record_count == 4 || record_count == 5 )) \
+ || { echo "bound draft is neither the staged set nor a recoverable one-asset interruption" >&2; return 1; }
+
+ upload_template="$(gh_with_timeout api "repos/${REPO}/releases/${EXPECTED_RELEASE_ID}" --jq '.upload_url')" \
+ || return 1
+ [[ "$upload_template" == "https://uploads.github.com/repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets{?name,label}" ]] \
+ || { echo "bound Release returned an unexpected upload URL: $upload_template" >&2; return 1; }
+ upload_endpoint="https://uploads.github.com/repos/${REPO}/releases/${EXPECTED_RELEASE_ID}/assets"
+
+ # Fill every absent asset first. Once the set is complete, each old asset is
+ # deleted immediately before its replacement, so an interrupted run can leave
+ # at most one of the five final assets absent and is safe to resume.
+ for name in linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \
+ linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig SHA256SUMS; do
+ [[ -z "${seen_names[$name]+present}" ]] || continue
+ require_draft || return 1
+ expected_size="$(wc -c < "$BUNDLE_DIR/$name")"
+ result="$(gh_with_timeout api --method POST -H 'Content-Type: application/octet-stream' \
+ "${upload_endpoint}?name=${name}" --input "$BUNDLE_DIR/$name" \
+ --jq '[.id, .name, (.size|tostring), .url] | @tsv')" \
+ || { echo "could not upload bound draft asset: $name" >&2; return 1; }
+ IFS=$'\t' read -r actual_id actual_name actual_size actual_url extra <<<"$result"
+ [[ -z "$extra" && "$result" == "$actual_id"$'\t'"$actual_name"$'\t'"$actual_size"$'\t'"$actual_url" \
+ && "$actual_id" =~ ^[1-9][0-9]*$ && "$actual_name" == "$name" \
+ && "$actual_size" == "$expected_size" \
+ && "$actual_url" == "https://api.github.com/repos/${REPO}/releases/assets/${actual_id}" ]] \
+ || { echo "GitHub returned an unexpected uploaded asset identity: $result" >&2; return 1; }
+ require_draft || return 1
+ done
+
+ # Re-list the bound draft before the first deletion. This turns a stale or
+ # misleading upload response into a non-destructive failure.
+ require_exact_signed_assets \
+ || { echo "bound draft is not complete before destructive asset replacement" >&2; return 1; }
+ [[ -n "$records" ]] || return 0
+ while IFS=$'\t' read -r name asset_id api_url extra; do
+ require_draft || return 1
+ gh_with_timeout api --method DELETE "repos/${REPO}/releases/assets/${asset_id}" --silent \
+ || { echo "could not delete bound draft asset $name ($asset_id)" >&2; return 1; }
+ require_draft || return 1
+ expected_size="$(wc -c < "$BUNDLE_DIR/$name")"
+ result="$(gh_with_timeout api --method POST -H 'Content-Type: application/octet-stream' \
+ "${upload_endpoint}?name=${name}" --input "$BUNDLE_DIR/$name" \
+ --jq '[.id, .name, (.size|tostring), .url] | @tsv')" \
+ || { echo "could not upload replacement for bound draft asset: $name" >&2; return 1; }
+ IFS=$'\t' read -r actual_id actual_name actual_size actual_url extra <<<"$result"
+ [[ -z "$extra" && "$result" == "$actual_id"$'\t'"$actual_name"$'\t'"$actual_size"$'\t'"$actual_url" \
+ && "$actual_id" =~ ^[1-9][0-9]*$ && "$actual_name" == "$name" \
+ && "$actual_size" == "$expected_size" \
+ && "$actual_url" == "https://api.github.com/repos/${REPO}/releases/assets/${actual_id}" ]] \
+ || { echo "GitHub returned an unexpected replacement asset identity: $result" >&2; return 1; }
+ require_draft || return 1
+ done <<<"$records"
+}
if [[ "$TAG" == *-* ]]; then
expected_prerelease=true
else
expected_prerelease=false
fi
-REMOTE_RELEASE_STATE="$(release_state)"
-case "$REMOTE_RELEASE_STATE" in
- "true false $TAG"|"true true $TAG") RELEASE_WAS_DRAFT=1 ;;
- "false $expected_prerelease $TAG") RELEASE_WAS_DRAFT=0 ;;
- *)
- echo "release is neither the expected draft nor an exactly matching published release: $REMOTE_RELEASE_STATE" >&2
- exit 1
- ;;
-esac
+bind_initial_release
+readonly EXPECTED_RELEASE_ID RELEASE_WAS_DRAFT
BASELINE_HIGHEST_TAG="$(highest_stable_release_excluding "$TAG")"
-BASELINE_LATEST_TAG="$(current_latest_tag)"
+BASELINE_HIGHEST_ID=""
+if [[ -n "$BASELINE_HIGHEST_TAG" ]]; then
+ BASELINE_HIGHEST_ID="$(resolve_published_stable_release_id "$BASELINE_HIGHEST_TAG")"
+fi
+BASELINE_LATEST_RELEASE="$(current_latest_release)"
+BASELINE_LATEST_TAG=""
+BASELINE_LATEST_ID=""
+if [[ -n "$BASELINE_LATEST_RELEASE" ]]; then
+ read -r BASELINE_LATEST_TAG BASELINE_LATEST_ID BASELINE_LATEST_EXTRA <<<"$BASELINE_LATEST_RELEASE"
+ [[ -z "$BASELINE_LATEST_EXTRA" \
+ && "$BASELINE_LATEST_RELEASE" == "$BASELINE_LATEST_TAG $BASELINE_LATEST_ID" ]] \
+ || { echo "could not bind the initial Latest Release identity: $BASELINE_LATEST_RELEASE" >&2; exit 1; }
+fi
RESUMING_ALREADY_LATEST=0
if [[ "$TAG" != *-* && "$RELEASE_WAS_DRAFT" -eq 0 && "$BASELINE_LATEST_TAG" == "$TAG" ]]; then
+ [[ "$BASELINE_LATEST_ID" == "$EXPECTED_RELEASE_ID" ]] \
+ || { echo "Latest tag $TAG points to Release $BASELINE_LATEST_ID, expected bound Release $EXPECTED_RELEASE_ID" >&2; exit 1; }
# A previous run completed the promotion. Verification failures during this
# read-only resume must not demote a release that was already Latest at entry.
RESUMING_ALREADY_LATEST=1
else
- require_latest_exact "$BASELINE_HIGHEST_TAG" "invalid publication baseline"
+ require_latest_release_exact "$BASELINE_HIGHEST_TAG" "$BASELINE_HIGHEST_ID" "invalid publication baseline"
fi
+readonly BASELINE_HIGHEST_TAG BASELINE_HIGHEST_ID BASELINE_LATEST_RELEASE \
+ BASELINE_LATEST_TAG BASELINE_LATEST_ID
if [[ "$TAG" != *-* && -n "$BASELINE_HIGHEST_TAG" ]]; then
stable_tag_gt "$TAG" "$BASELINE_HIGHEST_TAG" \
|| { echo "stable release $TAG must be newer than highest other release $BASELINE_HIGHEST_TAG" >&2; exit 1; }
@@ -542,12 +844,9 @@ fi
if (( RELEASE_WAS_DRAFT == 1 )); then
require_draft
- require_initial_remote_assets
+ require_recoverable_draft_assets
echo ">> [publish 1/4] replace draft with the exact signed bytes"
- gh_with_timeout release upload "$TAG" --repo "$REPO" --clobber \
- "$BUNDLE_DIR/linux-temp-admin-linux-amd64" "$BUNDLE_DIR/linux-temp-admin-linux-amd64.sig" \
- "$BUNDLE_DIR/linux-temp-admin-linux-arm64" "$BUNDLE_DIR/linux-temp-admin-linux-arm64.sig" \
- "$BUNDLE_DIR/SHA256SUMS"
+ replace_bound_draft_assets
require_draft
require_exact_signed_assets
mkdir "$work/draft"
@@ -565,16 +864,21 @@ if (( RELEASE_WAS_DRAFT == 1 )); then
require_exact_signed_assets
require_remote_asset_digests
require_remote_tag_object
- require_latest_exact "$BASELINE_HIGHEST_TAG" "Latest changed during publication preparation"
- if [[ "$TAG" == *-* ]]; then
- gh_with_timeout release edit "$TAG" --repo "$REPO" --draft=false --prerelease --latest=false
- else
+ require_latest_release_exact "$BASELINE_HIGHEST_TAG" "$BASELINE_HIGHEST_ID" \
+ "Latest changed during publication preparation"
+ if [[ "$TAG" != *-* ]]; then
# Keep the new stable version off Latest until its public versioned route
# has passed byte-for-byte and signature verification. Set the restoration
# guard before the call because a failed response can still follow an
# applied server-side mutation.
LATEST_PROMOTION_ATTEMPTED=1
- gh_with_timeout release edit "$TAG" --repo "$REPO" --draft=false --prerelease=false --latest=false
+ fi
+ if ! publish_bound_release; then
+ echo "publishing bound Release $EXPECTED_RELEASE_ID failed or returned an invalid response" >&2
+ if ! secure_failed_publication_state; then
+ echo "CRITICAL: could not confirm or roll back the failed publication; keep it unannounced" >&2
+ fi
+ exit 1
fi
else
echo ">> [publish 1/4] resume exactly matching published release (no asset mutation)"
@@ -583,8 +887,13 @@ else
echo ">> [publish 2/4] published state already present; continue independent verification"
fi
-[[ "$(release_state)" == "false $expected_prerelease $TAG" ]] \
- || { echo "release did not publish as expected" >&2; exit 1; }
+if ! require_immutable_release; then
+ echo "bound Release $EXPECTED_RELEASE_ID did not become the expected immutable publication" >&2
+ if ! secure_failed_publication_state; then
+ echo "CRITICAL: could not confirm or roll back the mutable publication; keep it unannounced" >&2
+ fi
+ exit 1
+fi
require_remote_tag_object
require_exact_signed_assets
require_remote_asset_digests
@@ -634,7 +943,8 @@ verify_public_set "https://github.com/${REPO}/releases/download/${TAG}" "$work/p
if [[ "$TAG" != *-* ]]; then
echo ">> [publish 4/4] promote and independently verify the stable Latest route"
if (( RESUMING_ALREADY_LATEST == 0 )); then
- require_latest_exact "$BASELINE_HIGHEST_TAG" "Latest changed before final promotion; refusing to overwrite it"
+ require_latest_release_exact "$BASELINE_HIGHEST_TAG" "$BASELINE_HIGHEST_ID" \
+ "Latest changed before final promotion; refusing to overwrite it"
[[ "$(highest_stable_release_excluding "$TAG")" == "$BASELINE_HIGHEST_TAG" ]] \
|| { echo "the stable release baseline changed before final promotion" >&2; exit 1; }
[[ "$(highest_stable_release_excluding "")" == "$TAG" ]] \
@@ -642,7 +952,8 @@ if [[ "$TAG" != *-* ]]; then
# Set this before the mutating call: gh can fail after the server applied
# the update. The EXIT trap must restore even in that ambiguous outcome.
LATEST_PROMOTION_ATTEMPTED=1
- gh_with_timeout release edit "$TAG" --repo "$REPO" --latest
+ require_immutable_release
+ set_latest_by_release_id "$EXPECTED_RELEASE_ID" "$TAG" true
else
echo "resuming a previously promoted $TAG after exact asset verification"
fi
@@ -658,7 +969,8 @@ if [[ "$TAG" != *-* ]]; then
fi
exit 1
fi
- require_latest_exact "$TAG" "published stable release did not become Latest"
+ require_latest_release_exact "$TAG" "$EXPECTED_RELEASE_ID" \
+ "published stable release did not become Latest"
verify_public_set "https://github.com/${REPO}/releases/latest/download" "$work/public-latest"
require_remote_tag_object
require_exact_signed_assets
@@ -674,15 +986,18 @@ if [[ "$TAG" != *-* ]]; then
fi
exit 1
fi
- require_latest_exact "$TAG" "Latest changed during final verification"
+ require_latest_release_exact "$TAG" "$EXPECTED_RELEASE_ID" \
+ "Latest changed during final verification"
else
require_remote_tag_object
require_exact_signed_assets
require_remote_asset_digests
[[ "$(highest_stable_release_excluding "$TAG")" == "$BASELINE_HIGHEST_TAG" ]] \
- || { echo "the stable release set changed while publishing a prerelease" >&2; exit 1; }
- require_latest_exact "$BASELINE_HIGHEST_TAG" "publishing a prerelease unexpectedly changed Latest"
+ || { echo "the highest stable release changed while publishing a prerelease" >&2; exit 1; }
+ require_latest_release_exact "$BASELINE_HIGHEST_TAG" "$BASELINE_HIGHEST_ID" \
+ "publishing a prerelease unexpectedly changed Latest"
echo ">> [publish 4/4] prerelease correctly excluded from Latest verification"
fi
+require_immutable_release
PUBLISH_COMPLETE=1
echo "published and independently verified: $TAG"
From ee95b95d2a52d04fb5ba66b5b970ad3cd17d6bab Mon Sep 17 00:00:00 2001
From: "XXV.CC"
Date: Sat, 1 Aug 2026 01:32:57 +0800
Subject: [PATCH 2/2] fix: close sudo RunAs and CI fixture gaps
---
CHANGELOG.md | 4 +-
internal/cli/cli_test.go | 8 +++
internal/cli/doctor_identity_test.go | 3 ++
internal/cli/revoke_test.go | 3 ++
internal/cli/uninstall_test.go | 1 +
internal/sudoers/sudoers.go | 73 +++++++++++++++++++---------
internal/sudoers/sudoers_test.go | 41 +++++++++++-----
internal/user/user_test.go | 12 ++++-
8 files changed, 106 insertions(+), 39 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ceb257..b1b6b64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -116,7 +116,9 @@ All notable changes to this project are documented here.
Honor sudoers' last-match ordering when checking `sudo -l` output, so a later
`PASSWD: ALL`, restricted PASSWD command, or unparsed command list cannot be
hidden by an earlier `NOPASSWD: ALL` match; only a later exact NOPASSWD grant
- restores the full-policy proof.
+ restores the full-policy proof. Treat numeric UID `#0` as root as sudoers does,
+ and fail closed on dynamic RunAs groups, netgroups, aliases, or malformed
+ entries whose root membership cannot be proven locally.
- Require `chpasswd` during dependency planning only for password-login invites,
so a missing password helper is reported or installed before account creation;
keep it optional for key-only invites and the base `doctor` verdict.
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 74cae09..4df92bb 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -100,8 +100,16 @@ func newTestApp(t *testing.T, in string) (*App, *bytes.Buffer, *bytes.Buffer) {
return a, &out, &errb
}
+func requireRootRegistryFixture(t *testing.T) {
+ t.Helper()
+ if os.Geteuid() != 0 {
+ t.Skip("root-owned Registry fixtures are covered by required Root integration")
+ }
+}
+
func setTestRegistryRecord(t *testing.T, a *App, rec registry.Record) {
t.Helper()
+ requireRootRegistryFixture(t)
dir := t.TempDir()
a.Registry = ®istry.Store{
Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"),
diff --git a/internal/cli/doctor_identity_test.go b/internal/cli/doctor_identity_test.go
index 15d3ec5..304e8ce 100644
--- a/internal/cli/doctor_identity_test.go
+++ b/internal/cli/doctor_identity_test.go
@@ -13,6 +13,7 @@ import (
)
func TestDoctorReportsUnsafeLiveAccountGID(t *testing.T) {
+ requireRootRegistryFixture(t)
const (
name = "xxvcc-a1"
generation = "0123456789abcdef0123456789abcdef"
@@ -45,6 +46,7 @@ func TestDoctorReportsUnsafeLiveAccountGID(t *testing.T) {
}
func TestCompletedAccountIdentityRejectsUnsafeLiveAccountGID(t *testing.T) {
+ requireRootRegistryFixture(t)
const (
name = "xxvcc-a1"
generation = "0123456789abcdef0123456789abcdef"
@@ -148,6 +150,7 @@ func TestStatusReportsAbsentDeletionRecovery(t *testing.T) {
}
func TestDoctorReportsLifecycleMarkerWithoutRegistryRow(t *testing.T) {
+ requireRootRegistryFixture(t)
const (
registered = "xxvcc-registered"
markerOnly = "xxvcc-marker-only"
diff --git a/internal/cli/revoke_test.go b/internal/cli/revoke_test.go
index cb43204..ea9583b 100644
--- a/internal/cli/revoke_test.go
+++ b/internal/cli/revoke_test.go
@@ -223,6 +223,7 @@ func TestInteractiveRevokeBindsConfirmationToAccountGeneration(t *testing.T) {
}
func TestInteractiveLegacyAndUnregisteredDeletionPersistUIDWitnessBeforeUserdel(t *testing.T) {
+ requireRootRegistryFixture(t)
const generation = "0123456789abcdef0123456789abcdef"
for _, tc := range []struct {
name string
@@ -296,6 +297,7 @@ func TestInteractiveLegacyAndUnregisteredDeletionPersistUIDWitnessBeforeUserdel(
}
func TestUnregisteredDeletionWitnessWriteFailureBlocksUserdel(t *testing.T) {
+ requireRootRegistryFixture(t)
const username = "xxvcc-recovery2"
pw := user.Passwd{
Name: username, UID: 1001, GID: 1001,
@@ -332,6 +334,7 @@ func TestUnregisteredDeletionWitnessWriteFailureBlocksUserdel(t *testing.T) {
}
func TestRevokeUnsafeHomeAndGrantFailureStillDisableAndRetainAccount(t *testing.T) {
+ requireRootRegistryFixture(t)
const (
name = "xxvcc-a1"
generation = "0123456789abcdef0123456789abcdef"
diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go
index 20aeea6..f4f8a5e 100644
--- a/internal/cli/uninstall_test.go
+++ b/internal/cli/uninstall_test.go
@@ -73,6 +73,7 @@ func TestUninstallMarkerOnlyPermanentAccountBlocksWithoutDeleteAuthority(t *test
}
func TestUninstallRefusesLiveUIDOnlyRecoveryBeforeAnyMutation(t *testing.T) {
+ requireRootRegistryFixture(t)
const name = "xxvcc-live-recovery"
a, _, errb := newTestApp(t, "")
root := t.TempDir()
diff --git a/internal/sudoers/sudoers.go b/internal/sudoers/sudoers.go
index b0ffd3f..c6b47b9 100644
--- a/internal/sudoers/sudoers.go
+++ b/internal/sudoers/sudoers.go
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"sort"
+ "strconv"
"strings"
"syscall"
"time"
@@ -247,11 +248,10 @@ func verifyNopasswdOutput(out []byte) error {
if endRunas < 2 {
continue
}
- includesRoot, ambiguous := runasRootScope(line[1:endRunas])
- if ambiguous {
- return fmt.Errorf("effective policy has an ambiguous negated RunAs list while verifying root NOPASSWD: ALL")
- }
- if !includesRoot {
+ switch runasRootScope(line[1:endRunas]) {
+ case runasRootAmbiguous:
+ return fmt.Errorf("effective policy has an ambiguous RunAs user list while verifying root NOPASSWD: ALL")
+ case runasRootExcluded:
continue
}
mode, exactAll := allAuthenticationMode(strings.TrimSpace(line[endRunas+1:]))
@@ -277,35 +277,60 @@ func verifyNopasswdOutput(out []byte) error {
return fmt.Errorf("effective policy has no root NOPASSWD: ALL grant")
}
-func runasIncludesRoot(runas string) bool {
- includesRoot, ambiguous := runasRootScope(runas)
- return includesRoot && !ambiguous
-}
+type rootRunasScope uint8
+
+const (
+ runasRootExcluded rootRunasScope = iota
+ runasRootIncluded
+ runasRootAmbiguous
+)
-// runasRootScope reports whether the literal RunAs user list includes root and
-// whether it is too ambiguous to use as policy proof. A positive root/ALL token
-// combined with any exclusion may or may not still apply to root depending on
-// sudoers ordering and alias semantics. The verifier deliberately refuses the
-// complete proof instead of skipping that line and accidentally preserving an
-// earlier NOPASSWD verdict.
-func runasRootScope(runas string) (includesRoot, ambiguous bool) {
+// runasRootScope classifies whether the RunAs user list applies to root. Only
+// literal users and numeric UIDs are decidable without evaluating sudoers
+// aliases, Unix groups, or netgroups. Any dynamic or negated item invalidates
+// the complete policy proof instead of letting an earlier NOPASSWD verdict
+// survive a rule that may apply to root.
+func runasRootScope(runas string) rootRunasScope {
users := runas
if colon := strings.IndexByte(users, ':'); colon >= 0 {
users = users[:colon]
}
- hasExclusion := false
- for _, user := range strings.Split(users, ",") {
- user = strings.TrimSpace(user)
- if strings.HasPrefix(user, "!") {
- hasExclusion = true
- continue
+ if strings.TrimSpace(users) == "" {
+ return runasRootAmbiguous
+ }
+
+ includesRoot := false
+ for _, raw := range strings.Split(users, ",") {
+ entry := strings.TrimSpace(raw)
+ if entry == "" || strings.HasPrefix(entry, "!") {
+ return runasRootAmbiguous
}
- switch user {
+ switch entry {
case "root", "ALL":
includesRoot = true
+ continue
}
+ if strings.HasPrefix(entry, "#") {
+ uid, err := strconv.ParseUint(strings.TrimPrefix(entry, "#"), 10, 32)
+ if err != nil {
+ return runasRootAmbiguous
+ }
+ if uid == 0 {
+ includesRoot = true
+ }
+ continue
+ }
+ if validate.Username(entry) {
+ continue
+ }
+ // User aliases, groups, netgroups, escaped names, and any syntax this
+ // verifier does not fully understand can resolve to root at runtime.
+ return runasRootAmbiguous
+ }
+ if includesRoot {
+ return runasRootIncluded
}
- return includesRoot, includesRoot && hasExclusion
+ return runasRootExcluded
}
// allAuthenticationMode reports the authentication mode of an exact ALL
diff --git a/internal/sudoers/sudoers_test.go b/internal/sudoers/sudoers_test.go
index c6763a3..224a881 100644
--- a/internal/sudoers/sudoers_test.go
+++ b/internal/sudoers/sudoers_test.go
@@ -210,6 +210,7 @@ func TestVerifyNopasswdOutputRequiresRootNopasswdAll(t *testing.T) {
ok bool
}{
{"root", "User alice may run the following commands:\n (root) NOPASSWD: ALL\n", true},
+ {"numeric root", " (#0) NOPASSWD: ALL\n", true},
{"all runas", " (ALL : ALL) NOPASSWD: ALL\n", true},
{"all except root", " (ALL, !root) NOPASSWD: ALL\n", false},
{"root exclusion before all", " (!root, ALL) NOPASSWD: ALL\n", false},
@@ -224,10 +225,15 @@ func TestVerifyNopasswdOutputRequiresRootNopasswdAll(t *testing.T) {
{"later root-applicable exclusion is globally ambiguous", " (root) NOPASSWD: ALL\n (ALL, !daemon) PASSWD: ALL\n", false},
{"restricted root-applicable exclusion is globally ambiguous", " (root) NOPASSWD: ALL\n (ALL, !daemon) PASSWD: /bin/true\n (root) NOPASSWD: ALL\n", false},
{"later restricted passwd invalidates full grant", " (root) NOPASSWD: ALL\n (root) PASSWD: /bin/true\n", false},
+ {"numeric root restricted passwd invalidates full grant", " (root) NOPASSWD: ALL\n (#0) PASSWD: /bin/true\n", false},
{"later command-list passwd all invalidates full grant", " (root) NOPASSWD: ALL\n (root) NOPASSWD: /bin/false, PASSWD: ALL\n", false},
{"exact later grant restores after command list", " (root) NOPASSWD: ALL\n (root) PASSWD: /bin/true\n (root) NOPASSWD: ALL\n", true},
{"later nopasswd all restores", " (root) PASSWD: ALL\n (root) NOPASSWD: ALL\n", true},
{"later non-root rule does not override", " (root) NOPASSWD: ALL\n (daemon) PASSWD: ALL\n", true},
+ {"later non-root uid rule does not override", " (root) NOPASSWD: ALL\n (#1) PASSWD: ALL\n", true},
+ {"dynamic group is ambiguous", " (root) NOPASSWD: ALL\n (%wheel) PASSWD: /bin/true\n", false},
+ {"netgroup is ambiguous", " (root) NOPASSWD: ALL\n (+operators) PASSWD: /bin/true\n", false},
+ {"user alias is ambiguous", " (root) NOPASSWD: ALL\n (OPERATORS) PASSWD: /bin/true\n", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -242,24 +248,33 @@ func TestVerifyNopasswdOutputRequiresRootNopasswdAll(t *testing.T) {
}
}
-func TestRunasIncludesRootFailsClosedOnExclusions(t *testing.T) {
+func TestRunasRootScopeFailsClosedOnDynamicOrNegatedEntries(t *testing.T) {
tests := []struct {
+ name string
runas string
- want bool
+ want rootRunasScope
}{
- {runas: "root", want: true},
- {runas: "ALL : ALL", want: true},
- {runas: "daemon, root", want: true},
- {runas: "daemon"},
- {runas: "ALL, !root"},
- {runas: "!root, ALL"},
- {runas: "root, !ALL"},
- {runas: "ALL, !daemon"},
+ {name: "root", runas: "root", want: runasRootIncluded},
+ {name: "numeric root", runas: "#0", want: runasRootIncluded},
+ {name: "numeric root with leading zeroes", runas: "#000", want: runasRootIncluded},
+ {name: "all users", runas: "ALL : ALL", want: runasRootIncluded},
+ {name: "mixed literal users", runas: "daemon, root", want: runasRootIncluded},
+ {name: "non-root user", runas: "daemon", want: runasRootExcluded},
+ {name: "non-root uid", runas: "#1", want: runasRootExcluded},
+ {name: "root exclusion", runas: "ALL, !root", want: runasRootAmbiguous},
+ {name: "leading exclusion", runas: "!root, ALL", want: runasRootAmbiguous},
+ {name: "all exclusion", runas: "root, !ALL", want: runasRootAmbiguous},
+ {name: "other exclusion", runas: "ALL, !daemon", want: runasRootAmbiguous},
+ {name: "unix group", runas: "%wheel", want: runasRootAmbiguous},
+ {name: "netgroup", runas: "+operators", want: runasRootAmbiguous},
+ {name: "user alias", runas: "OPERATORS", want: runasRootAmbiguous},
+ {name: "malformed uid", runas: "#root", want: runasRootAmbiguous},
+ {name: "empty user list", runas: ": wheel", want: runasRootAmbiguous},
}
for _, tt := range tests {
- t.Run(tt.runas, func(t *testing.T) {
- if got := runasIncludesRoot(tt.runas); got != tt.want {
- t.Fatalf("runasIncludesRoot(%q) = %t, want %t", tt.runas, got, tt.want)
+ t.Run(tt.name, func(t *testing.T) {
+ if got := runasRootScope(tt.runas); got != tt.want {
+ t.Fatalf("runasRootScope(%q) = %d, want %d", tt.runas, got, tt.want)
}
})
}
diff --git a/internal/user/user_test.go b/internal/user/user_test.go
index 5fdcc07..c0dcbe0 100644
--- a/internal/user/user_test.go
+++ b/internal/user/user_test.go
@@ -1861,14 +1861,24 @@ func TestDeleteExpectedRejectsUnboundHomeBeforeHelper(t *testing.T) {
}
func TestValidateHomeRemovalRequiresDedicatedOwnedRealDirectory(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("root-owned Home fixtures are covered by required Root integration")
+ }
+ const testUID, testGID = 2345, 2346
oldRoot := managedHomeRoot
managedHomeRoot = t.TempDir()
t.Cleanup(func() { managedHomeRoot = oldRoot })
+ if err := os.Chown(managedHomeRoot, 0, 0); err != nil {
+ t.Fatal(err)
+ }
home := managedHome("xxvcc-u")
if err := os.Mkdir(home, 0o700); err != nil {
t.Fatal(err)
}
- expected := Passwd{Name: "xxvcc-u", UID: os.Getuid(), GID: os.Getgid(), Home: home}
+ if err := os.Chown(home, testUID, testGID); err != nil {
+ t.Fatal(err)
+ }
+ expected := Passwd{Name: "xxvcc-u", UID: testUID, GID: testGID, Home: home}
if err := validateHomeRemoval(expected); err != nil {
t.Fatalf("safe dedicated home rejected: %v", err)
}