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

nordisk/Skidbladnir

src/rootless.rs

//! **ROOTLESS-CONTAINER HOST SETUP** — the five host facts a `User=`-confined
//! nordisk service needs before draupnir's OCI engine can talk to a *rootless*
//! podman, as an INSPECTABLE report first and a mutation second.
//!
//! # The incident this is made of (oden, 2026-08-11 — VERIFIED)
//!
//! `korp-server` is a systemd SYSTEM unit running `User=korp`: uid 111, shell
//! `/usr/sbin/nologin`, no lingering, no login session it will ever have. It needs
//! a podman API socket. Getting one took three host mutations that an operator
//! typed by hand, at a terminal, on one box:
//!
//! ```text
//! loginctl enable-linger korp                    # logind then creates /run/user/111
//! systemctl --user enable --now podman.socket    # as korp, in ITS user manager
//! echo "korp:296608:65536" >> /etc/subuid        # rootless needs a subordinate id range
//! echo "korp:296608:65536" >> /etc/subgid
//! podman system migrate                          # as korp
//! ```
//!
//! Results, measured: `loginctl show-user korp` → `Linger=yes`; `/run/user/111`
//! → `drwx------ korp korp`; `/run/user/111/podman/podman.sock` →
//! `srw-rw---- korp korp`; FalkorDB came up on `:6379` for the first time. Before
//! the subuid grant the image build failed with *"potentially insufficient UIDs or
//! GIDs available in user namespace (requested 111:111)"* — the error that names
//! exactly this file and is almost never read as naming it.
//!
//! Every one of those is a HOST mutation. None of them is expressible in a
//! `ServiceSpec`, which is why they were typed rather than declared, and why they
//! are typed again on the next box.
//!
//! # What this module is, and what it deliberately is not
//!
//! It is **the inspection**, first and mostly. [`inspect`] answers *"is this host
//! already set up?"* without changing anything, and [`Report::plan`] prints the
//! exact mutations that WOULD happen. [`apply`] is the small part at the end, and
//! it refuses to run at all if any step is [`State::Refused`] or
//! [`State::Unsupported`] — a half-applied host setup is worse than none.
//!
//! It is **not** a package installer. Three of the five steps can be *fixed* here;
//! the other two (`newuidmap` missing, no login manager) are host decisions whose
//! fix is a package install, i.e. a subprocess, i.e. forbidden — and, more to the
//! point, not this crate's business. They are reported with the exact command for
//! *this* family ([`crate::hostfamily::Family::uidmap_package`]) and stop the run.
//!
//! It is **not** the engine. Where the socket is, whether it answers, and whether
//! `podman system migrate` is owed belong to draupnir, which owns the OCI engine
//! and already classifies socket reachability (`draupnir::container::SocketProbe`).
//! The seam: **skidbladnir mutates the host and account; draupnir states the
//! engine's requirement and verifies the result.** Neither repeats the other.
//!
//! # Zero shell, per the LAW
//!
//! * `/etc/subuid`, `/etc/subgid`, `/etc/passwd`, `/etc/login.defs` are text files.
//!   They are parsed, edited in memory and written back through a
//!   same-directory temp file + `rename` — never `echo >>`, which cannot be atomic
//!   and cannot preserve a mode.
//! * Lingering is a D-Bus method,
//!   `org.freedesktop.login1.Manager.SetUserLinger(uid, enable, interactive)`, and
//!   is called over `zbus` exactly as [`crate::systemd::Systemd`] calls
//!   `org.freedesktop.systemd1` — no `loginctl` subprocess.
//! * Its *state* is read from logind's on-disk marker
//!   (`/var/lib/systemd/linger/<name>`), which needs neither root nor a bus, so the
//!   inspection works from an unprivileged process. VERIFIED on t14s: a zero-byte
//!   file, mode 0644, in a 0755 directory.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::hostfamily::{Host, Logind};

/// Where a distribution drops UNIT FILES for user managers, in the order systemd itself
/// searches them (`/etc` wins over `/usr`, and `/lib` is the pre-usrmerge spelling of the
/// same directory). [`Step::ContainerSocket`] looks for the container engine's socket unit
/// here — it never writes one: shipping a unit is the engine package's job, and a
/// hand-written copy is a unit no package upgrade will ever fix.
const USER_UNIT_DIRS: &[&str] = &[
    "/etc/systemd/user",
    "/usr/lib/systemd/user",
    "/lib/systemd/user",
];

/// The user-scope socket unit a rootless container engine ships. One name, because one
/// engine ships one — see [`crate::hostfamily::Family::container_engine_package`] for why
/// this is podman and not docker.
const CONTAINER_SOCKET_UNIT: &str = "podman.socket";

/// The target a socket unit installs into (`WantedBy=sockets.target` in every
/// distribution's `podman.socket`), and therefore the `.wants` directory whose symlink IS
/// the enablement — the file `systemctl --user enable` writes and the one thing that
/// survives a reboot.
const SOCKETS_TARGET_WANTS: &str = "sockets.target.wants";

/// The unit's file NAME from the path it was found at — `podman.socket` out of
/// `/usr/lib/systemd/user/podman.socket`. The report wants the path, the link wants the
/// name, and neither should be derived by hand at three call sites.
fn unit_file_name(path: &str) -> String {
    Path::new(path)
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| CONTAINER_SOCKET_UNIT.to_string())
}

/// **The enablement link for `unit` in `home`'s user manager** —
/// `<home>/.config/systemd/user/sockets.target.wants/<unit>`.
///
/// ONE writer of this path, used by the reader in [`read_inputs`] and by the writer in
/// [`enable_user_unit`] alike. Spelled twice, they could disagree about whether the step is
/// done — which is the shape of bug where an install repeats work forever or skips it
/// silently.
fn user_wants_link(home: &str, unit: &str) -> PathBuf {
    Path::new(home)
        .join(".config/systemd/user")
        .join(SOCKETS_TARGET_WANTS)
        .join(unit)
}

// ---------------------------------------------------------------------------
// The account
// ---------------------------------------------------------------------------

/// A service account as `/etc/passwd` describes it.
///
/// Read from the file rather than through `getpwnam_r` FFI: the LAW keeps FFI for
/// last resort, and nothing here needs NSS to reach LDAP — a service account that
/// a *rootless container* runs as must have a local subordinate-id grant anyway,
/// and `/etc/subuid` is keyed by the name in this very file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
    pub name: String,
    pub uid: u32,
    pub gid: u32,
    /// Login shell — `nologin` for a service account, which is normal and is
    /// exactly why lingering has to be granted explicitly.
    pub shell: String,
    pub home: String,
}

impl Account {
    /// Look `name` up in `/etc/passwd` CONTENTS (pure → fixture-testable).
    pub fn from_passwd(passwd: &str, name: &str) -> Option<Account> {
        for line in passwd.lines() {
            let f: Vec<&str> = line.split(':').collect();
            if f.len() >= 7 && f[0] == name {
                return Some(Account {
                    name: f[0].to_string(),
                    uid: f[2].parse().ok()?,
                    gid: f[3].parse().ok()?,
                    home: f[5].to_string(),
                    shell: f[6].to_string(),
                });
            }
        }
        None
    }

    /// The rootless runtime directory logind creates for this account.
    pub fn runtime_dir(&self) -> PathBuf {
        PathBuf::from(format!("/run/user/{}", self.uid))
    }

    /// Where a rootless podman puts its API socket for this account — the value a
    /// unit's `DOCKER_HOST` must carry, and the same string draupnir derives.
    pub fn podman_socket(&self) -> PathBuf {
        self.runtime_dir().join("podman/podman.sock")
    }
}

// ---------------------------------------------------------------------------
// Subordinate id files
// ---------------------------------------------------------------------------

/// One `owner:start:count` grant from `/etc/subuid` or `/etc/subgid`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubIdRange {
    pub owner: String,
    pub start: u32,
    pub count: u32,
}

impl SubIdRange {
    /// Last id in the range (inclusive). `count` is a COUNT, not an end — a
    /// confusion that produces off-by-65536 overlaps.
    pub fn end(&self) -> u64 {
        self.start as u64 + self.count as u64 - 1
    }
    pub fn overlaps(&self, other: &SubIdRange) -> bool {
        self.start as u64 <= other.end() && other.start as u64 <= self.end()
    }
    pub fn render(&self) -> String {
        format!("{}:{}:{}", self.owner, self.start, self.count)
    }
}

/// A line of a subid file: either a parsed grant or **anything else, kept
/// verbatim**.
///
/// Comments, blank lines and malformed entries are carried through untouched
/// rather than dropped. A rewriter that silently normalises a file it does not
/// fully understand is a rewriter that eventually deletes somebody's grant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubIdLine {
    Range(SubIdRange),
    Other(String),
}

/// A parsed `/etc/subuid` or `/etc/subgid`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubIdFile {
    pub lines: Vec<SubIdLine>,
    /// Whether the source text ended with a newline. Preserved so a rewrite of an
    /// unchanged file is byte-identical.
    trailing_newline: bool,
}

impl SubIdFile {
    /// Parse file CONTENTS (pure).
    ///
    /// **This must survive a file that is already wrong.** oden's `/etc/subuid`
    /// contains `github:100000:65536` AND `alice:100000:65536` — two accounts
    /// granted the identical range, which shadow-utils would never produce and
    /// which nothing on the host complains about. The parser reads it, the
    /// allocator routes around it, and neither "fixes" it: repairing somebody
    /// else's grant would break whatever is currently running inside it.
    pub fn parse(contents: &str) -> SubIdFile {
        let mut lines = Vec::new();
        for line in contents.lines() {
            let t = line.trim();
            let parsed = (|| {
                if t.is_empty() || t.starts_with('#') {
                    return None;
                }
                let f: Vec<&str> = t.split(':').collect();
                if f.len() != 3 {
                    return None;
                }
                let start: u32 = f[1].trim().parse().ok()?;
                let count: u32 = f[2].trim().parse().ok()?;
                if count == 0 || f[0].is_empty() {
                    return None;
                }
                Some(SubIdRange {
                    owner: f[0].to_string(),
                    start,
                    count,
                })
            })();
            lines.push(match parsed {
                Some(r) => SubIdLine::Range(r),
                None => SubIdLine::Other(line.to_string()),
            });
        }
        SubIdFile {
            lines,
            trailing_newline: contents.is_empty() || contents.ends_with('\n'),
        }
    }

    /// Render back to text. Unchanged input round-trips byte-for-byte.
    pub fn render(&self) -> String {
        let mut s = String::new();
        for (i, l) in self.lines.iter().enumerate() {
            if i > 0 {
                s.push('\n');
            }
            match l {
                SubIdLine::Range(r) => s.push_str(&r.render()),
                SubIdLine::Other(o) => s.push_str(o),
            }
        }
        if self.trailing_newline && !s.is_empty() {
            s.push('\n');
        }
        s
    }

