crates/skidbladnir-systemd/src/uninstall.rs
//! **THE UNINSTALL LAW** — `uninstall` ALWAYS kills the DATA and ALWAYS keeps the
//! SSH KEYS, out of **ONE declared data root** that is the same whoever owns the
//! process.
//!
//! > *"`uninstall` WILL ALWAYS AND MUST ALWAYS KILL DATA BUT KEEP ssh keys!"*
//! > *"make the data directory the SAME regardless of who fucking owns it"*
//! > — the owner, 2026-07-27
//!
//! Not behind a flag. Not opt-in. **Not `--purge`.** Plain
//! [`Lifecycle::uninstall`](crate::service::Lifecycle::uninstall) does BOTH halves,
//! every time, in every suite application — because the behaviour lives HERE, in the
//! ship, and every consumer inherits it rather than re-inventing it.
//!
//! ## Why the ship owns this
//!
//! Four applications implemented uninstall four different ways and got it wrong four
//! different ways:
//!
//! | app | the bug |
//! |---|---|
//! | **holger** | `remove_dir_all("/home/holger")` **and then** `userdel` — destroyed the deploy key twice over |
//! | **dwarves** | purged a hardcoded `/home/dwarves` while its resolver used a different path ⇒ a per-user install wiped **nothing** and still printed success |
//! | **korp** | the same two-ladder bug with `/home/korp` |
//! | **nornir** | needed an extra flag (`--purge`) to do the only correct thing, and that flag ALSO took the key |
//!
//! One law, one implementation, one test shape — so a fifth interpretation cannot be
//! invented.
//!
//! ## The two halves
//!
//! 1. **Data DIES.** Everything under the data root: warehouses, registries, caches,
//! job ledgers, workspace trees. After an uninstall the application holds nothing
//! the next install can be confused by.
//! 2. **Keys LIVE.** Any `.ssh` directory, and any stored `token`, survives
//! **byte-identical**. The deploy key is registered on the private git remotes;
//! destroying it turns a "clean slate" into an outage that only a human with
//! access to every repo can end. Destroying it is allowed only through the
//! explicit [`UninstallOptions::wipe_identity`](crate::uninstall::UninstallOptions::wipe_identity) opt-in.
//!
//! ## ONE data root — no `$HOME` ladder, no scope branch
//!
//! [`DataRoot`](crate::uninstall::DataRoot) is **one absolute path, declared on the spec**
//! ([`ServiceSpec::data_root`](crate::service::ServiceSpec::data_root)). It is not
//! derived from `$HOME`, not derived from the CWD, and **not branched on
//! [`Scope`](crate::systemd::Scope)** — the unix account that happens to own the
//! process is orthogonal to where the product keeps its state.
//!
//! [`plan_state_erase`](crate::uninstall::plan_state_erase) and [`execute_state_erase`](crate::uninstall::execute_state_erase) read that ONE declaration. That is
//! the fix for the actual incident: dwarves' resolver and dwarves' eraser were two
//! separate ladders, so the eraser purged a path the resolver never used — wiping
//! nothing while printing success. *If a resolver and an eraser can disagree about
//! where state lives, one of them is lying.*
//!
//! A scope-aware `$HOME` root was tried in dwarves on 2026-07-27 and was a near-miss:
//! under user scope the root resolved to `/home/alice` and the sweep would have
//! taken `.rustup`, `work`, `.njord-secrets`, `.bashrc`, `GolandProjects`. Hence
//! [`DataRoot::new`](crate::uninstall::DataRoot::new): a root that **is** or **contains** a real user's home is
//! refused at DECLARATION time, where the mistake is written, not deep inside the
//! erase.
//!
//! ## The UPPER BOUND — the half that must not depend on a downstream guard
//!
//! This law is deliberately DESTRUCTIVE, so the guarantee that matters most is the
//! ceiling: a sweep must be unable to climb above the data dir. **A plan-time check
//! is not that ceiling.** [`execute_state_erase`](crate::uninstall::execute_state_erase) accepts a [`StatePlan`](crate::uninstall::StatePlan) it did not
//! build, [`StatePlan::sweep`](crate::uninstall::StatePlan::sweep) validates nothing, and
//! [`crate::systemd::purge_service_data_keep_keys`] takes its root VERBATIM — which is
//! the entry point dwarves calls. So [`sweep_root`] re-validates the root immediately
//! before enumerating its children, and that is the guarantee.
//!
//! This file used to claim the downstream delete guard was the backstop —
//! *"`guarded_remove_dir_all` refused every single one"*. **That was true when written
//! and is not true now.** [`EraseOwner::uid_for`](crate::uninstall::EraseOwner) hands the INVOKING uid for a path
//! under the invoking home (correctly — it is the fix for the uid bug that made an
//! uninstall wipe nothing while printing ✓), and
//! [`crate::systemd::guard_chown_target_in`]'s `is_target_accounts_own_home` exception
//! then admits every child. Measured 2026-08-03 by
//! `a_sweep_can_never_climb_into_a_login_home`: with the [`sweep_root`] check removed,
//! a sweep of a fabricated login home really does erase `.bashrc`, `.rustup`, `work`
//! and `GolandProjects`. Two guards that were each believed to cover the other is how
//! a ceiling goes missing.
//!
//! ## Legacy roots — migration, stated out loud
//!
//! A machine installed before the fixed root has state under the old per-user paths
//! (`~/.nornir` was 5.3 GB on this box, `~/.dwarves` 548 K). A fixed root would make
//! those permanent orphans that no future uninstall ever finds, so a spec may declare
//! [`legacy roots`](crate::service::ServiceSpec::legacy_data_root) that an uninstall
//! ALSO sweeps — same law, data dies and keys live — and the report NAMES each one it
//! swept. Nothing is silently migrated and nothing is silently ignored.
//!
//! ## Honesty
//!
//! [`execute_state_erase`](crate::uninstall::execute_state_erase) fills an [`UninstallReport`](crate::uninstall::UninstallReport) naming every path erased,
//! every identity path KEPT, and every refusal. [`UninstallReport::is_noop`](crate::uninstall::UninstallReport::is_noop) is true
//! when nothing at all happened — and a caller that prints
//! [`UninstallReport::summary`](crate::uninstall::UninstallReport::summary) therefore cannot print success over a no-op.
//!
//! Design: `.nornir/uninstall-design.md` · guide: `.nornir/uninstall-guide.md` ·
//! walkthrough: `.nornir/uninstall-for-idiots.md`. The suite-wide statement of the
//! law lives in `workspace_nordisk/.nornir/suite-uninstall-law.md`.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::systemd::{normalize_abs, path_components};
/// Path components that mark **identity**, not data. An entry whose file name is one
/// of these is KEPT by a plain uninstall and is destroyed only under the explicit
/// [`UninstallOptions::wipe_identity`](crate::uninstall::UninstallOptions::wipe_identity) opt-in.
///
/// `.ssh` is additionally refused by the delete primitive itself
/// ([`crate::systemd::guarded_remove_dir_all`] returns
/// [`DeleteRefusal::KeyMaterial`](crate::systemd::DeleteRefusal::KeyMaterial)), so a
/// bug in THIS list still cannot take the deploy key — the list exists so the rest of
/// the identity (a stored `token`, a stray key file) is spared too, and so the report
/// can NAME what it kept.
pub const IDENTITY_NAMES: &[&str] = &[
".ssh",
// The DOTLESS key directory. nornir keeps its deploy key in `<data-root>/ssh`
// (e.g. `~/.nornir/ssh/id_ed25519`), NOT `.ssh` — and `guarded_remove_dir_all`'s
// `KeyMaterial` refusal only triggers on a `.ssh` component, so this name had
// NOTHING standing behind it. Incident 2026-07-28: `dwarves-full --uninstall`
// sweeps `~/.nornir`, and every entry here is matched by EXACT name, so `ssh`
// was classified as data and the deploy key would have been erased — the precise
// outcome the Uninstall Law exists to forbid.
"ssh",
"token",
"id_ed25519",
"id_ed25519.pub",
"id_rsa",
"id_rsa.pub",
"authorized_keys",
"known_hosts",
];
/// Key-file STEMS. A name that starts with one of these is identity whatever follows,
/// so rotation/backup spellings survive too — `id_ed25519.nornir-t14s.bak`,
/// `id_rsa.old`, `id_ecdsa.pub`. Exact-name matching alone kept `id_ed25519` but
/// erased the `.bak` beside it, which is still a destroyed key from the operator's
/// point of view (it is the copy a rotation left behind).
const IDENTITY_STEMS: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
/// Is this file name identity (key material / a stored token) rather than data?
///
/// Matches an exact [`IDENTITY_NAMES`] entry, or any name beginning with an
/// `IDENTITY_STEMS` key stem (so `.pub` / `.bak` / rotation suffixes are spared).
pub fn is_identity_name(name: &std::ffi::OsStr) -> bool {
if IDENTITY_NAMES
.iter()
.any(|n| name == std::ffi::OsStr::new(n))
{
return true;
}
// Non-UTF8 names cannot match a stem; they fall through as data.
match name.to_str() {
Some(s) => IDENTITY_STEMS.iter().any(|stem| s.starts_with(stem)),
None => false,
}
}
/// The minimum data-root depth. `/` (0 components) is never a data root; a
/// one-component root (`/home`, `/var`) is refused by the delete guard anyway, but
/// refusing it HERE means the mistake is caught where it was written.
const MIN_ROOT_COMPONENTS: usize = 2;
// ───────────────────────────── the ONE data root ─────────────────────────────
/// WHY a declared data root was refused. A data root is where a product keeps ALL of
/// its state; naming the wrong one is a configuration error, and this is the error it
/// fails with **at declaration time**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataRootRefusal {
/// Not an absolute path. A relative root resolves against the process CWD — and a
/// systemd unit's `WorkingDirectory=/` makes that the filesystem ROOT.
NotAbsolute,
/// Fewer than `MIN_ROOT_COMPONENTS` components (`/`, `/var`, `/home`).
TooShallow { components: usize },
/// A system directory (`/`, `/etc`, `/usr`, `/var`, `/home`, `/proc`, …).
SystemDir,
/// The path IS a real account's home directory.
IsUserHome { user: String },
/// The path CONTAINS a real account's home directory — a sweep would take it.
ContainsUserHome { user: String, home: PathBuf },
}
impl std::fmt::Display for DataRootRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DataRootRefusal::NotAbsolute => write!(f, "not an absolute path"),
DataRootRefusal::TooShallow { components } => {
write!(
f,
"only {components} component(s) deep — a data root is never that shallow"
)
}
DataRootRefusal::SystemDir => write!(f, "it is a system directory"),
DataRootRefusal::IsUserHome { user } => {
write!(f, "it IS account `{user}`'s home directory")
}
DataRootRefusal::ContainsUserHome { user, home } => write!(
f,
"it CONTAINS account `{user}`'s home directory {} — an uninstall sweep would \
take that user's files",
home.display()
),
}
}
}
/// A REFUSED data root — the named error [`DataRoot::new`](crate::uninstall::DataRoot::new) returns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataRootRefused {
pub path: PathBuf,
pub reason: DataRootRefusal,
}
impl std::fmt::Display for DataRootRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"REFUSING {} as a data root: {} — a product's data root is ONE fixed absolute \
directory it owns entirely, never a user's home and never derived from $HOME",
self.path.display(),
self.reason
)
}
}
impl std::error::Error for DataRootRefused {}
/// **THE one data root** — the single absolute directory a product keeps all of its
/// state in, identical whoever owns the process.
///
/// Constructing one VALIDATES it, so a [`ServiceSpec`](crate::service::ServiceSpec)
/// cannot hold a root that is (or contains) a real user's home, a system directory, a
/// relative path, or something one component deep. That is requirement 3 of the
/// 2026-07-27 correction: the mistake fails where it is written, not deep inside an
/// erase where only a downstream guard stands between it and a human's `~/work`.
///
/// It is deliberately NOT derived from `$HOME`, the CWD, or the install
/// [`Scope`](crate::systemd::Scope). One env override is sanctioned —
/// [`DataRoot::from_env`] — and it feeds this same value, never a second ladder.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DataRoot(PathBuf);
impl DataRoot {
/// Validate `path` against the LIVE system (`/etc/passwd`).
pub fn new(path: impl AsRef<Path>) -> Result<Self, DataRootRefused> {
Self::new_in(path, Path::new("/etc/passwd"))
}
/// Dependency-injected [`DataRoot::new`](crate::uninstall::DataRoot::new) — reads `passwd_path` (a fixture in
/// tests) and nothing else. Pure apart from that one read.
pub fn new_in(path: impl AsRef<Path>, passwd_path: &Path) -> Result<Self, DataRootRefused> {
let path = path.as_ref();
let refuse = |reason| {
Err(DataRootRefused {
path: path.to_path_buf(),
reason,
})
};
let Some(norm) = normalize_abs(path) else {
return refuse(DataRootRefusal::NotAbsolute);
};
if crate::systemd::is_system_dir(&norm) {
return refuse(DataRootRefusal::SystemDir);
}
let n = path_components(&norm);
if n < MIN_ROOT_COMPONENTS {
return refuse(DataRootRefusal::TooShallow { components: n });
}
// IS, or CONTAINS, a real account's home. Being INSIDE one is allowed: a dev
// box may point `<APP>_DATA` at `~/.dwarves`, and the legacy per-user roots a
// migration must still sweep live there too. What is refused is a root that
// would swallow somebody's whole home on the way down.
for (user, _uid, home) in crate::systemd::passwd_homes_of(passwd_path) {
if path_components(&home) < MIN_ROOT_COMPONENTS {
continue;
}
if home == norm {
return refuse(DataRootRefusal::IsUserHome { user });
}
if home.starts_with(&norm) {
return refuse(DataRootRefusal::ContainsUserHome { user, home });
}
}
Ok(DataRoot(norm))
}
/// The ONE sanctioned env override, for dev boxes: read `var` (e.g.
/// `DWARVES_DATA`) and fall back to `default`. Whatever wins is validated and
/// becomes THE declaration both the resolver and the eraser read — it never
/// becomes a second path ladder.
pub fn from_env(var: &str, default: impl AsRef<Path>) -> Result<Self, DataRootRefused> {
match std::env::var_os(var) {
Some(v) if !v.is_empty() => Self::new(PathBuf::from(v)),
_ => Self::new(default),
}
}
/// The validated absolute path.
pub fn path(&self) -> &Path {
&self.0
}
/// Consume into the inner path.
pub fn into_path(self) -> PathBuf {
self.0
}
}
impl std::fmt::Display for DataRoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.display())
}
}
impl AsRef<Path> for DataRoot {
fn as_ref(&self) -> &Path {
&self.0
}
}
// ─────────────────────────────── the input ──────────────────────────────────
/// The neutral slice of a service spec the eraser needs — so the backend-neutral
/// [`ServiceSpec`](crate::service::ServiceSpec) and the systemd-specific
/// [`SystemdServiceSpec`](crate::systemd::SystemdServiceSpec) drive the SAME planner
/// instead of each carrying its own idea of where state lives.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateSpec<'a> {
/// Service name (for messages).
pub service: &'a str,
/// The owning account, when there is one (system installs). Used ONLY to pick the
/// uid the delete guard is told owns the tree — never to resolve a path.
pub user: Option<&'a str>,
/// **THE data root** — one absolute path, the same whoever owns the process.
/// `None` ⇒ the spec declares no data root and the uninstall erases nothing (and
/// says so).
pub data_root: Option<&'a str>,
/// Pre-fixed-root data roots this product used to use, swept by the same law so a
/// machine upgraded from the old layout ends up genuinely clean. Each is reported.
pub legacy_roots: &'a [String],
/// Sub-directories of the data root the service owns. Empty ⇒ sweep every child
/// of the root (the normal case: the root is the product's own directory).
pub state_dirs: &'a [String],
}
// ─────────────────────────────── the plan ───────────────────────────────────
/// WHAT an uninstall will erase — resolved but not yet executed. Pure data, so a
/// consumer (and its test suite) can assert the plan without deleting anything.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EraseMode {
/// Erase every direct child of this root except the identity entries. The normal
/// case: the data root is the product's own directory.
SweepChildren(PathBuf),
/// Erase exactly these paths (each a dir or a file) — a spec that declares
/// `state_dirs` inside a shared root.
Declared(Vec<PathBuf>),
/// Nothing may be erased — the string says WHY. This is a REPORTED outcome, never
/// a silent success.
Refused(String),
}
/// The resolved erase plan: the ONE data root, what to erase inside it, the legacy
/// roots to sweep as well, and every note the resolution produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatePlan {
/// The declared data root (`None` ⇒ none declared or it was refused).
pub root: Option<PathBuf>,
/// What to erase inside the root.
pub mode: EraseMode,
/// Pre-fixed-root roots to sweep as well (migration). Each is swept by the same
/// law: data dies, identity lives.
pub legacy: Vec<PathBuf>,
/// Human-readable resolution notes, surfaced in the report so a no-op explains
/// itself.
pub notes: Vec<String>,
}
impl StatePlan {
/// A plan that erases nothing, carrying the reason.
pub fn refused(reason: impl Into<String>) -> Self {
StatePlan {
root: None,
mode: EraseMode::Refused(reason.into()),
legacy: Vec::new(),
notes: Vec::new(),
}
}
/// A plan that sweeps every child of one root — the shape
/// [`crate::systemd::purge_service_data_keep_keys`] drives.
pub fn sweep(root: impl Into<PathBuf>) -> Self {
let root = root.into();
StatePlan {
root: Some(root.clone()),
mode: EraseMode::SweepChildren(root),
legacy: Vec::new(),
notes: Vec::new(),
}
}
/// Will this plan erase anything at all?
pub fn erases_anything(&self) -> bool {
!self.legacy.is_empty()
|| match &self.mode {
EraseMode::SweepChildren(_) => true,
EraseMode::Declared(v) => !v.is_empty(),
EraseMode::Refused(_) => false,
}
}
}
/// Resolve the erase plan from the ONE declared data root. **Pure** — reads
/// `passwd_path` (a fixture in tests, `/etc/passwd` live) and nothing else: no
/// syscalls, no deletions, no `$HOME`, no [`Scope`](crate::systemd::Scope).
///
/// The root is [`DataRoot`]-validated here too, not only at declaration time, so a
/// spec that arrived over the wire (deserialized from an airgap bundle, where the
/// passwd file is a different machine's) is re-checked against the machine it is
/// about to erase on. A refused root produces an [`EraseMode::Refused`] plan carrying
/// the named reason — never a quiet fall-through to some other path.
pub fn plan_state_erase(spec: &StateSpec<'_>, passwd_path: &Path) -> StatePlan {
let mut notes: Vec<String> = Vec::new();
// Legacy roots first — they are reported even when the primary root is refused,
// because a half-migrated machine still has the old bytes on disk.
let mut legacy: Vec<PathBuf> = Vec::new();
for l in spec.legacy_roots {
match DataRoot::new_in(l, passwd_path) {
Ok(r) => {
notes.push(format!(
"ALSO sweeping the legacy data root {} (pre-fixed-root layout)",
r.path().display()
));
if !legacy.contains(&r.0) {
legacy.push(r.into_path());
}
}
Err(e) => notes.push(format!("legacy data root NOT swept: {e}")),
}
}
let Some(declared) = spec.data_root else {
let mut plan = StatePlan::refused(format!(
"`{}` declares NO data root — an uninstall cannot know what it owns, so NOTHING was \
erased. Declare one absolute root with `ServiceSpec::data_root(\"/var/lib/{}\")`.",
spec.service, spec.service
));
plan.legacy = legacy;
plan.notes = notes;
return plan;
};
let root = match DataRoot::new_in(declared, passwd_path) {
Ok(r) => r.into_path(),
Err(e) => {
let mut plan =
StatePlan::refused(format!("`{}`: {e} — NOTHING was erased", spec.service));
plan.legacy = legacy;
plan.notes = notes;
return plan;
}
};
// Declared sub-dirs, if any; otherwise sweep the root's children.
if !spec.state_dirs.is_empty() {
let mut targets: Vec<PathBuf> = Vec::new();
for d in spec.state_dirs {
let p = Path::new(d);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
match normalize_abs(&joined) {
// Inside the ONE root, and never the root itself.
Some(abs) if abs.starts_with(&root) && abs != root => {
if !targets.contains(&abs) {
targets.push(abs);
}
}
Some(abs) => notes.push(format!(
"DROPPED the state dir {} — it is outside the declared data root {} \
(a second path ladder is exactly the bug this design removes)",
abs.display(),
root.display()
)),
None => notes.push(format!("DROPPED the unresolvable state dir `{d}`")),
}
}
if targets.is_empty() {
let mut plan = StatePlan::refused(format!(
"`{}`: every declared state dir resolved outside the data root {} — NOTHING was \
erased",
spec.service,
root.display()
));
plan.root = Some(root);
plan.legacy = legacy;
plan.notes = notes;
return plan;
}
return StatePlan {
root: Some(root),
mode: EraseMode::Declared(targets),
legacy,
notes,
};
}
notes.push(format!(
"sweeping every child of the data root {} except identity",
root.display()
));
StatePlan {
root: Some(root.clone()),
mode: EraseMode::SweepChildren(root),
legacy,
notes,
}
}
// ─────────────────────────────── options ────────────────────────────────────
/// What an uninstall is allowed to destroy BEYOND the data. Defaults to the law:
/// data dies, identity lives, the account stays.
///
/// Neither flag can be reached by accident — they exist only on this struct, the
/// plain [`Lifecycle::uninstall`](crate::service::Lifecycle::uninstall) never
/// constructs anything but [`UninstallOptions::default`], and
/// [`UninstallOptions::validate`] refuses the dangerous combination outright.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UninstallOptions {
/// **DESTROY the identity too** — the `.ssh` deploy key, the stored token, every
/// [`IDENTITY_NAMES`] entry under the data root.
///
/// ⚠ **The deploy key registered on the git remotes will be destroyed.** A
/// reinstall then needs a NEW key generated and registered on **every** private
/// repo before it can clone anything. That is an outage until a human with access
/// to all of those repos finishes; it is never a side effect of anything, and
/// never the default.
pub wipe_identity: bool,
/// Also delete the service ACCOUNT (its passwd/group/shadow rows).
///
/// Requires [`UninstallOptions::wipe_identity`](crate::uninstall::UninstallOptions::wipe_identity) — see
/// [`UninstallOptions::validate`]. holger's old uninstall did
/// `remove_dir_all("/home/holger")` and THEN `userdel`, destroying the deploy key
/// twice over while claiming to be a tidy-up; removing the account that owns the
/// key is only ever legitimate once the key material has been deliberately
/// destroyed first.
pub remove_account: bool,
}
impl UninstallOptions {
/// The law's default: data dies, identity lives, the account stays.
pub fn new() -> Self {
Self::default()
}
/// Opt in to destroying the identity — see [`UninstallOptions::wipe_identity`](crate::uninstall::UninstallOptions::wipe_identity).
pub fn wipe_identity(mut self, yes: bool) -> Self {
self.wipe_identity = yes;
self
}
/// Opt in to removing the service account — see
/// [`UninstallOptions::remove_account`]. Only valid together with
/// [`UninstallOptions::wipe_identity`](crate::uninstall::UninstallOptions::wipe_identity).
pub fn remove_account(mut self, yes: bool) -> Self {
self.remove_account = yes;
self
}
/// REFUSE the combination that destroyed holger's key twice: removing the account
/// that owns the key without having explicitly asked for the key to be destroyed.
/// The key material must be handled FIRST, or the removal is refused.
pub fn validate(&self) -> anyhow::Result<()> {
if self.remove_account && !self.wipe_identity {
anyhow::bail!(
"REFUSING to remove the service account: the account OWNS the ssh deploy key, so \
deleting it destroys the key as a side effect — the exact holger incident. Pass \
`wipe_identity(true)` as well if destroying the key is genuinely intended \
(a reinstall then needs a new key registered on every repo), or leave the \
account in place."
);
}
Ok(())
}
}
/// WHO owns the tree being erased — the uid handed to
/// [`crate::systemd::guarded_remove_dir_all`] so its "this account's own home is
/// allowed" rule applies to the right account.
///
/// This picks an OWNER, never a PATH: the data root is one fixed declaration and this
/// struct cannot change it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EraseOwner {
/// The service account's uid (system installs).
pub service_uid: u32,
/// The invoking process's euid.
pub invoking_uid: u32,
/// The invoking user's home, when known — a data root INSIDE it (a dev box's
/// `~/.dwarves`, or a legacy per-user root) is owned by the invoking user.
pub invoking_home: Option<PathBuf>,
}
impl EraseOwner {
/// Resolve the owner for `user` against the live system.
#[cfg(unix)]
pub fn for_service(user: Option<&str>) -> Self {
let euid = crate::systemd::current_euid();
EraseOwner {
service_uid: user
.and_then(crate::systemd::lookup_system_uid)
.unwrap_or(euid),
invoking_uid: euid,
invoking_home: std::env::var_os("HOME").map(PathBuf::from),
}
}
/// Everything owned by one uid (tests, and a per-user install).
pub fn uid(uid: u32) -> Self {
EraseOwner {
service_uid: uid,
invoking_uid: uid,
invoking_home: None,
}
}
/// The uid to hand the delete guard for `path`.
fn uid_for(&self, path: &Path) -> u32 {
match &self.invoking_home {
Some(h) if path.starts_with(h) => self.invoking_uid,
_ => self.service_uid,
}
}
}
// ─────────────────────────────── the report ─────────────────────────────────
/// An HONEST account of what an uninstall did. A caller that prints
/// [`UninstallReport::summary`](crate::uninstall::UninstallReport::summary) cannot print success over a no-op: an uninstall that
/// removed nothing SAYS so, and says why.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UninstallReport {
/// Service name.
pub service: String,
/// The declared data root (`None` ⇒ none declared, or it was refused).
pub data_root: Option<PathBuf>,
/// Was the unit file actually removed?
pub unit_removed: bool,
/// Every path erased.
pub erased: Vec<PathBuf>,
/// Every identity path deliberately KEPT (`.ssh`, a stored token, …).
pub kept: Vec<PathBuf>,
/// Every path the guard refused, with the reason — reported, never swallowed.
pub refused: Vec<(PathBuf, String)>,
/// Legacy roots that were swept as well (migration).
pub legacy_swept: Vec<PathBuf>,
/// Was the identity destroyed (the explicit opt-in)?
pub identity_wiped: bool,
/// Was the service account removed (the explicit opt-in)?
pub account_removed: bool,
/// Every `<unit>.service.d/*.conf` DELIBERATELY left in place. The install
/// writes the unit fragment and never a drop-in, so the uninstall never deletes
/// one — but a drop-in dir surviving a removed unit re-arms the NEXT install
/// with the same override, so it is named here rather than left silent
/// (`crate::dropin`).
pub dropins_kept: Vec<PathBuf>,
/// The private env file (secrets) removed — at the location the install wrote it.
#[serde(default)]
pub env_file_removed: Option<PathBuf>,
/// The config files ([`crate::service::ServiceSpec::config_files`]) that were
/// removed.
#[serde(default)]
pub config_files_removed: Vec<PathBuf>,
/// Resolution + execution notes — why a no-op is a no-op.
pub notes: Vec<String>,
}
impl UninstallReport {
/// A blank report for `service`.
pub fn new(service: impl Into<String>) -> Self {
UninstallReport {
service: service.into(),
..Default::default()
}
}
/// Did this uninstall do NOTHING at all? (No unit removed, nothing erased, no
/// account removed.) The one predicate a caller must consult before it prints a
/// success line.
pub fn is_noop(&self) -> bool {
!self.unit_removed
&& self.erased.is_empty()
&& !self.account_removed
&& self.env_file_removed.is_none()
&& self.config_files_removed.is_empty()
}
/// The count of erased top-level items.
pub fn erased_count(&self) -> usize {
self.erased.len()
}
/// A one-paragraph, honest summary — the line a CLI prints. Never says "removed"
/// when nothing was removed.
pub fn summary(&self) -> String {
let mut s = String::new();
if self.is_noop() {
s.push_str(&format!(
"✗ uninstall {}: NOTHING was removed",
self.service
));
if let Some(r) = &self.data_root {
s.push_str(&format!(" (data root {})", r.display()));
}
} else {
s.push_str(&format!("✓ uninstall {}:", self.service));
if self.unit_removed {
s.push_str(" unit removed;");
}
if let Some(ef) = &self.env_file_removed {
s.push_str(&format!(" env file {} removed;", ef.display()));
}
for c in &self.config_files_removed {
s.push_str(&format!(" config file {} removed;", c.display()));
}
s.push_str(&format!(" {} data item(s) erased", self.erased.len()));
if let Some(r) = &self.data_root {
s.push_str(&format!(" under {}", r.display()));
}
s.push(';');
for l in &self.legacy_swept {
s.push_str(&format!(" legacy root {} swept;", l.display()));
}
if self.identity_wiped {
s.push_str(
" IDENTITY DESTROYED — the ssh deploy key and token are GONE; a reinstall \
needs a new key registered on every repo;",
);
} else {
s.push_str(&format!(
" {} identity item(s) KEPT (ssh key + token survive byte-identical);",
self.kept.len()
));
}
if self.account_removed {
s.push_str(" service account removed;");
}
}
for (p, why) in &self.refused {
s.push_str(&format!("\n ! left {} in place: {why}", p.display()));
}
for n in &self.notes {
s.push_str(&format!("\n · {n}"));
}
s
}
}
// ─────────────────────────────── execution ──────────────────────────────────
/// EXECUTE a [`StatePlan`]: erase the data, keep the identity (or, under
/// `opts.wipe_identity`, destroy it too), sweep any legacy roots, and record every
/// decision in `report`.
///
/// Every delete routes through [`crate::systemd::guarded_remove_dir_all`] — the
/// post-incident authority that refuses any `.ssh` component, a real user's home, a
/// system directory, and a symlink out of the tree. Nothing here hand-rolls
/// `remove_dir_all` on a service tree.
///
/// Single-threaded by design: this is an unlink-bound filesystem walk, and a torn
/// parallel delete of a service's state has no upside.
#[cfg(unix)]
pub fn execute_state_erase(
plan: &StatePlan,
owner: &EraseOwner,
opts: UninstallOptions,
report: &mut UninstallReport,
) {
execute_state_erase_in(plan, owner, opts, report, Path::new("/etc/passwd"))
}
/// Dependency-injected [`execute_state_erase`] — reads `passwd_path` instead of the
/// live `/etc/passwd` when deciding whether a sweep root is a real account's home.
///
/// The seam exists because the UPPER BOUND is the half of this law that cannot be
/// tested any other way: proving that a sweep **cannot** climb into a login home needs
/// a home that a test may legitimately destroy, and the only way to have one is to
/// fabricate the passwd row that makes a temp directory *be* somebody's home. Every
/// other entry point ([`execute_state_erase`], [`crate::systemd::purge_service_data_keep_keys`],
/// [`crate::systemd::remove_systemd_scoped_with`]) reaches this same function against
/// the real file.
#[cfg(unix)]
pub fn execute_state_erase_in(
plan: &StatePlan,
owner: &EraseOwner,
opts: UninstallOptions,
report: &mut UninstallReport,
passwd_path: &Path,
) {
report.data_root = plan.root.clone();
report.notes.extend(plan.notes.iter().cloned());
match &plan.mode {
EraseMode::Refused(why) => report.notes.push(why.clone()),
EraseMode::Declared(targets) => {
let root = plan.root.clone().unwrap_or_else(|| PathBuf::from("/"));
for t in targets {
erase_tree(t, &root, owner, opts.wipe_identity, report);
}
}
EraseMode::SweepChildren(dir) => sweep_root(dir, owner, opts, report, passwd_path),
}
// LEGACY roots — same law, separately reported so a migration is visible.
for l in &plan.legacy {
let before = report.erased.len();
sweep_root(l, owner, opts, report, passwd_path);
if report.erased.len() > before {
report.legacy_swept.push(l.clone());
}
}
}
/// Sweep every child of one root, sparing identity.
///
/// **THE UPPER BOUND.** The root is [`DataRoot`]-validated HERE, immediately before
/// the children are enumerated, because this is the last point every caller shares:
/// [`plan_state_erase`] validates too, but [`execute_state_erase`] takes a
/// [`StatePlan`] it did not build and [`StatePlan::sweep`] is public and validates
/// nothing — [`crate::systemd::purge_service_data_keep_keys`] takes its root VERBATIM
/// and is what dwarves calls. So a plan-time check alone is an upper bound on the
/// plans this module builds, not on the sweeps it performs.
///
/// Nothing may be swept out of a directory that IS or CONTAINS a real account's home:
/// the law nukes *the app's data dir inside* a home, never the home. The refusal is
/// recorded, so the sweep that did not happen is never silence.
///
/// This does NOT depend on [`crate::systemd::guarded_remove_dir_all`] catching a bad
/// root. It cannot: [`EraseOwner::uid_for`] hands the INVOKING uid for a path under
/// the invoking home (correctly — that is the fix for the uid bug), and the delete
/// guard's `is_target_accounts_own_home` exception then admits every child. Measured
/// 2026-08-03 by `a_sweep_can_never_climb_into_a_login_home`: with this check removed,
/// a sweep of a fabricated home erased `.bashrc`, `.rustup`, `work` and
/// `GolandProjects`.
#[cfg(unix)]
fn sweep_root(
dir: &Path,
owner: &EraseOwner,
opts: UninstallOptions,
report: &mut UninstallReport,
passwd_path: &Path,
) {
if let Err(e) = DataRoot::new_in(dir, passwd_path) {
report.notes.push(format!("{e} — swept NOTHING there"));
report
.refused
.push((dir.to_path_buf(), e.reason.to_string()));
return;
}
let Ok(rd) = std::fs::read_dir(dir) else {
report.notes.push(format!(
"data root {} does not exist or is unreadable — nothing to erase there",
dir.display()
));
return;
};
let mut children: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
children.sort();
for c in children {
erase_tree(&c, dir, owner, opts.wipe_identity, report);
}
}
/// Bottom-up guarded erase of one target.
///
/// Returns `true` when something inside was KEPT, in which case the containing
/// directory must survive to hold it. The fast path — a subtree with no identity in
/// it — is a single [`crate::systemd::guarded_remove_dir_all`]; only a subtree that
/// actually contains key material is walked entry by entry.
#[cfg(unix)]
fn erase_tree(
path: &Path,
root: &Path,
owner: &EraseOwner,
wipe_identity: bool,
report: &mut UninstallReport,
) -> bool {
let uid = owner.uid_for(path);
let name = path.file_name().unwrap_or_default();
// IDENTITY — the whole point of the law.
if is_identity_name(name) {
if wipe_identity {
match crate::systemd::wipe_identity_material(path, uid, root) {
Ok(true) => {
report.identity_wiped = true;
report.erased.push(path.to_path_buf());
}
Ok(false) => {}
Err(e) => report.refused.push((path.to_path_buf(), format!("{e:#}"))),
}
return false;
}
if path.symlink_metadata().is_ok() {
report.kept.push(path.to_path_buf());
return true;
}
return false;
}
let Ok(md) = path.symlink_metadata() else {
return false; // already gone — idempotent
};
// A directory that holds identity somewhere below must be walked, not swept.
if md.is_dir() && !md.file_type().is_symlink() && !wipe_identity && subtree_has_identity(path) {
let mut kept_something = false;
let mut children: Vec<PathBuf> = match std::fs::read_dir(path) {
Ok(rd) => rd.flatten().map(|e| e.path()).collect(),
Err(e) => {
report
.refused
.push((path.to_path_buf(), format!("read_dir: {e}")));
return true;
}
};
children.sort();
for c in children {
kept_something |= erase_tree(&c, root, owner, wipe_identity, report);
}
if kept_something {
return true;
}
// Nothing survived inside after all — the directory itself is data.
}
match crate::systemd::guarded_remove_dir_all(path, uid, Some(root)) {
Ok(()) => {
report.erased.push(path.to_path_buf());
false
}
Err(e) => {
report.refused.push((path.to_path_buf(), format!("{e:#}")));
true // it is still there; the parent must survive
}
}
}
/// Does this subtree contain any [`IDENTITY_NAMES`] entry? Iterative (no recursion
/// depth limit), metadata-only, and never follows a symlink out of the tree.
#[cfg(unix)]
fn subtree_has_identity(dir: &Path) -> bool {
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for e in rd.flatten() {
if is_identity_name(&e.file_name()) {
return true;
}
match e.file_type() {
Ok(ft) if ft.is_dir() => stack.push(e.path()),
_ => {}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn passwd_fixture(dir: &Path, service_home: &str, human_home: &str) -> PathBuf {
let p = dir.join("passwd");
std::fs::write(
&p,
format!(
"root:x:0:0::/root:/bin/bash\n\
dwarves:x:110:110::{service_home}:/usr/sbin/nologin\n\
alice:x:1000:1000::{human_home}:/bin/bash\n"
),
)
.unwrap();
p
}
/// **ONE data root, whoever owns the process.** The SAME declaration resolves to
/// the SAME absolute path with no `$HOME` in sight and no scope to branch on —
/// and the resolver's answer is the eraser's input, so they cannot disagree.
///
/// RED when a `$HOME`/scope ladder comes back: dwarves and korp resolved state one
/// way and erased another, wiping NOTHING on a per-user install while printing
/// success.
#[test]
fn the_data_root_is_one_declaration_never_derived_from_home_or_owner() {
let d = tempfile::tempdir().unwrap();
let passwd = passwd_fixture(d.path(), "/home/dwarves", "/home/alice");
let none: Vec<String> = Vec::new();
let spec = StateSpec {
service: "dwarves",
user: Some("dwarves"),
data_root: Some("/var/lib/dwarves"),
legacy_roots: &none,
state_dirs: &none,
};
// Root user, service user, human user, no $HOME at all — one answer.
let plan = plan_state_erase(&spec, &passwd);
assert_eq!(plan.root.as_deref(), Some(Path::new("/var/lib/dwarves")));
assert_eq!(
plan.mode,
EraseMode::SweepChildren(PathBuf::from("/var/lib/dwarves"))
);
// The planner takes NO $HOME and NO Scope, so there is nothing to vary: the
// same call is the whole proof. Re-running it under a mutated environment must
// still give the identical plan.
let before = std::env::var_os("HOME");
// SAFETY: single-threaded test process; restored immediately below.
unsafe { std::env::set_var("HOME", "/home/somebody-else") };
let again = plan_state_erase(&spec, &passwd);
match before {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
assert_eq!(plan, again, "the data root must not move when $HOME moves");
}
/// A data root that IS, or CONTAINS, a real user's home is refused where it is
/// WRITTEN. The near-miss of 2026-07-27: `$HOME` as a data root would have swept
/// `.rustup`, `work`, `.njord-secrets`, `.bashrc`, `GolandProjects` and only a
/// downstream guard stood in the way.
#[test]
fn a_data_root_that_is_or_contains_a_users_home_is_refused_at_declaration() {
let d = tempfile::tempdir().unwrap();
let passwd = passwd_fixture(d.path(), "/home/dwarves", "/home/alice");
let is_home = DataRoot::new_in("/home/alice", &passwd).unwrap_err();
assert_eq!(
is_home.reason,
DataRootRefusal::IsUserHome {
user: "alice".into()
}
);
let contains = DataRoot::new_in("/home", &passwd).unwrap_err();
// `/home` is a system dir AND contains homes — either refusal is correct.
assert!(matches!(
contains.reason,
DataRootRefusal::SystemDir | DataRootRefusal::ContainsUserHome { .. }
));
assert_eq!(
DataRoot::new_in("/", &passwd).unwrap_err().reason,
DataRootRefusal::SystemDir
);
assert_eq!(
DataRoot::new_in("relative/path", &passwd)
.unwrap_err()
.reason,
DataRootRefusal::NotAbsolute
);
assert_eq!(
DataRoot::new_in("/data", &passwd).unwrap_err().reason,
DataRootRefusal::TooShallow { components: 1 }
);
// …and every classic system root is refused by name, not by depth.
assert_eq!(
DataRoot::new_in("/srv", &passwd).unwrap_err().reason,
DataRootRefusal::SystemDir
);
// The legitimate shapes: a product directory, and a dev box's dir INSIDE a
// home (inside is fine — it is being a home, or swallowing one, that is not).
DataRoot::new_in("/var/lib/dwarves", &passwd).expect("a product's own dir");
DataRoot::new_in("/home/dwarves/data", &passwd).expect("inside the service account's home");
DataRoot::new_in("/home/alice/.dwarves", &passwd)
.expect("a dev box's <APP>_DATA inside the human's home");
// …and the refusal survives into the PLAN as a REFUSED plan, never a quiet
// fall-through to some other path.
let none: Vec<String> = Vec::new();
let plan = plan_state_erase(
&StateSpec {
service: "dwarves",
user: Some("dwarves"),
data_root: Some("/home/alice"),
legacy_roots: &none,
state_dirs: &none,
},
&passwd,
);
assert!(!plan.erases_anything());
let EraseMode::Refused(why) = &plan.mode else {
panic!("{:?}", plan.mode)
};
assert!(why.contains("home directory"), "{why}");
}
/// A spec with NO data root erases nothing — and says so, naming the fix.
#[test]
fn no_declared_data_root_erases_nothing_and_says_why() {
let d = tempfile::tempdir().unwrap();
let passwd = passwd_fixture(d.path(), "/home/dwarves", "/home/alice");
let none: Vec<String> = Vec::new();
let plan = plan_state_erase(
&StateSpec {
service: "korp",
user: None,
data_root: None,
legacy_roots: &none,
state_dirs: &none,
},
&passwd,
);
assert!(!plan.erases_anything());
let EraseMode::Refused(why) = &plan.mode else {
panic!("{:?}", plan.mode)
};
assert!(
why.contains("NO data root") && why.contains("data_root("),
"{why}"
);
}
/// Legacy roots are planned, reported by name, and never silently ignored — the
/// 5.3 GB `~/.nornir` orphan problem.
#[test]
fn legacy_roots_are_planned_and_named() {
let d = tempfile::tempdir().unwrap();
let passwd = passwd_fixture(d.path(), "/home/dwarves", "/home/alice");
let legacy = vec![
"/home/alice/.dwarves".to_string(),
"/home/alice".to_string(),
];
let none: Vec<String> = Vec::new();
let plan = plan_state_erase(
&StateSpec {
service: "dwarves",
user: Some("dwarves"),
data_root: Some("/var/lib/dwarves"),
legacy_roots: &legacy,
state_dirs: &none,
},
&passwd,
);
assert_eq!(plan.legacy, vec![PathBuf::from("/home/alice/.dwarves")]);
assert!(
plan.notes
.iter()
.any(|n| n.contains("legacy data root NOT swept")),
"a legacy root that is a human's home must be REFUSED and reported: {:?}",
plan.notes
);
}
/// THE LAW, executed: data dies, the ssh key and the token LIVE byte-identical.
#[cfg(unix)]
#[test]
fn uninstall_kills_the_data_and_keeps_the_key_and_token() {
const KEY: &[u8] = b"-----BEGIN OPENSSH PRIVATE KEY-----\nDEPLOY\n";
let d = tempfile::tempdir().unwrap();
let root = d.path().join("var/lib/dwarves");
std::fs::create_dir_all(root.join(".ssh")).unwrap();
std::fs::write(root.join(".ssh/id_ed25519"), KEY).unwrap();
std::fs::create_dir_all(root.join("warehouse/parts")).unwrap();
std::fs::write(root.join("warehouse/facts.parquet"), b"DATA").unwrap();
std::fs::create_dir_all(root.join(".nornir/workspaces/x")).unwrap();
std::fs::write(root.join(".nornir/token"), b"TOKEN").unwrap();
std::fs::write(root.join(".nornir/registry.ron"), b"REG").unwrap();
std::fs::create_dir_all(root.join("cache/blobs")).unwrap();
std::fs::write(root.join("state.db"), b"STATE").unwrap();
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&StatePlan::sweep(&root),
&EraseOwner::uid(crate::systemd::current_euid()),
UninstallOptions::new(),
&mut report,
);
// DATA DIES.
assert!(
!root.join("warehouse").exists(),
"the warehouse must be GONE"
);
assert!(!root.join("cache").exists(), "the cache must be GONE");
assert!(
!root.join("state.db").exists(),
"the state file must be GONE"
);
assert!(
!root.join(".nornir/workspaces").exists(),
"workspaces must be GONE"
);
assert!(
!root.join(".nornir/registry.ron").exists(),
"the registry must be GONE"
);
// KEYS LIVE — byte-identical.
assert!(
root.join(".ssh/id_ed25519").exists(),
"THE DEPLOY KEY WAS DESTROYED"
);
assert_eq!(std::fs::read(root.join(".ssh/id_ed25519")).unwrap(), KEY);
assert_eq!(std::fs::read(root.join(".nornir/token")).unwrap(), b"TOKEN");
assert!(
!report.is_noop(),
"an uninstall that erased data is not a no-op"
);
assert!(!report.identity_wiped);
assert!(
report.kept.iter().any(|p| p.ends_with(".ssh")),
"{:?}",
report.kept
);
assert!(
report.kept.iter().any(|p| p.ends_with("token")),
"{:?}",
report.kept
);
assert!(
report.summary().contains("KEPT"),
"the summary must state the key was kept: {}",
report.summary()
);
}
/// THE LAW over nornir's REAL on-disk layout: the key directory is `ssh`, with NO
/// leading dot (`~/.nornir/ssh/id_ed25519`), beside rotation `.bak` spellings.
///
/// Incident 2026-07-28 (caught before it ran): `IDENTITY_NAMES` matched by EXACT
/// name and listed only `.ssh`, so a `dwarves-full --uninstall` sweep of `~/.nornir`
/// classified `ssh` as DATA and would have erased the deploy key registered on every
/// private git remote. `guarded_remove_dir_all`'s `KeyMaterial` refusal does not
/// cover it either — that guard keys on a `.ssh` component.
///
/// RED WHEN REVERTED: drop `"ssh"` from `IDENTITY_NAMES` (or the `IDENTITY_STEMS`
/// prefix match) and this test fails on the destroyed key / destroyed `.bak`.
#[cfg(unix)]
#[test]
fn uninstall_keeps_the_dotless_ssh_dir_and_rotated_key_spellings() {
const KEY: &[u8] = b"-----BEGIN OPENSSH PRIVATE KEY-----\nNORNIR-DEPLOY\n";
const BAK: &[u8] = b"-----BEGIN OPENSSH PRIVATE KEY-----\nROTATED\n";
let d = tempfile::tempdir().unwrap();
// The shape of a real ~/.nornir.
let root = d.path().join("home/alice/.nornir");
std::fs::create_dir_all(root.join("ssh")).unwrap();
std::fs::write(root.join("ssh/id_ed25519"), KEY).unwrap();
std::fs::write(root.join("ssh/id_ed25519.pub"), b"ssh-ed25519 AAAA").unwrap();
std::fs::write(root.join("ssh/id_ed25519.nornir-t14s.bak"), BAK).unwrap();
// …and the data that MUST die beside it.
std::fs::create_dir_all(root.join("workspaces/nordisk/git")).unwrap();
std::fs::write(root.join("workspaces/registry.redb"), b"REGISTRY").unwrap();
std::fs::create_dir_all(root.join("warehouse/parts")).unwrap();
std::fs::write(root.join("node_key"), b"NODE").unwrap();
std::fs::write(root.join("token"), b"TOKEN").unwrap();
let mut report = UninstallReport::new("nornir");
execute_state_erase(
&StatePlan::sweep(&root),
&EraseOwner::uid(crate::systemd::current_euid()),
UninstallOptions::new(),
&mut report,
);
// DATA DIES — including the 1.7 GB workspaces tree and the registry.
assert!(!root.join("workspaces").exists(), "workspaces must be GONE");
assert!(
!root.join("warehouse").exists(),
"the warehouse must be GONE"
);
assert!(
!root.join("node_key").exists(),
"node_key is data, must be GONE"
);
// KEYS LIVE — byte-identical, dot or no dot, rotated or not.
assert!(
root.join("ssh/id_ed25519").exists(),
"THE NORNIR DEPLOY KEY WAS DESTROYED (dotless `ssh` treated as data)"
);
assert_eq!(std::fs::read(root.join("ssh/id_ed25519")).unwrap(), KEY);
assert_eq!(
std::fs::read(root.join("ssh/id_ed25519.pub")).unwrap(),
b"ssh-ed25519 AAAA"
);
assert_eq!(
std::fs::read(root.join("ssh/id_ed25519.nornir-t14s.bak")).unwrap(),
BAK,
"a rotated .bak key is still key material"
);
assert_eq!(std::fs::read(root.join("token")).unwrap(), b"TOKEN");
assert!(!report.is_noop(), "data was erased, so this is not a no-op");
assert!(
!report.identity_wiped,
"plain uninstall must never wipe identity"
);
}
/// The IDENTITY OPT-IN really does destroy the key and the token — and only when
/// asked.
#[cfg(unix)]
#[test]
fn the_identity_opt_in_destroys_key_and_token() {
let d = tempfile::tempdir().unwrap();
let root = d.path().join("var/lib/dwarves");
std::fs::create_dir_all(root.join(".ssh")).unwrap();
std::fs::write(root.join(".ssh/id_ed25519"), b"KEY").unwrap();
std::fs::create_dir_all(root.join(".nornir")).unwrap();
std::fs::write(root.join(".nornir/token"), b"TOKEN").unwrap();
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&StatePlan::sweep(&root),
&EraseOwner::uid(crate::systemd::current_euid()),
UninstallOptions::new().wipe_identity(true),
&mut report,
);
assert!(
!root.join(".ssh").exists(),
"the opt-in must destroy the key dir"
);
assert!(
!root.join(".nornir").exists(),
"the opt-in must destroy the token too"
);
assert!(
report.identity_wiped,
"the report must say the identity was destroyed"
);
assert!(
report.kept.is_empty(),
"nothing is kept under --wipe-identity: {:?}",
report.kept
);
assert!(
report.summary().contains("IDENTITY DESTROYED"),
"{}",
report.summary()
);
}
/// A legacy root is swept by the SAME law — data gone, key kept — and named in the
/// report, so a machine upgraded from the per-user layout ends up genuinely clean.
#[cfg(unix)]
#[test]
fn a_legacy_root_is_swept_by_the_same_law_and_named() {
let d = tempfile::tempdir().unwrap();
let root = d.path().join("var/lib/dwarves");
let old = d.path().join("home/alice/.dwarves");
std::fs::create_dir_all(root.join("warehouse")).unwrap();
std::fs::create_dir_all(old.join("warehouse")).unwrap();
std::fs::create_dir_all(old.join(".ssh")).unwrap();
std::fs::write(old.join("warehouse/old.parquet"), b"OLD").unwrap();
std::fs::write(old.join(".ssh/id_ed25519"), b"KEY").unwrap();
let mut plan = StatePlan::sweep(&root);
plan.legacy = vec![old.clone()];
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&plan,
&EraseOwner::uid(crate::systemd::current_euid()),
UninstallOptions::new(),
&mut report,
);
assert!(
!old.join("warehouse").exists(),
"the legacy warehouse must be GONE"
);
assert!(
old.join(".ssh/id_ed25519").exists(),
"the legacy key still LIVES"
);
assert_eq!(
report.legacy_swept,
vec![old],
"the legacy sweep must be NAMED"
);
assert!(
report.summary().contains("legacy root"),
"{}",
report.summary()
);
}
/// **THE UID BUG, in the one place every previous fixture avoided: INSIDE `$HOME`.**
///
/// The measured 2026-07-27 failure was not only the root — it was the *uid*.
/// `purge_service_data_keep_keys(home, spec.user)` resolved the owning uid from the
/// SERVICE ACCOUNT NAME (`to_systemd_spec` falls back to the app name), never the
/// invoking human. [`crate::systemd::guard_chown_target_in`] then refused every
/// child inside the invoking user's home —
/// `Guard(InvokingUserHome { home: "/home/alice" })` — giving `wiped=0 kept=1,
/// data intact` while the CLI printed ✓ for all four apps.
///
/// Every existing fixture builds its tree in a tempdir OUTSIDE `$HOME`, which is
/// exactly the one place the guard behaves differently, which is exactly why no
/// test caught it. **This one builds inside the real `$HOME`.**
///
/// RED when [`EraseOwner::uid_for`] stops preferring the invoking uid for a tree
/// inside `$HOME`: the service uid is deliberately 0 here, and with uid 0 the guard
/// refuses every single child.
#[cfg(unix)]
#[test]
fn data_inside_the_invoking_users_home_is_actually_erased() {
let euid = crate::systemd::current_euid();
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
eprintln!("SKIP: no $HOME to test inside");
return;
};
if euid == 0 || !home.is_dir() {
eprintln!(
"SKIP: running as root (or $HOME missing) — the guard's $HOME rule needs a human"
);
return;
}
// A real temp tree INSIDE the human's home — the guard's blind spot.
let d = tempfile::TempDir::with_prefix_in("skidbladnir-uninstall-law-", &home).unwrap();
let root = d.path().join(".dwarves");
std::fs::create_dir_all(root.join("warehouse")).unwrap();
std::fs::create_dir_all(root.join(".ssh")).unwrap();
std::fs::write(root.join("warehouse/facts.parquet"), b"DATA").unwrap();
std::fs::write(root.join("state.db"), b"STATE").unwrap();
std::fs::write(root.join(".ssh/id_ed25519"), b"KEY").unwrap();
// The SERVICE ACCOUNT's uid is 0 (the app-name fallback resolves to no real
// account ⇒ the old code passed something that is not the invoking human).
let owner = EraseOwner {
service_uid: 0,
invoking_uid: euid,
invoking_home: Some(home.clone()),
};
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&StatePlan::sweep(&root),
&owner,
UninstallOptions::new(),
&mut report,
);
assert!(
report.refused.is_empty(),
"THE UID BUG IS BACK — the guard refused data inside $HOME: {:?}",
report.refused
);
assert!(
!root.join("warehouse").exists(),
"warehouse inside $HOME must be GONE"
);
assert!(
!root.join("state.db").exists(),
"state inside $HOME must be GONE"
);
assert!(
root.join(".ssh/id_ed25519").exists(),
"the key inside $HOME still LIVES"
);
assert_eq!(std::fs::read(root.join(".ssh/id_ed25519")).unwrap(), b"KEY");
assert!(
!report.is_noop(),
"wiped=0 while printing ✓ is the whole incident"
);
assert_eq!(report.erased.len(), 2, "{:?}", report.erased);
}
/// A no-op uninstall REPORTS "nothing removed" — it never prints success.
#[cfg(unix)]
#[test]
fn a_noop_uninstall_says_nothing_was_removed() {
let d = tempfile::tempdir().unwrap();
let root = d.path().join("var/lib/never-installed");
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&StatePlan::sweep(&root),
&EraseOwner::uid(crate::systemd::current_euid()),
UninstallOptions::new(),
&mut report,
);
assert!(report.is_noop(), "nothing existed, so nothing was removed");
let s = report.summary();
assert!(s.contains("NOTHING was removed"), "{s}");
assert!(
!s.starts_with('✓'),
"a no-op must NEVER render as a success line: {s}"
);
// A REFUSED plan is equally honest, and carries the reason.
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&StatePlan::refused("`dwarves` declares NO data root"),
&EraseOwner::uid(crate::systemd::current_euid()),
UninstallOptions::new(),
&mut report,
);
assert!(report.is_noop());
assert!(
report.summary().contains("NO data root"),
"{}",
report.summary()
);
}
/// **THE UPPER BOUND: a sweep can NEVER climb into a login home.**
///
/// The owner, 2026-08-03: *"You must NOT nuke the home directory. You must nuke the
/// app's data dir inside that home dir, regardless of what username it is."*
///
/// [`plan_state_erase`] refuses such a root at PLAN time — but
/// [`execute_state_erase`] takes a [`StatePlan`] it did not build, and
/// [`StatePlan::sweep`] is public and validates NOTHING. Two shipped callers build
/// a plan that way: [`crate::systemd::purge_service_data_keep_keys`] (whose own
/// doc says it "takes `home` VERBATIM") and dwarves through it. So the plan-time
/// check is not the upper bound; this is.
///
/// The doc at the top of this module claimed the downstream delete guard was the
/// backstop — *"`guarded_remove_dir_all` refused every single one"*. **That claim
/// is stale.** [`EraseOwner::uid_for`] (added later, to fix the uid bug where the
/// guard refused data legitimately inside `$HOME`) now hands the INVOKING uid for
/// any path under the invoking home, and
/// [`crate::systemd::guard_chown_target_in`]'s `is_target_accounts_own_home`
/// exception then lets every child through. Measured, not reasoned: with the
/// validation removed this test deletes `.rustup`, `work` and `.bashrc` out of a
/// fabricated home.
///
/// Fabricated end to end — the passwd row that makes `<tmp>/home/human` a real
/// account's home is written by this test, so no live account is ever involved.
///
/// RED WHEN REVERTED: drop the [`DataRoot::new_in`] check at the top of
/// [`sweep_root`] and the three login-home assertions below fail on files that
/// are actually gone from disk.
#[cfg(unix)]
#[test]
fn a_sweep_can_never_climb_into_a_login_home() {
let d = tempfile::tempdir().unwrap();
// A fabricated login home, made real by a fabricated passwd row.
let home = d.path().join("home/human");
let passwd = d.path().join("passwd");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(
&passwd,
format!("human:x:1000:1000::{}:/bin/bash\n", home.display()),
)
.unwrap();
// The human's own files — the 2026-07-27 near-miss list, verbatim.
for dir in ["\u{2e}rustup", "work", "GolandProjects"] {
std::fs::create_dir_all(home.join(dir)).unwrap();
std::fs::write(home.join(dir).join("payload"), b"MINE").unwrap();
}
std::fs::write(home.join(".bashrc"), b"export PATH").unwrap();
// …and the app's data dir INSIDE it, which the same law says MUST die.
let app = home.join(".theapp");
std::fs::create_dir_all(app.join("warehouse")).unwrap();
std::fs::write(app.join("warehouse/facts.parquet"), b"DATA").unwrap();
std::fs::create_dir_all(app.join("ssh")).unwrap();
std::fs::write(app.join("ssh/id_ed25519"), b"KEY").unwrap();
// A `--run-as <a-human>` shape: the owner resolves to the human, and the tree
// is inside that human's home, so `uid_for` hands the invoking uid — the exact
// combination that disarms the downstream guard.
let owner = EraseOwner {
service_uid: crate::systemd::current_euid(),
invoking_uid: crate::systemd::current_euid(),
invoking_home: Some(home.clone()),
};
// ── THE UPPER BOUND ──────────────────────────────────────────────────────
let mut report = UninstallReport::new("theapp");
execute_state_erase_in(
&StatePlan::sweep(&home),
&owner,
UninstallOptions::new(),
&mut report,
&passwd,
);
assert!(
report.erased.is_empty(),
"A SWEEP CLIMBED INTO A LOGIN HOME — it erased: {:?}",
report.erased
);
assert!(
home.join(".rustup/payload").exists(),
"A SWEEP TOOK ~/.rustup"
);
assert!(home.join("work/payload").exists(), "A SWEEP TOOK ~/work");
assert!(home.join(".bashrc").exists(), "A SWEEP TOOK ~/.bashrc");
assert!(
home.join("GolandProjects/payload").exists(),
"A SWEEP TOOK ~/GolandProjects"
);
// …and it must say WHY it refused, naming the account — never a silent no-op.
assert!(
report.is_noop(),
"a refused sweep is a no-op and must report as one: {}",
report.summary()
);
let why = report.summary();
assert!(
why.contains("home directory") && why.contains("human"),
"the refusal must NAME the account whose home it refused: {why}"
);
// ── POSITIVE CONTROL — the guard is not simply refusing everything ────────
// The app's data dir INSIDE that same home is swept clean, key spared. Without
// this the test above would pass just as well against a `sweep_root` that had
// been broken into a permanent no-op.
let mut report = UninstallReport::new("theapp");
execute_state_erase_in(
&StatePlan::sweep(&app),
&owner,
UninstallOptions::new(),
&mut report,
&passwd,
);
assert!(
!app.join("warehouse").exists(),
"the app's own data must still DIE"
);
assert_eq!(
std::fs::read(app.join("ssh/id_ed25519")).unwrap(),
b"KEY",
"the key inside the app's data dir still LIVES, byte-identical"
);
assert_eq!(report.erased.len(), 1, "{:?}", report.erased);
assert!(!report.is_noop(), "the legitimate sweep is NOT a no-op");
// And the home above it is STILL untouched after the legitimate sweep.
assert!(
home.join(".bashrc").exists(),
"the sweep climbed out of the data dir"
);
}
/// Removing the ACCOUNT that owns the key is refused unless the key was
/// explicitly destroyed first — holger's double key destruction, as a
/// reachable-but-refused combination.
#[test]
fn removing_the_account_without_wiping_the_identity_is_refused() {
let e = UninstallOptions::new()
.remove_account(true)
.validate()
.expect_err("account removal without an identity wipe must be REFUSED");
let msg = format!("{e:#}");
assert!(msg.contains("REFUSING"), "{msg}");
assert!(
msg.contains("holger"),
"the refusal must name the incident: {msg}"
);
UninstallOptions::new()
.wipe_identity(true)
.remove_account(true)
.validate()
.expect("with the key deliberately destroyed first, the removal is allowed");
UninstallOptions::new()
.validate()
.expect("the default is always valid");
}
}