Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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");
    }
}