    pub fn ranges(&self) -> impl Iterator<Item = &SubIdRange> {
        self.lines.iter().filter_map(|l| match l {
            SubIdLine::Range(r) => Some(r),
            SubIdLine::Other(_) => None,
        })
    }

    /// The grant for `owner`, if any. First match wins, as shadow-utils reads it.
    pub fn range_for(&self, owner: &str) -> Option<&SubIdRange> {
        self.ranges().find(|r| r.owner == owner)
    }

    /// **Overlaps that ALREADY exist in the file**, reported and never repaired.
    ///
    /// Worth surfacing because it is invisible otherwise and it explains
    /// otherwise-impossible container behaviour (two accounts writing into one
    /// another's namespace ids). oden has one.
    pub fn existing_overlaps(&self) -> Vec<(String, String)> {
        let rs: Vec<&SubIdRange> = self.ranges().collect();
        let mut out = Vec::new();
        for (i, a) in rs.iter().enumerate() {
            for b in rs.iter().skip(i + 1) {
                if a.overlaps(b) {
                    out.push((a.render(), b.render()));
                }
            }
        }
        out
    }

    /// True if `[start, start+count)` collides with any grant in the file.
    fn collides(&self, start: u32, count: u32) -> bool {
        let probe = SubIdRange {
            owner: String::new(),
            start,
            count,
        };
        self.ranges().any(|r| r.overlaps(&probe))
    }

    /// Append a grant, returning a new file. Never mutates in place, so a caller
    /// can render a proposal without committing to it.
    pub fn with_range(&self, r: SubIdRange) -> SubIdFile {
        let mut out = self.clone();
        out.lines.push(SubIdLine::Range(r));
        out.trailing_newline = true;
        out
    }
}

/// The allocation policy — **read from the host, never invented.**
///
/// shadow-utils publishes it in `/etc/login.defs` as `SUB_UID_MIN` / `SUB_UID_MAX`
/// / `SUB_UID_COUNT` (and the `SUB_GID_*` triple). VERIFIED on t14s: `100000` /
/// `600100000` / `65536`. The fallback constants below are shadow's own compiled
/// defaults, used only when the file is absent — which is itself reported.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubIdPolicy {
    pub min: u32,
    pub max: u32,
    pub count: u32,
}

/// Which of the two id spaces a policy or file refers to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdKind {
    Uid,
    Gid,
}

impl IdKind {
    pub fn file(self) -> &'static str {
        match self {
            IdKind::Uid => "/etc/subuid",
            IdKind::Gid => "/etc/subgid",
        }
    }
    fn login_defs_prefix(self) -> &'static str {
        match self {
            IdKind::Uid => "SUB_UID",
            IdKind::Gid => "SUB_GID",
        }
    }
}

impl SubIdPolicy {
    /// shadow-utils' compiled defaults, used only when `/etc/login.defs` is silent.
    pub const SHADOW_DEFAULT: SubIdPolicy = SubIdPolicy {
        min: 100_000,
        max: 600_100_000,
        count: 65_536,
    };

    /// Parse `/etc/login.defs` CONTENTS for one id space (pure).
    pub fn from_login_defs(contents: &str, kind: IdKind) -> SubIdPolicy {
        let p = kind.login_defs_prefix();
        let get = |suffix: &str| -> Option<u32> {
            let key = format!("{p}_{suffix}");
            for line in contents.lines() {
                let t = line.trim();
                if t.starts_with('#') {
                    continue;
                }
                let mut w = t.split_whitespace();
                if w.next()? == key {
                    return w.next()?.parse().ok();
                }
            }
            None
        };
        SubIdPolicy {
            min: get("MIN").unwrap_or(SubIdPolicy::SHADOW_DEFAULT.min),
            max: get("MAX").unwrap_or(SubIdPolicy::SHADOW_DEFAULT.max),
            count: get("COUNT").unwrap_or(SubIdPolicy::SHADOW_DEFAULT.count),
        }
    }
}

/// **Pick a range that overlaps nothing** — in BOTH files at once.
///
/// The algorithm is the one shadow-utils and podman visibly use, and it is
/// deliberately boring: walk the aligned slots `min`, `min+count`, `min+2*count`, …
/// and take the first that collides with no existing grant. oden's hand-typed
/// `korp:296608:65536` is `100000 + 3*65536` — the same lattice, which is how the
/// grant landed on a value nobody chose by hand.
///
/// **Uid and gid are allocated TOGETHER, to the same start.** Nothing requires it
/// mathematically, but every tool that writes these files keeps them equal, and a
/// service whose subuid and subgid bases differ produces container files whose
/// owner and group map to unrelated host ids — debuggable only by someone who
/// already knows to look here.
///
/// Returns `Err` rather than a range when the policy window is exhausted. Refusing
/// beats wrapping around into somebody's real uid space.
pub fn allocate_pair(
    owner: &str,
    subuid: &SubIdFile,
    subgid: &SubIdFile,
    policy: SubIdPolicy,
) -> Result<SubIdRange> {
    let count = policy.count.max(1);
    let mut start = policy.min;
    loop {
        if start as u64 + count as u64 - 1 > policy.max as u64 {
            anyhow::bail!(
                "no free subordinate-id range for `{owner}`: every aligned slot of {count} \
                 between {} and {} is already granted in {} or {}. Widen SUB_UID_MAX/\
                 SUB_GID_MAX in /etc/login.defs, or reclaim a range — this will not \
                 allocate outside the host's own policy window.",
                policy.min,
                policy.max,
                IdKind::Uid.file(),
                IdKind::Gid.file(),
            );
        }
        if !subuid.collides(start, count) && !subgid.collides(start, count) {
            return Ok(SubIdRange {
                owner: owner.to_string(),
                start,
                count,
            });
        }
        start = match start.checked_add(count) {
            Some(s) => s,
            None => anyhow::bail!("subordinate-id search overflowed u32 for `{owner}`"),
        };
    }
}

// ---------------------------------------------------------------------------
// Steps, states, findings
// ---------------------------------------------------------------------------

/// One host fact a rootless service account needs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
    /// `newuidmap`/`newgidmap` exist. Without them a rootless container gets ONE
    /// uid — its own — and any image with a second owner fails to unpack.
    IdMapTools,
    /// A `/etc/subuid` grant for the account.
    SubUid,
    /// A `/etc/subgid` grant for the account.
    SubGid,
    /// Lingering, so the login manager keeps a user manager and a runtime dir for
    /// an account that will never log in.
    Linger,
    /// `/run/user/<uid>` exists and belongs to the account.
    RuntimeDir,
    /// The account's own container-engine socket unit is ENABLED in its user manager,
    /// so it exists because it was declared rather than because something asked for it
    /// at the right moment.
    ///
    /// This step is the difference between a host that works and a host that KEEPS
    /// working. MEASURED on t14s 2026-08-15, after the five steps above were all
    /// satisfied: `podman.socket` in the `korp` account's user manager was `active` but
    /// `disabled` — an on-demand start by a client that happened to ask (knut's
    /// `ensure_podman_socket`, which can only ask once lingering has given the account a
    /// session bus). Nothing had enabled it, so it would not have come back on the next
    /// boot, and a service that starts containers at boot would have been racing a client
    /// that starts sockets on demand.
    ///
    /// That is the same defect this crate exists to remove, one level down: a service
    /// that only works because of a command nobody's installer ran.
    ContainerSocket,
}

impl Step {
    pub fn id(self) -> &'static str {
        match self {
            Step::IdMapTools => "idmap-tools",
            Step::SubUid => "subuid",
            Step::SubGid => "subgid",
            Step::Linger => "linger",
            Step::RuntimeDir => "runtime-dir",
            Step::ContainerSocket => "container-socket",
        }
    }
}

/// A mutation this crate is willing to perform. Data, so it can be PRINTED by a
/// dry run and executed by [`apply`] from the same value — the plan and the doing
/// cannot drift.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
    /// Append `owner:start:count` to a subid file.
    GrantSubId {
        file: PathBuf,
        range: SubIdRange,
    },
    /// `org.freedesktop.login1.Manager.SetUserLinger(uid, true, false)`.
    EnableLinger {
        name: String,
        uid: u32,
        logind: Logind,
    },
    /// Enable a unit in the ACCOUNT'S OWN user manager, by writing the `.wants` symlink
    /// that IS the enablement — `<home>/.config/systemd/user/sockets.target.wants/<unit>`
    /// → the packaged unit file, owned by the account.
    ///
    /// **Why a symlink and not `EnableUnitFiles` over D-Bus.** Both doors are shut for a
    /// root caller, MEASURED on t14s 2026-08-15:
    ///
    ///   * the SESSION bus (`/run/user/<uid>/bus`) is mode 0666 but its policy admits one
    ///     uid, so root is refused during EXTERNAL authentication — "failed to read from
    ///     socket";
    ///   * the user manager's PRIVATE socket (`/run/user/<uid>/systemd/private`) does
    ///     admit root, and zbus cannot speak it: sd-bus's direct protocol carries a sender
    ///     field that is not a valid D-Bus unique name, so zbus 5.19 PANICS its connection
    ///     executor (`message/fields.rs:219`, `InvalidName`) while parsing the reply. The
    ///     calling thread then waits on a futex forever — an install sat there 11 minutes
    ///     with 2 seconds of CPU, its work already done and its reply unparseable.
    ///
    /// So the D-Bus route is not "less elegant", it is unavailable, and shelling
    /// `systemctl` is not an option this crate has. The symlink is not a workaround for
    /// the API — it is what the API writes: `systemctl --user is-enabled` answers by
    /// reading exactly this link, which is why enabling this way is indistinguishable from
    /// enabling that way.
    ///
    /// Ordered after [`Change::EnableLinger`], and still load-bearing even without a bus:
    /// the account's manager must exist for the enablement to mean anything at boot.
    EnableUserSocket {
        name: String,
        uid: u32,
        gid: u32,
        /// The packaged unit file the link points AT (`/usr/lib/systemd/user/podman.socket`).
        unit_path: String,
        /// The link itself — `<home>/.config/systemd/user/sockets.target.wants/<unit>`.
        link: PathBuf,
    },
}

impl Change {
    /// The change in one line, for a dry run.
    pub fn describe(&self) -> String {
        match self {
            Change::GrantSubId { file, range } => format!(
                "append `{}` to {} (atomic temp-file + rename, mode preserved)",
                range.render(),
                file.display()
            ),
            Change::EnableLinger { name, uid, logind } => format!(
                "SetUserLinger({uid}, true) on {} — the D-Bus call `loginctl enable-linger {name}` makes",
                logind.id()
            ),
            Change::EnableUserSocket {
                name,
                uid,
                unit_path,
                link,
                ..
            } => format!(
                "symlink {} -> {unit_path}, owned by `{name}` (uid {uid}) — the file \
                 `systemctl --user enable` writes, and the one `is-enabled` reads back",
                link.display()
            ),
        }
    }
}

/// What [`inspect`] found for one [`Step`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
    /// Already correct. Idempotency lives here: [`apply`] does nothing for these.
    Satisfied(String),
    /// Not correct, and this crate can fix it.
    Needed { detail: String, change: Change },
    /// Will become true as a CONSEQUENCE of another step — nothing to do directly.
    /// `/run/user/<uid>` is the only one: logind creates it when linger is set, and
    /// a tool that `mkdir`s it itself produces a directory logind does not own and
    /// will not populate.
    Follows(String),
    /// This host genuinely cannot do it. Not a no-op, not a soft failure — the
    /// mechanism does not exist here.
    Unsupported(String),
    /// The fix is a HOST decision (install a package), stated with the exact
    /// command for this family and NOT performed.
    Manual(String),
    /// Refuse: proceeding would be a guess, or would be silently undone.
    Refused(String),
}

impl State {
    /// Does this state stop [`apply`] from running at all?
    pub fn is_blocking(&self) -> bool {
        matches!(
            self,
            State::Unsupported(_) | State::Manual(_) | State::Refused(_)
        )
    }
    pub fn detail(&self) -> &str {
        match self {
            State::Satisfied(s)
            | State::Follows(s)
            | State::Unsupported(s)
            | State::Manual(s)
            | State::Refused(s) => s,
            State::Needed { detail, .. } => detail,
        }
    }
    pub fn label(&self) -> &'static str {
        match self {
            State::Satisfied(_) => "OK",
            State::Needed { .. } => "NEEDED",
            State::Follows(_) => "FOLLOWS",
            State::Unsupported(_) => "UNSUPPORTED",
            State::Manual(_) => "MANUAL",
            State::Refused(_) => "REFUSED",
        }
    }
}

/// One step and what was found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
    pub step: Step,
    pub state: State,
}

/// **Everything [`assess`] reads** — as data, so the whole decision matrix is
/// testable against fixtures with no host, no root and no VM.
///
/// `matrix.rs` is where booting a real Alpine and a real Ubuntu would prove the
/// READING of these values; the BEHAVIOUR they drive is pure and belongs here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Inputs {
    pub host: Host,
    pub account_name: String,
    /// Contents of `/etc/passwd`.
    pub passwd: String,
    /// Contents of `/etc/subuid`; `None` when the file could not be read.
    pub subuid: Option<String>,
    /// Contents of `/etc/subgid`; `None` when the file could not be read.
    pub subgid: Option<String>,
    /// Contents of `/etc/login.defs` (empty ⇒ shadow's compiled defaults).
    pub login_defs: String,
    /// logind's linger marker for this account exists.
    pub linger_marker: bool,
    /// Owner uid of `/run/user/<uid>`, or `None` when the directory is absent.
    pub runtime_dir_owner: Option<u32>,
    /// The container-engine socket unit this host ships for USER managers, as the path it
    /// was found at (`/usr/lib/systemd/user/podman.socket` and friends). `None` ⇒ the host
    /// has no such unit, which is a statement about the host, not about the account.
    pub container_socket_unit: Option<String>,
    /// The account has ENABLED that unit — the `sockets.target.wants/<unit>` symlink under
    /// its own `~/.config/systemd/user`, which is what `systemctl --user enable` writes and
    /// what makes the socket come back after a reboot.
    pub container_socket_enabled: bool,
}

/// The result of an inspection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
    pub host: Host,
    pub account_name: String,
    /// `None` when the account has no `/etc/passwd` entry — a hard refusal.
    pub account: Option<Account>,
    pub findings: Vec<Finding>,
    /// Overlaps that already existed in `/etc/subuid` — reported, never repaired.
    pub existing_subuid_overlaps: Vec<(String, String)>,
}

impl Report {
    /// Is the host already set up? True only when every step is
    /// [`State::Satisfied`] — `Follows` is not ready, it is pending.
    pub fn is_ready(&self) -> bool {
        !self.findings.is_empty()
            && self
                .findings
                .iter()
                .all(|f| matches!(f.state, State::Satisfied(_)))
    }

    /// Findings that stop [`apply`].
    pub fn blockers(&self) -> Vec<&Finding> {
        self.findings
            .iter()
            .filter(|f| f.state.is_blocking())
            .collect()
    }

    /// The mutations [`apply`] would perform, in order. **This is the dry run**, and
    /// it is worth more than the doing: an empty plan on a blocked host is not
    /// "nothing to do", so read [`Report::blockers`] as well.
    pub fn plan(&self) -> Vec<Change> {
        self.findings
            .iter()
            .filter_map(|f| match &f.state {
                State::Needed { change, .. } => Some(change.clone()),
                _ => None,
            })
            .collect()
    }

    /// The whole report as text, one line per step — the crisp column form.
    pub fn render(&self) -> String {
        let mut s = format!(
            "rootless host setup for `{}` on {}\n",
            self.account_name,
            self.host.summary()
        );
        match &self.account {
            Some(a) => s.push_str(&format!(
                "  account          uid={} gid={} shell={} runtime-dir={}\n",
                a.uid,
                a.gid,
                a.shell,
                a.runtime_dir().display()
            )),
            None => s.push_str("  account          ABSENT from /etc/passwd\n"),
        }
        for f in &self.findings {
            s.push_str(&format!(
                "  {:<16} {:<12} {}\n",
                f.step.id(),
                f.state.label(),
                f.state.detail()
            ));
        }
        for (a, b) in &self.existing_subuid_overlaps {
            s.push_str(&format!(
                "  NOTE             /etc/subuid already contains overlapping grants \
                 `{a}` and `{b}` — left exactly as found; this tool allocates around \
                 them and repairs nothing it did not write\n"
            ));
        }
        s
    }
}

/// **The whole decision, as a pure function of [`Inputs`].**
///
/// No filesystem, no bus, no privileges. Every refusal, every "unsupported", every
/// proposed range is decided here, which is why the distro-family behaviour is
/// unit-testable without four VMs.
pub fn assess(inp: &Inputs) -> Report {
    let account = Account::from_passwd(&inp.passwd, &inp.account_name);
    let subuid_file = inp.subuid.as_deref().map(SubIdFile::parse);
    let subgid_file = inp.subgid.as_deref().map(SubIdFile::parse);
    let overlaps = subuid_file
        .as_ref()
        .map(|f| f.existing_overlaps())
        .unwrap_or_default();

    let mut findings = Vec::new();

    // ── 1. the setuid id-mapping helpers ────────────────────────────────────
    //
    // First because it is the step whose absence produces the most misleading
    // error: podman reports "potentially insufficient UIDs or GIDs available in
    // user namespace (requested 111:111)", which reads like a subuid problem even
    // when the grant is present and the helper is what is missing.
    findings.push(Finding {
        step: Step::IdMapTools,
        state: if inp.host.facts.newuidmap && inp.host.facts.newgidmap {
            State::Satisfied(
                "newuidmap and newgidmap are installed (this does NOT verify they are \
                 setuid-root or carry file capabilities — reading xattrs is outside \
                 std, and a helper without either still fails at run time)"
                    .into(),
            )
        } else {
            let missing = match (inp.host.facts.newuidmap, inp.host.facts.newgidmap) {
                (false, false) => "newuidmap and newgidmap are",
                (false, true) => "newuidmap is",
                _ => "newgidmap is",
            };
            match (
                inp.host.family.uidmap_package(),
                inp.host.family.package_manager(),
            ) {
                (Some(pkg), Some(pm)) => State::Manual(format!(
                    "{missing} absent. On the {} family they come from `{pkg}`: {}. \
                     Installing a package is a host decision and a subprocess — this \
                     crate will not do it.",
                    inp.host.family,
                    pm.install_command(pkg)
                )),
                _ => State::Refused(format!(
                    "{missing} absent, and the distro family is `{}` — this crate does \
                     not know which package supplies them here, and will not guess a \
                     package name.",
                    inp.host.family
                )),
            }
        },
    });

    // ── 2 & 3. the subordinate id grants ────────────────────────────────────
    let policy_uid = SubIdPolicy::from_login_defs(&inp.login_defs, IdKind::Uid);
    let policy_gid = SubIdPolicy::from_login_defs(&inp.login_defs, IdKind::Gid);
    // The pair must land on one start (see `allocate_pair`), so the narrower of the
    // two policies governs: a range legal for uids and illegal for gids is not a
    // range this will hand out.
    let policy = SubIdPolicy {
        min: policy_uid.min.max(policy_gid.min),
        max: policy_uid.max.min(policy_gid.max),
        count: policy_uid.count.min(policy_gid.count),
    };

    let proposed = match (&subuid_file, &subgid_file, &account) {
        (Some(u), Some(g), Some(_)) => Some(allocate_pair(&inp.account_name, u, g, policy)),
        _ => None,
    };

    for (step, kind, file) in [
        (Step::SubUid, IdKind::Uid, &subuid_file),
        (Step::SubGid, IdKind::Gid, &subgid_file),
    ] {
        let state = match (file, &account) {
            (_, None) => State::Refused(format!(
                "account `{}` has no /etc/passwd entry — there is nothing to grant a \
                 subordinate id range TO, and creating the account is not this crate's \
                 decision.",
                inp.account_name
            )),
            (None, _) => State::Refused(format!(
                "{} could not be read. It is not optional: without it rootless podman \
                 has exactly one id to map. Refusing rather than creating a file whose \
                 intended ownership and mode are unknown.",
                kind.file()
            )),
            (Some(f), Some(_)) => match f.range_for(&inp.account_name) {
                Some(r) => State::Satisfied(format!(
                    "{} already grants `{}` ({} ids from {})",
                    kind.file(),
                    r.render(),
                    r.count,
                    r.start
                )),
                None => match &proposed {
                    Some(Ok(r)) => State::Needed {
                        detail: format!(
                            "no grant for `{}` in {}",
                            inp.account_name,
                            kind.file()
                        ),
                        change: Change::GrantSubId {
                            file: PathBuf::from(kind.file()),
                            range: r.clone(),
                        },
                    },
                    Some(Err(e)) => State::Refused(format!("{e:#}")),
                    None => State::Refused(format!(
                        "cannot allocate a range for {} without reading both \
                         /etc/subuid and /etc/subgid — the pair is granted together.",
                        kind.file()
                    )),
                },
            },
        };
        // NixOS overrides everything above: the file is generated, so a correct
        // append is a change that disappears at the next rebuild.
        let state = if inp.host.family.etc_is_generated() && !matches!(state, State::Satisfied(_)) {
            State::Refused(format!(
                "{} on {} is GENERATED from the system configuration. An appended line \
                 survives until the next `nixos-rebuild` and then vanishes, taking every \
                 container that depended on it. Declare it instead — \
                 `users.users.{}.subUidRanges` / `autoSubUidGidRange` — and rebuild.",
                kind.file(),
                inp.host.family,
                inp.account_name
            ))
        } else {
            state
        };
        findings.push(Finding { step, state });
    }

    // ── 4. lingering ────────────────────────────────────────────────────────
    //
    // The step that is NOT portable, and the reason a family type had to exist.
    // Lingering is a login-manager concept: `SetUserLinger` on
    // `org.freedesktop.login1`. On a host with no login manager there is no
    // `loginctl`, no such bus name and no such operation — and saying so is the
    // deliverable. A "portable" interface that no-ops here would hand back a
    // successful setup and a service that cannot start.
    let linger_state = match (&account, inp.host.logind) {
        (None, _) => State::Refused(format!(
            "account `{}` has no /etc/passwd entry — SetUserLinger takes a uid and \
             there is none.",
            inp.account_name
        )),
        (Some(_), None) => {
            let mut m = format!(
                "this host runs {} with NO login manager, so lingering does not exist \
                 here: there is no `loginctl`, no org.freedesktop.login1, and nothing \
                 will create /run/user/<uid> for an account with no session.",
                inp.host.init
            );
            match inp.host.family.elogind_package() {
                Some(pkg) => m.push_str(&format!(
                    " `{pkg}` provides exactly that interface on the {} family and the \
                     same D-Bus call then works — installing it is a host decision. \
                     Otherwise a rootless engine on this host needs its runtime dir \
                     provided another way (an explicit XDG_RUNTIME_DIR the service \
                     itself owns), which is a different design, not this one.",
                    inp.host.family
                )),
                None => m.push_str(
                    " No packaged login manager is known for this family — a rootless \
                     engine here needs a different runtime-dir design.",
                ),
            }
            State::Unsupported(m)
        }
        (Some(a), Some(l)) => {
            if inp.linger_marker {
                State::Satisfied(format!(
                    "{}/{} exists — {} keeps a user manager and a runtime dir for this \
                     account with no session",
                    l.linger_dir(),
                    a.name,
                    l.id()
                ))
            } else {
                State::Needed {
                    detail: format!(
                        "`{}` (uid {}, shell {}) does not linger, so {} tears its \
                         runtime dir down the moment nothing holds it — and a nologin \
                         service account never holds it in the first place",
                        a.name,
                        a.uid,
                        a.shell,
                        l.id()
                    ),
                    change: Change::EnableLinger {
                        name: a.name.clone(),
                        uid: a.uid,
                        logind: l,
                    },
                }
            }
        }
    };
    findings.push(Finding {
        step: Step::Linger,
        state: linger_state,
    });

    // ── 5. the runtime directory ────────────────────────────────────────────
    //
    // Never `Needed`: this crate does not create it. logind does, on linger, with
    // ownership and a tmpfs mount a `mkdir` cannot reproduce — a hand-made
    // /run/user/<uid> is a directory logind neither owns nor populates, and the
    // podman socket never appears inside it.
    let runtime_state = match (&account, inp.runtime_dir_owner, inp.host.logind) {
        (None, _, _) => State::Refused("no account, so no runtime dir to check".into()),
        (Some(a), Some(owner), _) if owner == a.uid => State::Satisfied(format!(
            "{} exists and belongs to uid {} — a rootless podman socket lands at {}",
            a.runtime_dir().display(),
            a.uid,
            a.podman_socket().display()
        )),
        (Some(a), Some(owner), _) => State::Refused(format!(
            "{} exists but belongs to uid {owner}, not {} — refusing to touch another \
             account's runtime directory.",
            a.runtime_dir().display(),
            a.uid
        )),
        (Some(a), None, Some(l)) => State::Follows(format!(
            "{} does not exist yet; {} creates it when lingering is enabled. This crate \
             does not mkdir it — a hand-made runtime dir is one logind does not own and \
             will not populate.",
            a.runtime_dir().display(),
            l.id()
        )),
        (Some(a), None, None) => State::Unsupported(format!(
            "{} does not exist and there is no login manager to create it.",
            a.runtime_dir().display()
        )),
    };
    findings.push(Finding {
        step: Step::RuntimeDir,
        state: runtime_state,
    });

    // ── 6. the account's OWN container-engine socket, ENABLED ───────────────
    //
    // The five steps above make a rootless container POSSIBLE. This one makes the
    // socket a service can connect to actually BE THERE — at boot, on every boot,
    // because the account declared it rather than because a client asked at the
    // right moment.
    //
    // The failure it removes is not a missing capability but a race, and races are
    // the defects that survive a working demo: on t14s the socket was `active` and
    // `disabled` at the same time, so everything worked until the next reboot.
    //
    // `Needed` only when the host actually ships a user socket unit. Where it does
    // not, this is `Manual` rather than `Refused`: installing a container engine is a
    // host decision, exactly as `IdMapTools` treats the id-mapping helpers, and the
    // wording matches so the two read the same way in one report.
    let socket_state = match (&account, &inp.container_socket_unit) {
        (None, _) => State::Refused("no account, so no user manager to enable a socket in".into()),
        (Some(a), Some(unit_path)) => {
            let unit = unit_file_name(unit_path);
            if inp.container_socket_enabled {
                State::Satisfied(format!(
                    "`{unit}` is enabled in `{}`'s user manager — logind starts it with the \
                     account's manager, so {} exists at boot rather than when a client \
                     happens to ask",
                    a.name,
                    a.podman_socket().display()
                ))
            } else {
                State::Needed {
                    detail: format!(
                        "the host ships `{unit}` ({unit_path}) but `{}` has not enabled it, \
                         so {} exists only while some client keeps starting it on demand — \
                         and after a reboot a service that starts containers is racing a \
                         client that starts sockets",
                        a.name,
                        a.podman_socket().display()
                    ),
                    change: Change::EnableUserSocket {
                        name: a.name.clone(),
                        uid: a.uid,
                        gid: a.gid,
                        unit_path: unit_path.clone(),
                        // The same path `read_inputs` reads enablement from, built from the
                        // same three pieces — one spelling of "where the link lives", or the
                        // reader and the writer can disagree about whether it is done.
                        link: user_wants_link(&a.home, &unit),
                    },
                }
            }
        }
        (Some(_), None) => match (
            inp.host.family.container_engine_package(),
            inp.host.family.package_manager(),
        ) {
            (Some(pkg), Some(pm)) => State::Manual(format!(
                "this host ships no user-scope container socket unit (looked in {}). On the \
                 {} family it comes from `{pkg}`: {}. Installing a package is a host \
                 decision and a subprocess — this crate will not do it.",
                USER_UNIT_DIRS.join(", "),
                inp.host.family,
                pm.install_command(pkg)
            )),
            _ => State::Manual(format!(
                "this host ships no user-scope container socket unit (looked in {}), and \
                 the distro family is `{}` — this crate does not know which package \
                 supplies one here and will not guess a package name. A service that runs \
                 containers as this account needs one, or a DOCKER_HOST pointing at an \
                 engine it may open.",
                USER_UNIT_DIRS.join(", "),
                inp.host.family
            )),
        },
    };
    findings.push(Finding {
        step: Step::ContainerSocket,
        state: socket_state,
    });

    Report {
        host: inp.host.clone(),
        account_name: inp.account_name.clone(),
        account,
        findings,
        existing_subuid_overlaps: overlaps,
    }
}

// ---------------------------------------------------------------------------
// Reading the real host
// ---------------------------------------------------------------------------

/// Read every input off the running host. Read-only; no privileges needed for the
/// parts that matter (see the module note on logind's readable marker).
pub fn read_inputs(account: &str) -> Inputs {
    let host = Host::detect();
    let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
    let acct = Account::from_passwd(&passwd, account);
    let linger_marker = host
        .logind
        .map(|l| Path::new(l.linger_dir()).join(account).exists())
        .unwrap_or(false);
    let runtime_dir_owner = acct.as_ref().and_then(|a| {
        #[cfg(unix)]
        {
            use std::os::unix::fs::MetadataExt;
            std::fs::metadata(a.runtime_dir()).ok().map(|m| m.uid())
        }
        #[cfg(not(unix))]
        {
            let _ = a;
            None
        }
    });
    // The engine's user socket unit, as the host ships it. Searched in systemd's own
    // order and read as a PATH rather than a yes/no, so the report can name the file it
    // found instead of asserting that one exists somewhere.
    let container_socket_unit = USER_UNIT_DIRS
        .iter()
        .map(|d| Path::new(d).join(CONTAINER_SOCKET_UNIT))
        .find(|p| p.exists())
        .map(|p| p.to_string_lossy().into_owned());
    // Enablement is a SYMLINK in the account's own config, which is what makes this
    // readable with no bus, no root and no running user manager — the same posture as the
    // linger marker above. Asking systemd would mean talking to a manager that may not be
    // running yet, and getting "inactive" for an account that is merely not lingering.
    let container_socket_enabled = acct
        .as_ref()
        .map(|a| {
            // `symlink_metadata`, not `exists()`: a dangling link is still an enablement
            // systemd will act on (and complain about), and `exists()` follows the link, so
            // it would call a broken enablement "not done" and write a second one.
            std::fs::symlink_metadata(user_wants_link(&a.home, CONTAINER_SOCKET_UNIT)).is_ok()
        })
        .unwrap_or(false);
    Inputs {
        host,
        account_name: account.to_string(),
        passwd,
        subuid: std::fs::read_to_string(IdKind::Uid.file()).ok(),
        subgid: std::fs::read_to_string(IdKind::Gid.file()).ok(),
        login_defs: std::fs::read_to_string("/etc/login.defs").unwrap_or_default(),
        linger_marker,
        runtime_dir_owner,
        container_socket_unit,
        container_socket_enabled,
    }
}

/// **"Is this host already set up?"** — the read-only answer, which must be
/// available before anyone asks for the mutation.
pub fn inspect(account: &str) -> Report {
    assess(&read_inputs(account))
}

// ---------------------------------------------------------------------------
// Applying
// ---------------------------------------------------------------------------

/// What [`apply`] did.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Applied {
    /// One line per mutation actually performed. Empty on an already-set-up host,
    /// which is the idempotent case and is a success.
    pub performed: Vec<String>,
}

/// **Perform the plan.** Refuses entirely if anything is blocking.
///
/// The refusal is the point. A host missing `newuidmap` can still have its subuid
/// grant written and its linger enabled — and would then look configured while
/// still being unable to run a container. Half-applied is worse than none, so a
/// single [`State::Unsupported`] / [`State::Manual`] / [`State::Refused`] stops the
/// whole run and returns every blocker at once, rather than the first.
///
/// Idempotent: it acts only on [`State::Needed`], so a second call on a set-up host
/// performs nothing and succeeds.
///
/// Needs root — writing `/etc/subuid` and calling `SetUserLinger` both do — and
/// says which operation was refused rather than "permission denied".
pub fn apply(report: &Report) -> Result<Applied> {
    let blockers = report.blockers();
    if !blockers.is_empty() {
        let mut m = format!(
            "refusing to set up rootless containers for `{}` — {} step(s) cannot be \
             completed, and a half-applied host setup is worse than none:",
            report.account_name,
            blockers.len()
        );
        for b in blockers {
            m.push_str(&format!(
                "\n  [{}] {}: {}",
                b.state.label(),
                b.step.id(),
                b.state.detail()
            ));
        }
        anyhow::bail!(m);
    }

    let mut applied = Applied::default();
    for change in report.plan() {
        match &change {
            Change::GrantSubId { file, range } => {
                let current = std::fs::read_to_string(file)
                    .with_context(|| format!("read {} before granting", file.display()))?;
                let parsed = SubIdFile::parse(&current);
                // Re-check under the file we just read: between `inspect` and here
                // another writer (usermod, podman, a second install) may have
                // granted the same account. Idempotency has to hold against THAT
                // too, not just against our own earlier run.
                if let Some(existing) = parsed.range_for(&range.owner) {
                    applied.performed.push(format!(
                        "{}: `{}` already granted by another writer since inspection — \
                         left alone",
                        file.display(),
                        existing.render()
                    ));
                    continue;
                }
                let next = parsed.with_range(range.clone());
                write_atomically(file, &next.render())?;
                applied
                    .performed
                    .push(format!("{}: granted `{}`", file.display(), range.render()));
            }
            Change::EnableLinger { name, uid, logind } => {
                set_linger(*uid, true).with_context(|| {
                    format!("enable lingering for `{name}` (uid {uid}) on {}", logind.id())
                })?;
                applied.performed.push(format!(
                    "{}: lingering enabled for `{name}` (uid {uid}) — {} will now create {}",
                    logind.id(),
                    logind.id(),
                    format_args!("/run/user/{uid}")
                ));
            }
            Change::EnableUserSocket {
                name,
                uid,
                gid,
                unit_path,
                link,
            } => {
                enable_user_unit(link, unit_path, *uid, *gid).with_context(|| {
                    format!(
                        "enable `{}` in `{name}`'s user manager (uid {uid})",
                        unit_file_name(unit_path)
                    )
                })?;
                applied.performed.push(format!(
                    "`{name}` (uid {uid}): {} -> {unit_path} — `{}` now comes up with the \
                     account's own manager at boot, instead of only when a client asks",
                    link.display(),
                    unit_file_name(unit_path)
                ));
            }
        }
    }
    Ok(applied)
}

/// Convenience: inspect the running host, then apply. Returns the report as well,
/// so a caller can print what was found even when the apply refused.
pub fn ensure(account: &str) -> (Report, Result<Applied>) {
    let report = inspect(account);
    let outcome = apply(&report);
    (report, outcome)
}

/// Write `contents` to `path` through a same-directory temp file + `rename`,
/// preserving the original mode.
///
/// `>>` cannot do this. An append that is interrupted leaves a truncated grant
/// line, and `/etc/subuid` with a half-written line is a file shadow-utils
/// silently ignores from that line on. Same directory so the rename is a rename
/// and not a cross-device copy.
fn write_atomically(path: &Path, contents: &str) -> Result<()> {
    let dir = path.parent().unwrap_or(Path::new("."));
    let tmp = dir.join(format!(
        ".{}.skidbladnir.tmp",
        path.file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| "subid".into())
    ));
    std::fs::write(&tmp, contents)
        .with_context(|| format!("write staging file {}", tmp.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        // Mirror the existing file's mode; fall back to shadow's own 0644 for these
        // files (they are world-readable by design — every container runtime reads
        // them unprivileged).
        let mode = std::fs::metadata(path)
            .map(|m| m.permissions().mode() & 0o7777)
            .unwrap_or(0o644);
        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
    }
    std::fs::rename(&tmp, path).with_context(|| {
        format!(
            "rename {} onto {} (the atomic step)",
            tmp.display(),
            path.display()
        )
    })?;
    Ok(())
}

/// **Set lingering over D-Bus** — `org.freedesktop.login1.Manager.SetUserLinger`.
///
/// The pure-Rust equivalent of `loginctl enable-linger <user>`, and the same shape
/// [`crate::systemd::Systemd`] already uses for `org.freedesktop.systemd1`: a
/// blocking `zbus` call on the SYSTEM bus, no `libdbus`, no subprocess.
///
/// `interactive = false` deliberately: a polkit prompt has nowhere to go from an
/// installer, so this either has the privilege or fails saying it does not.
///
/// Both logind implementations own this bus name — systemd-logind and elogind —
/// so one call covers both and there is no branch here on which is running.
#[cfg(unix)]
pub fn set_linger(uid: u32, enable: bool) -> Result<()> {
    let conn = zbus::block_on(zbus::Connection::system()).map_err(|e| {
        anyhow::anyhow!(
            "connect the system bus to reach org.freedesktop.login1: {e}. Lingering is a \
             login-manager operation; without a bus there is no `loginctl` equivalent to \
             fall back to, and this crate does not shell out."
        )
    })?;
    zbus::block_on(conn.call_method(
        Some("org.freedesktop.login1"),
        "/org/freedesktop/login1",
        Some("org.freedesktop.login1.Manager"),
        "SetUserLinger",
        // (uid: u, enable: b, interactive: b)
        &(uid, enable, false),
    ))
    .map_err(|e| {
        anyhow::anyhow!("SetUserLinger({uid}, {enable}) on org.freedesktop.login1: {e}")
    })?;
    Ok(())
}

#[cfg(not(unix))]
pub fn set_linger(_uid: u32, _enable: bool) -> Result<()> {
    anyhow::bail!("lingering is a Linux login-manager concept; there is nothing to set here")
}

/// **Enable a unit in another account's user manager, by writing the link that IS the
/// enablement** — `<home>/.config/systemd/user/sockets.target.wants/<unit>` → the packaged
/// unit file, with every directory it had to create, and the link itself, owned by that
/// account.
///
/// `systemctl --user enable` writes this symlink; `systemctl --user is-enabled` answers by
/// reading it. Doing it here is therefore not an imitation of the API — it is the API's
/// effect, reached the one way a root caller can (see [`Change::EnableUserSocket`] for the
/// two D-Bus doors and why both are shut).
///
/// **Ownership is not cosmetic.** A root-owned `~/.config/systemd` is a tree the account
/// cannot later manage: the user's own `systemctl --user disable` fails on it, and the
/// account is left unable to undo what an installer did to it. So every component created
/// here is chowned, and the link is `lchown`ed rather than `chown`ed — chowning a symlink
/// FOLLOWS it and would re-own `/usr/lib/systemd/user/podman.socket`, a packaged system
/// file, which is exactly the accident this paragraph exists to prevent.
///
/// Idempotent: a link already pointing at `unit_path` is left alone; one pointing elsewhere
/// is replaced, because an install asserts a desired state rather than merging with
/// whatever was there.
///
/// **It does not start the unit** — the name says `enable`, not `enable --now`, for that
/// reason. Starting needs the manager's bus, which is the unreachable half; the socket is
/// started on demand by the engine's clients in the meantime, and by the account's own
/// manager at every boot after, which is the durable property this step was added for.
#[cfg(unix)]
pub fn enable_user_unit(link: &Path, unit_path: &str, uid: u32, gid: u32) -> Result<()> {
    use std::os::unix::fs::{lchown, symlink};

    if !Path::new(unit_path).exists() {
        anyhow::bail!(
            "{unit_path} does not exist — refusing to write an enablement link to a unit \
             file that is not there. A dangling `.wants` link is a unit systemd complains \
             about at every boot."
        );
    }

    // Created component by component so each NEW directory can be handed to the account.
    // `create_dir_all` would leave them all root-owned, and a root-owned `~/.config` is a
    // lasting handicap for an account that must manage its own units.
    let parent = link
        .parent()
        .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", link.display()))?;
    let mut built = PathBuf::new();
    for comp in parent.components() {
        built.push(comp);
        if built.exists() {
            continue;
        }
        std::fs::create_dir(&built).with_context(|| format!("create {}", built.display()))?;
        std::os::unix::fs::chown(&built, Some(uid), Some(gid))
            .with_context(|| format!("hand {} to uid {uid}", built.display()))?;
    }

    // Idempotency read off the LINK, without following it.
    match std::fs::read_link(link) {
        Ok(target) if target == Path::new(unit_path) => return Ok(()),
        Ok(_) => std::fs::remove_file(link)
            .with_context(|| format!("replace the existing link {}", link.display()))?,
        // Not a link, or absent. A regular file here is somebody's own copy of a unit;
        // deleting that is not this crate's call, so let `symlink` fail and say so.
        Err(_) => {}
    }

    symlink(unit_path, link).with_context(|| format!("link {} -> {unit_path}", link.display()))?;
    lchown(link, Some(uid), Some(gid))
        .with_context(|| format!("hand the link {} to uid {uid}", link.display()))?;
    Ok(())
}

#[cfg(not(unix))]
pub fn enable_user_unit(_link: &Path, _unit_path: &str, _uid: u32, _gid: u32) -> Result<()> {
    anyhow::bail!("user managers are a Linux systemd concept; there is no unit to enable here")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hostfamily::HostFacts;

    const ODEN_PASSWD: &str = "\
root:x:0:0:root:/root:/bin/bash
korp:x:111:111::/home/korp:/usr/sbin/nologin
alice:x:1000:1000::/home/alice:/bin/bash
";

    /// A stock Ubuntu-shaped host with the id-map helpers present.
    fn ubuntu_facts() -> HostFacts {
        HostFacts {
            os_release: "ID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"24.04\"\n".into(),
            systemd_run_system: true,
            systemd_seats: true,
            newuidmap: true,
            newgidmap: true,
            ..Default::default()
        }
    }

    fn inputs(host: Host) -> Inputs {
        Inputs {
            host,
            account_name: "korp".into(),
            passwd: ODEN_PASSWD.into(),
            subuid: Some("alice:100000:65536\n".into()),
            subgid: Some("alice:100000:65536\n".into()),
            login_defs: "SUB_UID_MIN 100000\nSUB_UID_MAX 600100000\nSUB_UID_COUNT 65536\n\
                         SUB_GID_MIN 100000\nSUB_GID_MAX 600100000\nSUB_GID_COUNT 65536\n"
                .into(),
            linger_marker: false,
            runtime_dir_owner: None,
            // The shape a real host has BEFORE anything ran: the engine's unit is
            // installed (podman is a package, not something this crate provides) and the
            // service account has not enabled it — which is exactly the t14s state that
            // made `Step::ContainerSocket` necessary.
            container_socket_unit: Some("/usr/lib/systemd/user/podman.socket".into()),
            container_socket_enabled: false,
        }
    }

    fn finding(r: &Report, step: Step) -> &Finding {
        r.findings.iter().find(|f| f.step == step).expect("step")
    }

    // ── /etc/passwd ────────────────────────────────────────────────────────

    /// The account read is the one oden actually has: uid 111, `nologin`. That
    /// shell is not an anomaly to work around — it is the normal shape of a service
    /// account and the entire reason lingering must be granted explicitly.
    #[test]
    fn the_service_account_is_read_from_passwd_including_its_nologin_shell() {
        let a = Account::from_passwd(ODEN_PASSWD, "korp").expect("korp is in passwd");
        assert_eq!(a.uid, 111);
        assert_eq!(a.gid, 111);
        assert_eq!(a.shell, "/usr/sbin/nologin");
        assert_eq!(a.runtime_dir(), Path::new("/run/user/111"));
        assert_eq!(
            a.podman_socket(),
            Path::new("/run/user/111/podman/podman.sock"),
            "the DOCKER_HOST a rootless unit must carry"
        );
        assert_eq!(Account::from_passwd(ODEN_PASSWD, "nosuch"), None);
    }

    // ── the subid files ────────────────────────────────────────────────────

    /// **A file that is ALREADY WRONG must survive being read.**
    ///
    /// oden's `/etc/subuid` grants `github` and `alice` the identical range —
    /// impossible from shadow-utils, present anyway, and complained about by
    /// nothing. The parser reads it, [`SubIdFile::existing_overlaps`] reports it,
    /// and no code path repairs it: rewriting somebody else's grant would break
    /// whatever is running inside it right now.
    ///
    /// RED direction: make the parser dedupe or renumber overlapping grants and the
    /// round-trip assertion fails.
    #[test]
    fn an_already_overlapping_subuid_file_is_read_reported_and_left_alone() {
        let odens = "# a comment\ngithub:100000:65536\nalice:100000:65536\n";
        let f = SubIdFile::parse(odens);
        assert_eq!(f.ranges().count(), 2);
        assert_eq!(
            f.existing_overlaps(),
            vec![(
                "github:100000:65536".to_string(),
                "alice:100000:65536".to_string()
            )],
            "the overlap must be REPORTED — it is invisible otherwise"
        );
        assert_eq!(
            f.render(),
            odens,
            "and the file must round-trip byte-for-byte, comment included"
        );
    }

    /// Comments, blanks and malformed lines are carried verbatim. A rewriter that
    /// normalises what it does not understand eventually deletes a real grant.
    #[test]
    fn unparseable_lines_round_trip_untouched() {
        let messy = "# header\n\nalice:100000:65536\nbroken-line-no-colons\nx:notanumber:5\n";
        let f = SubIdFile::parse(messy);
        assert_eq!(f.ranges().count(), 1, "only one line is a real grant");
        assert_eq!(f.render(), messy);
        assert!(f.lines.iter().any(
            |l| matches!(l, SubIdLine::Other(o) if o == "broken-line-no-colons")
        ));
    }

    /// `count` is a COUNT. Two ranges that touch but do not share an id are NOT an
    /// overlap; one id of intersection is.
    #[test]
    fn range_overlap_is_computed_on_count_not_on_end() {
        let a = SubIdRange {
            owner: "a".into(),
            start: 100_000,
            count: 65_536,
        };
        assert_eq!(a.end(), 165_535);
        let touching = SubIdRange {
            owner: "b".into(),
            start: 165_536,
            count: 65_536,
        };
        assert!(!a.overlaps(&touching), "adjacent is not overlapping");
        let by_one = SubIdRange {
            owner: "c".into(),
            start: 165_535,
            count: 1,
        };
        assert!(a.overlaps(&by_one), "one shared id IS an overlap");
    }

    /// **The policy is READ, never invented** — `/etc/login.defs`, VERIFIED on t14s
    /// as `100000 / 600100000 / 65536`. A silent file falls back to shadow's own
    /// compiled defaults, not to a number this crate made up.
    #[test]
    fn the_allocation_policy_comes_from_login_defs() {
        let real = "\
# comment
SUB_UID_MIN\t\t   100000
SUB_UID_MAX\t\t600100000
SUB_UID_COUNT\t\t    65536
SUB_GID_MIN\t\t   200000
SUB_GID_MAX\t\t600100000
SUB_GID_COUNT\t\t    65536
";
        let u = SubIdPolicy::from_login_defs(real, IdKind::Uid);
        assert_eq!(
            u,
            SubIdPolicy {
                min: 100_000,
                max: 600_100_000,
                count: 65_536
            }
        );
        let g = SubIdPolicy::from_login_defs(real, IdKind::Gid);
        assert_eq!(g.min, 200_000, "the GID triple is read separately");
        assert_eq!(
            SubIdPolicy::from_login_defs("", IdKind::Uid),
            SubIdPolicy::SHADOW_DEFAULT,
            "an absent file falls back to shadow's compiled defaults"
        );
    }

    /// **Allocation routes AROUND an overlapping file without repairing it**, and
    /// lands on the same lattice the host's own tools use.
    ///
    /// oden's hand-typed grant was `korp:296608:65536` = `100000 + 3*65536`. The
    /// aligned-slot walk reproduces that arithmetic; given oden's two colliding
    /// grants at slot 0 it takes the first free slot rather than the fourth,
    /// because the file it is given has fewer occupants — the value is derived from
    /// the file, which is the property that matters.
    #[test]
    fn allocation_skips_occupied_slots_and_never_invents_a_number() {
        let subuid = SubIdFile::parse("github:100000:65536\nalice:100000:65536\n");
        let subgid = SubIdFile::parse("github:100000:65536\nalice:100000:65536\n");
        let r = allocate_pair("korp", &subuid, &subgid, SubIdPolicy::SHADOW_DEFAULT).unwrap();
        assert_eq!(r.start, 165_536, "slot 0 is taken (twice) — take slot 1");
        assert_eq!(r.count, 65_536);
        assert!(
            !subuid.collides(r.start, r.count),
            "the whole point: no overlap with anything already granted"
        );

        // Slots 0..3 occupied ⇒ oden's own number falls out of the same walk.
        let dense = SubIdFile::parse(
            "a:100000:65536\nb:165536:65536\nc:231072:65536\n",
        );
        let r = allocate_pair("korp", &dense, &dense, SubIdPolicy::SHADOW_DEFAULT).unwrap();
        assert_eq!(r.start, 296_608, "= 100000 + 3*65536, the value oden was given");
    }

    /// **Uid and gid are granted the SAME start**, so a slot free in one file and
    /// taken in the other is skipped in both.
    ///
    /// RED direction: allocate the two files independently and this returns 165536
    /// for the uid — producing a service whose container files have an owner and a
    /// group mapping to unrelated host ids.
    #[test]
    fn the_uid_and_gid_grants_are_allocated_as_one_pair() {
        let subuid = SubIdFile::parse("alice:100000:65536\n");
        let subgid = SubIdFile::parse("alice:100000:65536\nother:165536:65536\n");
        let r = allocate_pair("korp", &subuid, &subgid, SubIdPolicy::SHADOW_DEFAULT).unwrap();
        assert_eq!(
            r.start, 231_072,
            "slot 1 is free in subuid but taken in subgid, so neither file gets it"
        );
    }

    /// An exhausted policy window is a REFUSAL, not a wrap-around into real uids.
    #[test]
    fn an_exhausted_policy_window_refuses_rather_than_wrapping() {
        let full = SubIdFile::parse("a:1000:100\nb:1100:100\n");
        let policy = SubIdPolicy {
            min: 1000,
            max: 1199,
            count: 100,
        };
        let e = allocate_pair("korp", &full, &full, policy).unwrap_err();
        let m = format!("{e:#}");
        assert!(m.contains("no free subordinate-id range"), "{m}");
        assert!(
            m.contains("will not allocate outside the host's own policy window"),
            "the refusal must say WHY it stopped: {m}"
        );
    }

    // ── the assessment ─────────────────────────────────────────────────────

    /// **The oden case end to end**, as a pure function: Ubuntu, systemd-logind,
    /// helpers present, `korp` uid 111 with no grant and no linger. Three things
    /// are needed, `/run/user/111` follows from the third, and the plan says
    /// exactly that.
    #[test]
    fn the_oden_case_produces_a_four_change_plan_and_a_pending_runtime_dir() {
        let r = assess(&inputs(Host::from_facts(ubuntu_facts())));

        assert!(!r.is_ready(), "an unconfigured host is not ready");
        assert!(r.blockers().is_empty(), "nothing blocks: {}", r.render());
        assert!(
            matches!(finding(&r, Step::IdMapTools).state, State::Satisfied(_)),
            "newuidmap/newgidmap are present on this fixture"
        );

        let plan = r.plan();
        assert_eq!(
            plan.len(),
            4,
            "subuid + subgid + linger + the engine socket: {}",
            r.render()
        );
        let text: String = plan.iter().map(|c| c.describe()).collect::<Vec<_>>().join("\n");
        assert!(text.contains("/etc/subuid"), "{text}");
        assert!(text.contains("/etc/subgid"), "{text}");
        assert!(text.contains("SetUserLinger(111, true)"), "{text}");
        assert!(text.contains("podman.socket"), "{text}");
        // ORDER, not just membership: there is no user manager to enable a unit in
        // until lingering has created one, so a plan that enabled the socket first
        // would fail on precisely the hosts this crate exists for.
        let linger_at = plan
            .iter()
            .position(|c| matches!(c, Change::EnableLinger { .. }))
            .expect("the plan lingers");
        let socket_at = plan
            .iter()
            .position(|c| matches!(c, Change::EnableUserSocket { .. }))
            .expect("the plan enables the engine socket");
        assert!(
            linger_at < socket_at,
            "lingering must be planned BEFORE the socket it makes reachable: {text}"
        );
        assert!(
            text.contains("atomic temp-file + rename"),
            "the dry run must say HOW it writes, because `echo >>` is what it replaces: {text}"
        );

        // The runtime dir is a CONSEQUENCE, never a mkdir.
        let rd = &finding(&r, Step::RuntimeDir).state;
        assert!(matches!(rd, State::Follows(_)), "got {rd:?}");
        assert!(rd.detail().contains("/run/user/111"), "{}", rd.detail());
        assert!(
            rd.detail().contains("does not mkdir"),
            "and must say why it will not create it: {}",
            rd.detail()
        );
    }

    /// **Already set up ⇒ nothing to do.** The inspection has to be able to say
    /// "yes" or the apply can never be idempotent.
    #[test]
    fn a_host_that_is_already_set_up_reports_ready_with_an_empty_plan() {
        let mut i = inputs(Host::from_facts(ubuntu_facts()));
        i.subuid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
        i.subgid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
        i.linger_marker = true;
        i.runtime_dir_owner = Some(111);
        // The account has enabled the engine socket itself — the sixth step's
        // satisfied shape. Without this the host is NOT set up, and that is the point
        // of the step: `active but disabled` was what t14s looked like, and it read as
        // finished right up until the reboot.
        i.container_socket_enabled = true;

        let r = assess(&i);
        assert!(r.is_ready(), "{}", r.render());
        assert!(r.plan().is_empty());
        assert!(
            apply(&r).unwrap().performed.is_empty(),
            "a set-up host is a successful no-op, not an error"
        );
        assert!(
            finding(&r, Step::SubUid).state.detail().contains("296608"),
            "and it must name the grant it found"
        );
    }

    /// **ALPINE — the case the family type exists for.**
    ///
    /// Stock Alpine is OpenRC with no login manager. `loginctl enable-linger` is
    /// not "unavailable" there, it is *meaningless*: no such binary, no such bus
    /// name, no such concept, and nothing to create `/run/user/<uid>`. This must be
    /// [`State::Unsupported`] with an explanation — a common interface that quietly
    /// no-ops here would hand back a "configured" host and a service that cannot
    /// start.
    ///
    /// RED direction: make `Linger` fall through to `Satisfied` (or skip) on a
    /// logind-less host and both the blocker and the apply-refusal assertions fail
    /// while every systemd test stays green — exactly the silent half.
    #[test]
    fn stock_alpine_refuses_lingering_and_says_it_does_not_exist_there() {
        let alpine = Host::from_facts(HostFacts {
            os_release: "ID=alpine\nVERSION_ID=3.21.0\n".into(),
            openrc_softlevel: true,
            newuidmap: true,
            newgidmap: true,
            ..Default::default()
        });
        let r = assess(&inputs(alpine));

        let s = &finding(&r, Step::Linger).state;
        assert!(matches!(s, State::Unsupported(_)), "got {s:?}");
        assert!(
            s.detail().contains("NO login manager") && s.detail().contains("openrc"),
            "it must name the mechanism that is missing and the init that is running: {}",
            s.detail()
        );
        assert!(
            s.detail().contains("elogind"),
            "…and the real answer on this family, so the refusal is actionable: {}",
            s.detail()
        );

        // The subid grants on Alpine are perfectly ordinary — the file is the same
        // file. Only the runtime-dir mechanism differs, and that is the finding.
        assert!(
            matches!(finding(&r, Step::SubUid).state, State::Needed { .. }),
            "/etc/subuid is not a systemd concept and works here"
        );

        // …but the run as a whole refuses, because half of it would be pointless.
        let e = apply(&r).unwrap_err();
        let m = format!("{e:#}");
        assert!(
            m.contains("half-applied host setup is worse than none"),
            "{m}"
        );
        assert!(m.contains("linger"), "and it must name the step: {m}");
    }

    /// **Alpine WITH elogind is a supported host**, by the same code path as
    /// Ubuntu, because lingering is a logind capability and not a family one.
    ///
    /// RED direction: key the refusal off `Family::Alpine` and this host — which can
    /// genuinely do it — gets refused.
    #[test]
    fn alpine_with_elogind_can_linger_through_the_same_dbus_call() {
        let alpine = Host::from_facts(HostFacts {
            os_release: "ID=alpine\n".into(),
            openrc_softlevel: true,
            elogind_run: true,
            newuidmap: true,
            newgidmap: true,
            ..Default::default()
        });
        let r = assess(&inputs(alpine));
        assert!(r.blockers().is_empty(), "{}", r.render());
        match &finding(&r, Step::Linger).state {
            State::Needed { change, .. } => assert_eq!(
                change,
                &Change::EnableLinger {
                    name: "korp".into(),
                    uid: 111,
                    logind: Logind::Elogind,
                }
            ),
            other => panic!("expected a linger change, got {other:?}"),
        }
        // subuid + subgid + linger + the engine socket. Alpine-with-elogind reaches the
        // sixth step like any other host: the fixture ships podman's user unit, and
        // elogind gives the account the manager that unit is enabled in.
        assert_eq!(r.plan().len(), 4);
    }

    /// **A missing `newuidmap` stops the run and names the family's package.**
    ///
    /// This is the Debian-specific trap: `uidmap` is a separate package a default
    /// install does not pull in, so podman can be installed and rootless podman
    /// still cannot map a second id. The error podman gives — "potentially
    /// insufficient UIDs or GIDs available in user namespace" — reads like a subuid
    /// problem, so the tool has to be explicit about which of the two it is.
    #[test]
    fn a_missing_newuidmap_blocks_and_names_the_package_for_this_family() {
        let mut f = ubuntu_facts();
        f.newuidmap = false;
        let r = assess(&inputs(Host::from_facts(f)));

        let s = &finding(&r, Step::IdMapTools).state;
        assert!(matches!(s, State::Manual(_)), "got {s:?}");
        assert!(s.detail().contains("apt-get install -y uidmap"), "{}", s.detail());
        assert!(
            s.detail().contains("will not do it"),
            "installing a package is a subprocess and a host decision: {}",
            s.detail()
        );
        assert!(apply(&r).is_err(), "and it must stop the run");

        // The SAME missing tool on RHEL names a different package.
        let rhel = Host::from_facts(HostFacts {
            os_release: "ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n".into(),
            systemd_run_system: true,
            systemd_seats: true,
            newuidmap: false,
            newgidmap: false,
            ..Default::default()
        });
        let r = assess(&inputs(rhel));
        assert!(
            finding(&r, Step::IdMapTools)
                .state
                .detail()
                .contains("dnf install -y shadow-utils"),
            "{}",
            finding(&r, Step::IdMapTools).state.detail()
        );
    }

    /// **An unknown family is refused, not guessed at.** Nothing here can name the
    /// package, so nothing here pretends to.
    #[test]
    fn an_unknown_family_with_missing_tools_is_refused_by_name() {
        let host = Host::from_facts(HostFacts {
            os_release: "ID=slackware\n".into(),
            systemd_run_system: true,
            systemd_seats: true,
            ..Default::default()
        });
        let r = assess(&inputs(host));
        let s = &finding(&r, Step::IdMapTools).state;
        assert!(matches!(s, State::Refused(_)), "got {s:?}");
        assert!(s.detail().contains("slackware"), "{}", s.detail());
        assert!(
            s.detail().contains("will not guess a package name"),
            "{}",
            s.detail()
        );
    }

    /// **NixOS is refused on the subid steps specifically**, because there the
    /// append succeeds and then disappears at the next rebuild — the worst
    /// possible outcome, a setup that works until it silently does not.
    #[test]
    fn nixos_refuses_to_append_to_a_generated_etc() {
        let nixos = Host::from_facts(HostFacts {
            os_release: "ID=nixos\n".into(),
            systemd_run_system: true,
            systemd_seats: true,
            newuidmap: true,
            newgidmap: true,
            ..Default::default()
        });
        let r = assess(&inputs(nixos));
        for step in [Step::SubUid, Step::SubGid] {
            let s = &finding(&r, step).state;
            assert!(matches!(s, State::Refused(_)), "{step:?} → {s:?}");
            assert!(s.detail().contains("nixos-rebuild"), "{}", s.detail());
            assert!(
                s.detail().contains("subUidRanges"),
                "the refusal must name the declarative alternative: {}",
                s.detail()
            );
        }
        // Lingering is fine there — NixOS runs systemd-logind like anyone else.
        assert!(matches!(
            finding(&r, Step::Linger).state,
            State::Needed { .. }
        ));
        assert!(apply(&r).is_err());
    }

    /// **No passwd entry ⇒ refuse everything.** There is no uid to linger and no
    /// name to grant, and creating the account is not this crate's decision.
    #[test]
    fn an_account_with_no_passwd_entry_is_refused_on_every_step() {
        let mut i = inputs(Host::from_facts(ubuntu_facts()));
        i.account_name = "ghost".into();
        let r = assess(&i);
        assert_eq!(r.account, None);
        for step in [Step::SubUid, Step::SubGid, Step::Linger, Step::RuntimeDir] {
            assert!(
                matches!(finding(&r, step).state, State::Refused(_)),
                "{step:?} must refuse: {:?}",
                finding(&r, step).state
            );
        }
        assert!(r.render().contains("ABSENT from /etc/passwd"));
        assert!(apply(&r).is_err());
    }

    /// A `/run/user/<uid>` owned by somebody ELSE is refused rather than adopted —
    /// the one case where the directory exists and is worse than absent.
    #[test]
    fn a_runtime_dir_owned_by_another_account_is_refused() {
        let mut i = inputs(Host::from_facts(ubuntu_facts()));
        i.runtime_dir_owner = Some(1000);
        let r = assess(&i);
        let s = &finding(&r, Step::RuntimeDir).state;
        assert!(matches!(s, State::Refused(_)), "got {s:?}");
        assert!(s.detail().contains("uid 1000"), "{}", s.detail());
    }

    /// **RED-when-broken: an engine socket that is merely RUNNING is not set up.**
    ///
    /// The t14s case of 2026-08-15, as a fixture. Every other step was satisfied and
    /// `podman.socket` was `active` — started on demand by a client that happened to ask
    /// — while being `disabled`. That host works and stops working at the next reboot,
    /// which is the worst shape a "configured" verdict can have.
    ///
    /// So enablement is read from the SYMLINK, never from liveness: this crate has no
    /// input for "is it running", deliberately, because the answer would have been yes.
    ///
    /// RED direction: make the step consult a liveness fact, or default
    /// `container_socket_enabled` to true, and `is_ready` goes green on a host whose
    /// containers die at reboot.
    #[test]
    fn a_socket_that_is_running_but_not_enabled_is_still_needed() {
        let mut i = inputs(Host::from_facts(ubuntu_facts()));
        i.subuid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
        i.subgid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
        i.linger_marker = true;
        i.runtime_dir_owner = Some(111);
        i.container_socket_enabled = false;

        let r = assess(&i);
        assert!(
            !r.is_ready(),
            "a host whose engine socket is not ENABLED is not set up: {}",
            r.render()
        );
        let s = &finding(&r, Step::ContainerSocket).state;
        assert!(matches!(s, State::Needed { .. }), "got {s:?}");
        assert!(
            s.detail().contains("reboot"),
            "the detail must say what actually breaks, not just that a flag is off: {}",
            s.detail()
        );
        assert_eq!(
            r.plan().len(),
            1,
            "everything else is already done — only the socket is left: {}",
            r.render()
        );
        match &r.plan()[0] {
            Change::EnableUserSocket {
                name,
                uid,
                gid,
                unit_path,
                link,
            } => {
                assert_eq!((name.as_str(), *uid, *gid), ("korp", 111, 111));
                assert_eq!(unit_path, "/usr/lib/systemd/user/podman.socket");
                // The link must land in the ACCOUNT's home, under the target whose
                // `.wants` systemd actually pulls at boot — not in root's home, and not
                // in `/etc/systemd/user` (which would enable it for every account on the
                // box, an install deciding something about people it was never asked
                // about).
                assert_eq!(
                    link,
                    Path::new("/home/korp/.config/systemd/user/sockets.target.wants/podman.socket")
                );
            }
            other => panic!("expected an EnableUserSocket change, got {other:?}"),
        }
    }

    /// **RED-when-broken: enabling writes the link, hands it to the account, and is
    /// idempotent — and never re-owns the packaged unit it points at.**
    ///
    /// The ownership half is the subtle one. `chown` on a symlink FOLLOWS it, so the
    /// obvious spelling would have handed `/usr/lib/systemd/user/podman.socket` — a file
    /// belonging to the podman package — to a service account. `lchown` is what makes the
    /// link itself the thing that changes hands. This test pins the packaged file's owner
    /// before and after to keep that honest.
    ///
    /// The uid/gid assertions only run as root (nothing else may chown), so the ownership
    /// half is skipped for an unprivileged test run rather than faked; the linking and
    /// idempotency halves always run.
    #[test]
    fn enabling_writes_the_wants_link_and_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let unit_src = dir.path().join("podman.socket");
        std::fs::write(&unit_src, "[Socket]\nListenStream=%t/podman/podman.sock\n").unwrap();
        let packaged_before = std::fs::metadata(&unit_src).unwrap();

        let home = dir.path().join("home/korp");
        let link = user_wants_link(&home.to_string_lossy(), "podman.socket");
        let unit_path = unit_src.to_string_lossy().into_owned();

        // The ids this process already owns, read off a file it just created — so the
        // chowns are no-ops and the test runs unprivileged. (`libc` is not a dependency of
        // this crate, and adding one to learn our own uid would be a poor trade.) The
        // path, link and idempotency behaviour is what this test is about.
        let (uid, gid) = {
            use std::os::unix::fs::MetadataExt;
            let m = std::fs::metadata(&unit_src).unwrap();
            (m.uid(), m.gid())
        };

        enable_user_unit(&link, &unit_path, uid, gid).expect("enable writes the link");
        assert_eq!(
            std::fs::read_link(&link).unwrap(),
            unit_src,
            "the link must point at the packaged unit"
        );
        assert!(
            link.parent().unwrap().ends_with("sockets.target.wants"),
            "…inside the target's own .wants dir, or nothing pulls it at boot"
        );

        // The packaged unit is untouched — the `lchown`-not-`chown` property.
        let packaged_after = std::fs::metadata(&unit_src).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::MetadataExt;
            assert_eq!(
                (packaged_before.uid(), packaged_before.gid()),
                (packaged_after.uid(), packaged_after.gid()),
                "chowning the link must not follow it into the packaged unit file"
            );
        }

        // Idempotent: a second run is a no-op success, not a failed `symlink` on an
        // existing path (an install runs more than once by design).
        enable_user_unit(&link, &unit_path, uid, gid).expect("a second enable is a no-op");
        assert_eq!(std::fs::read_link(&link).unwrap(), unit_src);

        // A link aiming somewhere else is CORRECTED — an install asserts a state.
        std::fs::remove_file(&link).unwrap();
        std::os::unix::fs::symlink("/nonexistent/other.socket", &link).unwrap();
        enable_user_unit(&link, &unit_path, uid, gid).expect("a stale link is replaced");
        assert_eq!(std::fs::read_link(&link).unwrap(), unit_src);

        // And a unit file that does not exist is REFUSED rather than linked: a dangling
        // .wants entry is a complaint at every boot.
        let missing = dir.path().join("nope.socket");
        let err = enable_user_unit(&link, &missing.to_string_lossy(), uid, gid)
            .expect_err("a missing unit file must be refused");
        assert!(format!("{err:#}").contains("does not exist"), "{err:#}");
    }

    /// **A host with no engine is a HOST fact, stated as one — and it stops the run.**
    ///
    /// `Manual`, the same state [`Step::IdMapTools`] uses for a missing package, and it
    /// blocks for the same reason the crate blocks everywhere else: half-applied is worse
    /// than none. Setting up subids and lingering on a box with no container engine
    /// produces a host that LOOKS configured for rootless containers and cannot run one —
    /// which is exactly the "configured but broken" verdict every other step refuses to
    /// hand back.
    ///
    /// The consumer's side of that trade is deliberate too: `install_service` treats a
    /// rootless refusal as non-fatal, so the service still installs and still runs — it
    /// just does not pretend its container leg is ready.
    #[test]
    fn a_host_with_no_user_socket_unit_names_the_package_and_blocks_like_a_missing_tool() {
        let mut i = inputs(Host::from_facts(ubuntu_facts()));
        i.container_socket_unit = None;

        let r = assess(&i);
        let s = &finding(&r, Step::ContainerSocket).state;
        assert!(matches!(s, State::Manual(_)), "got {s:?}");
        assert!(s.detail().contains("podman"), "names the package: {}", s.detail());
        assert!(
            s.detail().contains("/usr/lib/systemd/user"),
            "and says where it looked: {}",
            s.detail()
        );
        assert!(
            s.is_blocking(),
            "a missing engine blocks, exactly as a missing newuidmap does — the two \
             findings must read the same way in one report"
        );
        // And the refusal must NAME this step, not just count blockers: an operator
        // reading it has to learn which package to install.
        let m = format!("{:#}", apply(&r).unwrap_err());
        assert!(m.contains("container-socket"), "{m}");
        assert!(m.contains("podman"), "{m}");
    }

    /// The refusal message lists EVERY blocker, not the first — an operator who
    /// fixes one and re-runs into the next has been made to do the work twice.
    #[test]
    fn the_apply_refusal_lists_every_blocker_at_once() {
        let mut f = ubuntu_facts();
        f.newuidmap = false;
        f.systemd_seats = false; // …and no logind either
        let r = assess(&inputs(Host::from_facts(f)));
        assert!(r.blockers().len() >= 2, "{}", r.render());
        let m = format!("{:#}", apply(&r).unwrap_err());
        assert!(m.contains("idmap-tools"), "{m}");
        assert!(m.contains("linger"), "{m}");
    }

    // ── writing ────────────────────────────────────────────────────────────

    /// **The write is atomic and preserves the mode** — the property `echo >>`
    /// cannot have. An interrupted append leaves a truncated grant line, and
    /// shadow-utils ignores a subid file from the first bad line onward.
    #[test]
    fn granting_a_range_rewrites_the_file_atomically_and_keeps_its_mode() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("subuid");
        std::fs::write(&file, "alice:100000:65536\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
        }

        let range = SubIdRange {
            owner: "korp".into(),
            start: 165_536,
            count: 65_536,
        };
        let report = Report {
            host: Host::from_facts(ubuntu_facts()),
            account_name: "korp".into(),
            account: Account::from_passwd(ODEN_PASSWD, "korp"),
            findings: vec![Finding {
                step: Step::SubUid,
                state: State::Needed {
                    detail: "no grant".into(),
                    change: Change::GrantSubId {
                        file: file.clone(),
                        range: range.clone(),
                    },
                },
            }],
            existing_subuid_overlaps: vec![],
        };

        let done = apply(&report).unwrap();
        assert_eq!(done.performed.len(), 1);
        assert_eq!(
            std::fs::read_to_string(&file).unwrap(),
            "alice:100000:65536\nkorp:165536:65536\n",
            "the existing grant is untouched and the new one is appended"
        );
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            assert_eq!(
                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
                0o600,
                "a rename must not hand /etc/subuid a fresh default mode"
            );
        }
        // No staging file left behind.
        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().contains("tmp"))
            .collect();
        assert!(leftovers.is_empty(), "the temp file must be renamed, not left");

        // Idempotent under a RACE: applying the same plan again finds the grant
        // already there (as if another writer had won) and leaves it alone.
        let again = apply(&report).unwrap();
        assert!(
            again.performed[0].contains("already granted by another writer"),
            "{:?}",
            again.performed
        );
        assert_eq!(
            std::fs::read_to_string(&file).unwrap(),
            "alice:100000:65536\nkorp:165536:65536\n",
            "and it must not append a second time"
        );
    }

    /// The rendered report is the crisp one-line-per-step form, and it names the
    /// pre-existing overlap without offering to fix it.
    #[test]
    fn the_report_renders_one_line_per_step_and_notes_a_broken_file() {
        let mut i = inputs(Host::from_facts(ubuntu_facts()));
        i.subuid = Some("github:100000:65536\nalice:100000:65536\n".into());
        let text = assess(&i).render();
        for step in ["idmap-tools", "subuid", "subgid", "linger", "runtime-dir"] {
            assert!(text.contains(step), "missing {step} in:\n{text}");
        }
        assert!(text.contains("overlapping grants"), "{text}");
        assert!(text.contains("repairs nothing it did not write"), "{text}");
    }

    /// The read-only path runs on whatever box the tests run on, touches nothing,
    /// and produces a report. `root` always has a passwd entry, so this exercises
    /// the account branch for real.
    #[test]
    fn inspecting_the_running_host_is_read_only_and_always_answers() {
        let r = inspect("root");
        assert_eq!(r.account_name, "root");
        assert_eq!(r.findings.len(), 6, "all six steps are always reported");
        assert!(!r.render().is_empty());
        // Whatever this host is, the report must not claim a mutation happened.
        assert!(r.plan().iter().all(|c| matches!(
            c,
            Change::GrantSubId { .. }
                | Change::EnableLinger { .. }
                | Change::EnableUserSocket { .. }
        )));
    }
}