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

nordisk/edda

crates/nornir-git/src/deploykey.rs

//! # deploykey — WHERE the git deploy key is, WHICH rung answered, and WHETHER
//! any remote actually accepts it
//!
//! [`sshkey`](crate::sshkey) MINTS a deploy key. This module answers the three
//! questions a human has when a fetch fails at 03:00:
//!
//! 1. **Which key is this process using, and where did it come from?**
//!    [`Resolution::resolve`] walks ONE ladder and reports every rung it looked at,
//!    not just the winner — so "it picked up a stale legacy path" is a thing you can
//!    SEE instead of a thing you deduce from `strace`.
//! 2. **What is the public half?** [`Resolution::public_line`] — the string you paste
//!    into Codeberg/GitHub. The private half is never read, never returned, never
//!    logged.
//! 3. **Does any remote accept it?** [`probe_remote`] does a real, cheap SSH ref
//!    advertisement and classifies the outcome. A key that exists is not a key that
//!    works, and this module refuses to let the two look the same.
//!
//! ## ONE ladder, not a second one
//!
//! [`ssh_key_path`] — the resolver the actual fetch path uses (`gitio::nornir_ssh_key_path` delegates to it) —
//! DELEGATES here. There is no second ladder to drift from. That is deliberate: a
//! duplicated `warehouse_root` ladder in this constellation ended up with 4 call
//! sites on one rule and 47 on another, and the UI that showed you the key would
//! have shown you a key the fetch did not use.
//!
//! ## The service is dwarves now
//!
//! nornir used to run as its own systemd unit; today dwarves compiles nornir IN and
//! there is ONE service, and it is dwarves. So the DEFAULT is dwarves' own
//! [`dwarves_home_ssh_dir`] (`~/.dwarves/ssh`) and the minted comment is
//! `dwarves@<host>` — no environment variable in the happy path: a service that owns
//! its identity should not need to be told where its own key is.
//!
//! The nornir-named rungs are kept as **read-only legacy fallbacks** so a box mid-
//! transition keeps fetching, and [`KeyOrigin::is_legacy`] makes the fallback
//! VISIBLE. They are not deprecated silently and they are not removed: eight repos
//! failed every 60-second poll for most of 2026-07-22 because of a key that could
//! not be found, and a hard cutover is how that happens again.
//!
//! Note the crate names (`nornir-git`, `nornir-monitor`, `nornir-release-core`) stay
//! as they are. Those are LIBRARIES dwarves consumes and every other consumer depends
//! on the names. What is being renamed is the SERVICE IDENTITY — the thing that
//! authenticates to Codeberg and the thing a human reads in a deploy-key list.
//!
//! ## Precedence, and the one place this differs from "simplest first"
//!
//! An EXPLICIT environment override is honoured FIRST. Not because env vars are the
//! mechanism — they are an escape hatch — but because a running deployment may
//! already point at a key that works, and a new default silently outranking an
//! operator's explicit configuration IS the hard cutover. The pane says loudly when
//! the escape hatch is what answered ([`Resolution::chosen_origin`] +
//! [`KeyOrigin::is_escape_hatch`]), which is the property that makes a stale config
//! diagnosable rather than mysterious.

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

use anyhow::Result;

/// The dwarves-named override (`DWARVES_SSH_DIR`). Preferred spelling.
pub const DWARVES_SSH_DIR_ENV: &str = "DWARVES_SSH_DIR";
/// The legacy nornir-named override (`NORNIR_SSH_DIR`). Still honoured — the live
/// forge on oden sets exactly this — but no longer the mechanism.
pub const NORNIR_SSH_DIR_ENV: &str = "NORNIR_SSH_DIR";

/// **CONFINE THE LADDER TO THE EXPLICIT OVERRIDES.** Set truthy (`1`, `true`,
/// `yes`, `on`) and [`Resolution::resolve`] considers ONLY the two env rungs —
/// no `$HOME` rungs, no absolute system-user rungs.
///
/// This exists because of a real incident. `resolve()` picks the first rung that
/// ACTUALLY HAS A KEY, not the first rung that is configured, so a test that
/// pointed `DWARVES_SSH_DIR` at an empty scratch dir was not isolated at all:
/// the empty dir was skipped, the ladder walked on, and it arrived at
/// `$HOME/.nornir/ssh`. MEASURED on t14s 2026-09-10 — `nornir key generate`
/// printed `• already present: ~/.nornir/ssh/id_ed25519.pub`, and a
/// second fixture went further and AUTHENTICATED to codeberg.org with that key.
///
/// Overriding `$HOME` fixes the home rungs. It cannot fix rungs 3 and 6:
/// `/home/dwarves/.ssh` and `/home/nornir/.ssh` are ABSOLUTE, so on a box with a
/// real service install a fixture would reach a real SERVICE key no matter what
/// it set. No env var could neutralise them — hence this one, which removes them
/// from the ladder outright.
///
/// Confined with no override set is an EMPTY ladder, and an empty ladder finds
/// no key. That is the correct answer for a test: a fixture that cannot name its
/// own key has no business borrowing someone else's.
///
/// Not a security boundary — anything that can set this can set `$HOME`. It is a
/// blast-radius control for test fixtures and sandboxed tools, and it is honest
/// about which rungs it removed ([`Resolution::confined`]).
pub const DWARVES_SSH_CONFINE_ENV: &str = "DWARVES_SSH_CONFINE";

/// Is the ladder confined to its explicit overrides? Reads
/// [`DWARVES_SSH_CONFINE_ENV`].
pub fn ladder_is_confined() -> bool {
    match std::env::var(DWARVES_SSH_CONFINE_ENV) {
        Ok(v) => {
            let v = v.trim().to_ascii_lowercase();
            matches!(v.as_str(), "1" | "true" | "yes" | "on")
        }
        Err(_) => false,
    }
}

/// Where a deploy key was found — the RUNG of the ladder, named.
///
/// This is the field that turns "the fetch is using some key" into "the fetch is
/// using the key at `~/.nornir/ssh` because `NORNIR_SSH_DIR` is still set in the
/// unit file".
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum KeyOrigin {
    /// `$DWARVES_SSH_DIR` — the dwarves-named explicit override.
    EnvDwarves,
    /// `$NORNIR_SSH_DIR` — the LEGACY explicit override, from when nornir was the
    /// service. Honoured so a live unit file keeps working.
    EnvNornirLegacy,
    /// `~/.dwarves/ssh` — **THE DEFAULT.** dwarves' own directory, needing no
    /// configuration at all.
    DwarvesHome,
    /// `/home/dwarves/.ssh` — the `dwarves` system user's home, on a systemd install.
    DwarvesServiceUser,
    /// `~/.nornir/ssh` — LEGACY: the per-user dev location from the nornir-as-service
    /// era.
    NornirHomeLegacy,
    /// `/home/nornir/.ssh` — LEGACY: the `nornir` system user's home.
    NornirServiceUserLegacy,
}

impl KeyOrigin {
    /// Stable label for logs and the UI.
    pub fn as_str(self) -> &'static str {
        match self {
            KeyOrigin::EnvDwarves => "$DWARVES_SSH_DIR",
            KeyOrigin::EnvNornirLegacy => "$NORNIR_SSH_DIR (legacy)",
            KeyOrigin::DwarvesHome => "~/.dwarves/ssh (default)",
            KeyOrigin::DwarvesServiceUser => "/home/dwarves/.ssh",
            KeyOrigin::NornirHomeLegacy => "~/.nornir/ssh (legacy)",
            KeyOrigin::NornirServiceUserLegacy => "/home/nornir/.ssh (legacy)",
        }
    }

    /// **This rung is a leftover from the nornir-as-service topology.** True for the
    /// nornir-named env var and both nornir-named directories. A resolution that
    /// lands here still WORKS — that is the whole point of keeping them — but the
    /// surface must say so, because the identity a human will read in Codeberg's
    /// deploy-key list will say `nornir@…` when the machine's service is dwarves.
    pub fn is_legacy(self) -> bool {
        matches!(
            self,
            KeyOrigin::EnvNornirLegacy
                | KeyOrigin::NornirHomeLegacy
                | KeyOrigin::NornirServiceUserLegacy
        )
    }

    /// **This rung was reached by explicit configuration**, not by the default. An
    /// escape hatch that answered is worth showing: it is the difference between
    /// "dwarves is using its own key" and "someone pointed dwarves at a key in 2026
    /// and nobody remembers".
    pub fn is_escape_hatch(self) -> bool {
        matches!(self, KeyOrigin::EnvDwarves | KeyOrigin::EnvNornirLegacy)
    }

    /// One sentence a reader can act on.
    pub fn explain(self) -> &'static str {
        match self {
            KeyOrigin::EnvDwarves => {
                "an explicit DWARVES_SSH_DIR override — the happy path needs none"
            }
            KeyOrigin::EnvNornirLegacy => {
                "an explicit NORNIR_SSH_DIR override, the nornir-era spelling — it \
                 still works, but the default (~/.dwarves/ssh) needs no env var at all"
            }
            KeyOrigin::DwarvesHome => "dwarves' own default directory — no configuration involved",
            KeyOrigin::DwarvesServiceUser => "the `dwarves` system user's home (systemd install)",
            KeyOrigin::NornirHomeLegacy => {
                "the nornir-era per-user directory — a fallback, kept so a box \
                 mid-transition keeps fetching"
            }
            KeyOrigin::NornirServiceUserLegacy => {
                "the `nornir` system user's home — from before dwarves compiled nornir \
                 in and became the one service"
            }
        }
    }
}

/// One rung of the ladder, and whether a private key is actually sitting on it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct KeyCandidate {
    /// Which rung this is.
    pub origin: KeyOrigin,
    /// The `.ssh`-style directory this rung points at.
    pub dir: PathBuf,
    /// `<dir>/id_ed25519`.
    pub private: PathBuf,
    /// `<dir>/id_ed25519.pub`.
    pub public: PathBuf,
    /// Whether the PRIVATE key exists here. (Existence only — the bytes are never
    /// read.)
    pub present: bool,
}

/// The full resolution: every rung that was considered, and which one answered.
///
/// Reporting the rungs that did NOT answer is not verbosity. "No key found" and "a
/// key was found somewhere unexpected" are different problems with different fixes,
/// and a resolver that returns `Option<PathBuf>` can express neither.
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct Resolution {
    /// Every rung, in precedence order.
    pub candidates: Vec<KeyCandidate>,
    /// Index into [`candidates`](Self::candidates) of the rung that answered.
    pub chosen: Option<usize>,
    /// Whether the ladder was CONFINED to its explicit overrides
    /// ([`DWARVES_SSH_CONFINE_ENV`]) — i.e. the `$HOME` and system-user rungs
    /// were never considered.
    ///
    /// Reported rather than inferred: "no key found" and "no key found because I
    /// was told not to look there" are different answers, and a caller that
    /// cannot tell them apart will debug the wrong one.
    #[serde(default)]
    pub confined: bool,
}

/// dwarves' own default key directory: `$HOME/.dwarves/ssh`. The answer in every
/// normal case, and the directory [`mint_default`] writes into.
pub fn dwarves_home_ssh_dir() -> Option<PathBuf> {
    std::env::var_os("HOME").map(|h| Path::new(&h).join(".dwarves/ssh"))
}

/// The nornir-era per-user directory: `$HOME/.nornir/ssh`. Legacy, read-only.
pub fn nornir_home_ssh_dir() -> Option<PathBuf> {
    std::env::var_os("HOME").map(|h| Path::new(&h).join(".nornir/ssh"))
}

/// **THE deploy-key resolver.** Runs the ladder and returns the private key
/// path, or `None` when no rung holds one that exists.
///
/// This is the one function; `gitio::nornir_ssh_key_path` is a re-export of it
/// and has been since it stopped carrying a ladder of its own. It lives HERE
/// rather than in `gitio` because it has nothing to do with gix: a caller that
/// only needs the SSH transport (`nornir-git` with `default-features = false`)
/// still has to find the key, and `gitio` is not compiled for it.
pub fn ssh_key_path() -> Option<PathBuf> {
    Resolution::resolve().private_path().map(Path::to_path_buf)
}

fn candidate(origin: KeyOrigin, dir: PathBuf) -> KeyCandidate {
    let private = dir.join(crate::sshkey::PRIV_FILE);
    let public = dir.join(crate::sshkey::PUB_FILE);
    let present = private.exists();
    KeyCandidate { origin, dir, private, public, present }
}

impl Resolution {
    /// Walk the ladder. Reads the filesystem for existence only; never opens a key.
    pub fn resolve() -> Resolution {
        let mut candidates = Vec::new();
        // 1. Explicit overrides. An operator who SET a path meant it, and a running
        //    deployment may already depend on it — a new default silently outranking
        //    it is the hard cutover this module exists to avoid.
        if let Some(d) = std::env::var_os(DWARVES_SSH_DIR_ENV) {
            candidates.push(candidate(KeyOrigin::EnvDwarves, PathBuf::from(d)));
        }
        if let Some(d) = std::env::var_os(NORNIR_SSH_DIR_ENV) {
            candidates.push(candidate(KeyOrigin::EnvNornirLegacy, PathBuf::from(d)));
        }
        // CONFINED: the explicit overrides are the WHOLE ladder. Everything below
        // reaches into $HOME or an absolute system path, which is exactly what a
        // fixture must not do. See `DWARVES_SSH_CONFINE_ENV`.
        if ladder_is_confined() {
            let chosen = candidates.iter().position(|c| c.present);
            return Resolution { candidates, chosen, confined: true };
        }
        // 2. THE DEFAULT: dwarves' own directory. No configuration.
        if let Some(d) = dwarves_home_ssh_dir() {
            candidates.push(candidate(KeyOrigin::DwarvesHome, d));
        }
        // 3. The dwarves system user, on a systemd install.
        candidates.push(candidate(KeyOrigin::DwarvesServiceUser, PathBuf::from("/home/dwarves/.ssh")));
        // 4-5. LEGACY, read-only: the nornir-as-service era.
        if let Some(d) = nornir_home_ssh_dir() {
            candidates.push(candidate(KeyOrigin::NornirHomeLegacy, d));
        }
        candidates.push(candidate(
            KeyOrigin::NornirServiceUserLegacy,
            PathBuf::from("/home/nornir/.ssh"),
        ));

        let chosen = candidates.iter().position(|c| c.present);
        Resolution { candidates, chosen, confined: false }
    }

    /// Was the ladder confined to its explicit overrides? See
    /// [`DWARVES_SSH_CONFINE_ENV`].
    pub fn confined(&self) -> bool {
        self.confined
    }

    /// The rung that answered.
    pub fn chosen(&self) -> Option<&KeyCandidate> {
        self.chosen.and_then(|i| self.candidates.get(i))
    }

    /// Which rung answered, as an origin.
    pub fn chosen_origin(&self) -> Option<KeyOrigin> {
        self.chosen().map(|c| c.origin)
    }

    /// The PRIVATE key path the fetch will use. Path only — the bytes are never read
    /// by this module and never leave it.
    pub fn private_path(&self) -> Option<&Path> {
        self.chosen().map(|c| c.private.as_path())
    }

    /// The PUBLIC key line, trimmed — `ssh-ed25519 AAAA… dwarves@oden`. This is the
    /// string to paste into a repo's deploy keys. Returns `None` when there is no key
    /// or the `.pub` half is missing (a truncated install), which is a state worth
    /// telling apart from "no key at all".
    pub fn public_line(&self) -> Option<String> {
        let c = self.chosen()?;
        let s = std::fs::read_to_string(&c.public).ok()?;
        let s = s.trim();
        (!s.is_empty()).then(|| s.to_string())
    }

    /// The OpenSSH **comment** on the public key — the identity a human reads in
    /// Codeberg's deploy-key list (`dwarves@oden`). `None` when the key carries none.
    pub fn identity(&self) -> Option<String> {
        let line = self.public_line()?;
        let mut it = line.split_whitespace();
        let _algo = it.next()?;
        let _blob = it.next()?;
        let comment = it.collect::<Vec<_>>().join(" ");
        (!comment.is_empty()).then_some(comment)
    }

    /// **The identity still says `nornir@…`.** Cosmetic — the comment field plays no
    /// part in authentication — but it is what identifies this machine in a deploy-key
    /// list, and on a box whose only service is dwarves it is wrong.
    pub fn identity_is_stale(&self) -> bool {
        self.identity().is_some_and(|i| i.starts_with("nornir@"))
    }

    /// True when the key came off a nornir-era rung.
    pub fn is_legacy_fallback(&self) -> bool {
        self.chosen_origin().is_some_and(KeyOrigin::is_legacy)
    }
}

/// **Mint dwarves' own deploy key at the default location** (`~/.dwarves/ssh`), with
/// the comment `dwarves@<host>`.
///
/// Idempotent — an existing key is preserved and `created` comes back `false`; a
/// live deploy identity is never silently overwritten. Routes through the ONE
/// canonical mint ([`crate::sshkey::generate_ed25519_openssh`]), pure Rust, no
/// `ssh-keygen` subprocess.
///
/// **This is never called from [`Resolution::resolve`].** Resolution is a read; a
/// resolver that mints as a side effect would, on the next restart, hand the running
/// service a key no remote has ever seen — a hard cutover disguised as a default.
/// Minting is an action a human takes.
pub fn mint_default() -> Result<(PathBuf, bool)> {
    let dir = dwarves_home_ssh_dir()
        .ok_or_else(|| anyhow::anyhow!("no $HOME — cannot place dwarves' deploy key"))?;
    let host = std::fs::read_to_string("/etc/hostname")
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "localhost".to_string());
    let created = crate::sshkey::generate_ed25519_openssh(&dir, &format!("dwarves@{host}"))?;
    Ok((dir.join(crate::sshkey::PRIV_FILE), created))
}

/// **Rewrite the OpenSSH comment on an existing public key** — a rename of the
/// IDENTITY, not of the key.
///
/// The comment is the trailing free-text field of an OpenSSH public-key line. It is
/// not covered by the signature and plays no part in authentication: changing it is
/// cosmetic, the key material is byte-identical, and NOTHING has to be re-registered
/// with any remote. What it changes is what a human reads in a deploy-key list, which
/// on a box whose only service is dwarves should say `dwarves@<host>`.
///
/// Touches ONLY the `.pub` file. The private key is not opened.
pub fn relabel_public_identity(public_path: &Path, comment: &str) -> Result<String> {
    let txt = std::fs::read_to_string(public_path)
        .map_err(|e| anyhow::anyhow!("read {}: {e}", public_path.display()))?;
    let mut it = txt.split_whitespace();
    let algo = it.next().ok_or_else(|| anyhow::anyhow!("{}: not an OpenSSH public key", public_path.display()))?;
    let blob = it.next().ok_or_else(|| anyhow::anyhow!("{}: public key has no key blob", public_path.display()))?;
    let line = format!("{algo} {blob} {comment}");
    std::fs::write(public_path, format!("{line}\n"))
        .map_err(|e| anyhow::anyhow!("write {}: {e}", public_path.display()))?;
    Ok(line)
}

// ── what a remote actually says ─────────────────────────────────────────────

/// What happened when this key was presented to a remote.
///
/// [`NotProbed`](ProbeOutcome::NotProbed) is a VALUE, not an absence. A row that has
/// never been asked and a row that answered "fine" must not render the same; a
/// missing measurement counting as a pass is the failure mode this codebase has been
/// bitten by all day (a golden-file test where a missing snapshot was a PASS, a
/// feature space that read vacuously green, a coverage table empty because nobody ran
/// the command).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case", tag = "outcome")]
pub enum ProbeOutcome {
    /// Nobody has asked this remote yet. **NOT a pass.**
    NotProbed,
    /// The remote authenticated us and advertised refs. `head` is HEAD's SHA when the
    /// remote advertised one.
    Authenticated { head: Option<String> },
    /// The remote refused the key: `publickey` auth rejected. This is the one a
    /// deploy key fixes.
    AuthRejected,
    /// We never got far enough to be judged — DNS, TCP, connection reset, timeout.
    /// **Not the same as a rejection**: codeberg rate-limits parallel connections
    /// with a `kex_exchange_identification` reset that is not an auth failure at all.
    Unreachable,
    /// There is no key to present. Distinct from a rejection: nothing was refused.
    NoKey,
}

impl ProbeOutcome {
    /// Stable label.
    pub fn as_str(&self) -> &'static str {
        match self {
            ProbeOutcome::NotProbed => "not probed",
            ProbeOutcome::Authenticated { .. } => "authenticating",
            ProbeOutcome::AuthRejected => "key rejected",
            ProbeOutcome::Unreachable => "unreachable",
            ProbeOutcome::NoKey => "no key",
        }
    }
    /// True only for a remote that really answered us.
    pub fn is_green(&self) -> bool {
        matches!(self, ProbeOutcome::Authenticated { .. })
    }
}

/// One remote's real fetch state.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RemoteProbe {
    /// The remote URL as configured (`ssh://git@git.gunnar.rs:2222/nordisk/nornir.git`).
    pub remote: String,
    /// What happened.
    pub outcome: ProbeOutcome,
    /// The error, VERBATIM, when there was one. Never summarised into an enum and
    /// thrown away — the enum is for grouping, this is for fixing.
    pub detail: String,
    /// How long the probe took.
    pub elapsed_ms: u64,
}

/// Classify a `ls-remote` error into an outcome. The auth rejection is the message
/// `crate::ssh::connect_session` raises when `authenticate_publickey` comes back
/// unsuccessful.
fn classify(err: &str) -> ProbeOutcome {
    let e = err.to_ascii_lowercase();
    if e.contains("auth rejected") || e.contains("publickey auth") || e.contains("permission denied")
    {
        ProbeOutcome::AuthRejected
    } else {
        ProbeOutcome::Unreachable
    }
}

/// **Probe one remote for real** — a cheap SSH ref advertisement (`ls-remote`), the
/// same handshake the poll loop's fetch begins with, with the same key.
///
/// Blocking and network-touching: run it on a worker, never on a UI thread. Returns
/// [`ProbeOutcome::NoKey`] rather than an error when there is nothing to present, so
/// the caller never has to decide whether an `Err` meant "broken" or "unconfigured".
pub fn probe_remote(remote: &str, key: Option<&Path>) -> RemoteProbe {
    let t0 = std::time::Instant::now();
    let Some(key) = key else {
        return RemoteProbe {
            remote: remote.to_string(),
            outcome: ProbeOutcome::NoKey,
            detail: "no deploy key resolved — nothing to present".to_string(),
            elapsed_ms: 0,
        };
    };
    let (outcome, detail) = match crate::ssh::ls_remote_blocking(remote, key) {
        Ok(refs) => {
            let head = refs.iter().find(|(_, n)| n == "HEAD").map(|(s, _)| s.clone());
            let n = refs.len();
            (ProbeOutcome::Authenticated { head }, format!("{n} ref(s) advertised"))
        }
        Err(e) => {
            let msg = format!("{e:#}");
            (classify(&msg), msg)
        }
    };
    RemoteProbe { remote: remote.to_string(), outcome, detail, elapsed_ms: t0.elapsed().as_millis() as u64 }
}

/// **The state of the deploy key, as five things that are not each other.**
///
/// The requirement this type exists to meet: "no key found", "key present but never
/// used", "key rejected by remote" and "fetching fine" must be DISTINGUISHABLE. They
/// are four variants here, plus a fifth — [`MintedNotRegistered`](KeyState::MintedNotRegistered)
/// — because a freshly minted key that no remote has been told about produces auth
/// failures that look exactly like a missing key, and that is the state a new user is
/// actually in.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum KeyState {
    /// Nothing on any rung of the ladder. Every SSH fetch fails with
    /// "needs a deploy key, but none was found".
    NoKeyFound,
    /// A key was minted here and has not been presented to any remote yet. Not a
    /// failure and not a success — an unfinished setup step. Registering the public
    /// half is the missing action.
    MintedNotRegistered,
    /// A key exists, and NO remote has been probed. **This is "we do not know", and
    /// it renders as its own thing.** It is not green.
    PresentNeverUsed,
    /// At least one remote refused the key and none accepted it. `rejected` counts
    /// them.
    RejectedByRemote { rejected: usize },
    /// At least one remote authenticated. `ok` accepted, `failing` did not — a
    /// partially-green fleet reports BOTH numbers rather than rounding to "fine",
    /// because eight failing repos beside eleven good ones is exactly the shape that
    /// went unnoticed for a day.
    Fetching { ok: usize, failing: usize },
}

impl KeyState {
    /// Stable label for a chip.
    pub fn as_str(&self) -> &'static str {
        match self {
            KeyState::NoKeyFound => "NO KEY FOUND",
            KeyState::MintedNotRegistered => "MINTED — NOT REGISTERED",
            KeyState::PresentNeverUsed => "KEY PRESENT — NEVER USED",
            KeyState::RejectedByRemote { .. } => "KEY REJECTED BY REMOTE",
            KeyState::Fetching { .. } => "FETCHING",
        }
    }

    /// The one sentence under the chip: what this state MEANS and what to do.
    pub fn headline(&self) -> String {
        match self {
            KeyState::NoKeyFound => {
                "no deploy key on any known path — every SSH fetch fails before it \
                 starts. Mint one below."
                    .to_string()
            }
            KeyState::MintedNotRegistered => {
                "key minted, but no remote has ever been told about it. Paste the \
                 public key into the repo's deploy keys, then probe."
                    .to_string()
            }
            KeyState::PresentNeverUsed => {
                "a key is present and NOTHING has been probed — this is 'unknown', \
                 not 'working'. Press Probe."
                    .to_string()
            }
            KeyState::RejectedByRemote { rejected } => format!(
                "{rejected} remote(s) refused this key — it is not registered there (or \
                 not on that repo). The public key below is what they need."
            ),
            KeyState::Fetching { ok, failing } if *failing == 0 => {
                format!("{ok} remote(s) authenticate with this key.")
            }
            KeyState::Fetching { ok, failing } => format!(
                "{ok} remote(s) authenticate — and {failing} do NOT. A partial green is \
                 not a green."
            ),
        }
    }

    /// True only when every probed remote authenticated AND at least one was probed.
    /// Deliberately false for [`PresentNeverUsed`](KeyState::PresentNeverUsed): no
    /// data is not good news.
    pub fn is_green(&self) -> bool {
        matches!(self, KeyState::Fetching { failing: 0, ok } if *ok > 0)
    }
}

/// Derive the state from the resolution, whether a key was minted in this session,
/// and the probes.
///
/// The ordering is the whole point: an unprobed fleet can never reach
/// [`KeyState::Fetching`], so silence cannot be mistaken for success.
pub fn key_state(res: &Resolution, minted_this_session: bool, probes: &[RemoteProbe]) -> KeyState {
    if res.chosen().is_none() {
        return KeyState::NoKeyFound;
    }
    let probed: Vec<&RemoteProbe> =
        probes.iter().filter(|p| p.outcome != ProbeOutcome::NotProbed).collect();
    if probed.is_empty() {
        return if minted_this_session {
            KeyState::MintedNotRegistered
        } else {
            KeyState::PresentNeverUsed
        };
    }
    let ok = probed.iter().filter(|p| p.outcome.is_green()).count();
    let rejected = probed.iter().filter(|p| p.outcome == ProbeOutcome::AuthRejected).count();
    if ok == 0 && rejected > 0 {
        return KeyState::RejectedByRemote { rejected };
    }
    KeyState::Fetching { ok, failing: probed.len() - ok }
}

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

    /// Serialises the tests that mutate process-global env.
    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// **The confine, and the incident it comes from.**
    ///
    /// `resolve()` picks the first rung that ACTUALLY HAS A KEY, not the first
    /// rung that is configured — so pointing `DWARVES_SSH_DIR` at an EMPTY dir
    /// does not isolate anything: the empty rung is skipped and the walk
    /// continues into `$HOME` and then into two ABSOLUTE system paths.
    ///
    /// RED first, then the fix, in one test so neither half can rot alone:
    ///   1. unconfined + empty override ⇒ the ladder walks PAST it (the bug);
    ///   2. confined ⇒ the ladder is the overrides and nothing else;
    ///   3. confined + a REAL key on the override ⇒ it still resolves, so the
    ///      confine did not simply break resolution (the inverse — without it a
    ///      green here is satisfied by a function that always finds nothing).
    ///
    /// Serialised on `ENV_LOCK`: it mutates process-global env.
    #[test]
    fn the_confine_removes_every_rung_that_reaches_outside_the_override() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let d = tempfile::tempdir().unwrap();
        let empty = d.path().join("empty-ssh");
        std::fs::create_dir_all(&empty).unwrap();

        let prev_dw = std::env::var_os(DWARVES_SSH_DIR_ENV);
        let prev_no = std::env::var_os(NORNIR_SSH_DIR_ENV);
        let prev_cf = std::env::var_os(DWARVES_SSH_CONFINE_ENV);
        // SAFETY: env mutation serialised by ENV_LOCK; restored below.
        unsafe {
            std::env::set_var(DWARVES_SSH_DIR_ENV, &empty);
            std::env::remove_var(NORNIR_SSH_DIR_ENV);
            std::env::remove_var(DWARVES_SSH_CONFINE_ENV);
        }

        // 1. THE BUG: unconfined, the empty override is not the whole ladder.
        let open = Resolution::resolve();
        assert!(!open.confined(), "not confined yet");
        assert!(
            open.candidates.len() > 1,
            "unconfined, the ladder walks past an empty override into $HOME and \
             two absolute system paths — that is the hazard: {:?}",
            open.candidates.iter().map(|c| c.origin).collect::<Vec<_>>()
        );
        assert!(
            open.candidates.iter().any(|c| matches!(
                c.origin,
                KeyOrigin::DwarvesServiceUser | KeyOrigin::NornirServiceUserLegacy
            )),
            "…including the ABSOLUTE rungs no env var can neutralise"
        );

        // 2. THE FIX: confined, the overrides ARE the ladder.
        // SAFETY: see above.
        unsafe { std::env::set_var(DWARVES_SSH_CONFINE_ENV, "1") };
        let shut = Resolution::resolve();
        assert!(shut.confined(), "the resolution reports that it was confined");
        assert_eq!(
            shut.candidates.iter().map(|c| c.origin).collect::<Vec<_>>(),
            vec![KeyOrigin::EnvDwarves],
            "only the explicit override survives"
        );
        assert_eq!(shut.chosen(), None, "and it holds no key, so nothing resolves");
        assert_eq!(ssh_key_path(), None, "the public door agrees");

        // 3. INVERSE: a real key on the override still resolves when confined.
        crate::sshkey::generate_ed25519_openssh(&empty, "dwarves@test").unwrap();
        let found = Resolution::resolve();
        assert!(found.confined());
        assert_eq!(found.chosen_origin(), Some(KeyOrigin::EnvDwarves));
        assert_eq!(
            found.private_path(),
            Some(empty.join(crate::sshkey::PRIV_FILE).as_path()),
            "the confine narrows the ladder; it does not break it"
        );

        // SAFETY: see above.
        unsafe {
            match prev_dw {
                Some(v) => std::env::set_var(DWARVES_SSH_DIR_ENV, v),
                None => std::env::remove_var(DWARVES_SSH_DIR_ENV),
            }
            match prev_no {
                Some(v) => std::env::set_var(NORNIR_SSH_DIR_ENV, v),
                None => std::env::remove_var(NORNIR_SSH_DIR_ENV),
            }
            match prev_cf {
                Some(v) => std::env::set_var(DWARVES_SSH_CONFINE_ENV, v),
                None => std::env::remove_var(DWARVES_SSH_CONFINE_ENV),
            }
        }
    }

    /// Only a truthy value confines. A stray `DWARVES_SSH_CONFINE=0` in an
    /// environment must not silently disarm a live forge's key ladder.
    #[test]
    fn only_a_truthy_value_confines() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let prev = std::env::var_os(DWARVES_SSH_CONFINE_ENV);
        for (v, want) in [
            ("1", true), ("true", true), ("YES", true), ("on", true), (" 1 ", true),
            ("0", false), ("false", false), ("no", false), ("", false), ("maybe", false),
        ] {
            // SAFETY: env mutation serialised by ENV_LOCK; restored below.
            unsafe { std::env::set_var(DWARVES_SSH_CONFINE_ENV, v) };
            assert_eq!(ladder_is_confined(), want, "DWARVES_SSH_CONFINE={v:?}");
        }
        // SAFETY: see above.
        unsafe { std::env::remove_var(DWARVES_SSH_CONFINE_ENV) };
        assert!(!ladder_is_confined(), "unset is not confined");
        // SAFETY: see above.
        unsafe {
            if let Some(v) = prev {
                std::env::set_var(DWARVES_SSH_CONFINE_ENV, v);
            }
        }
    }

    fn probe(remote: &str, outcome: ProbeOutcome) -> RemoteProbe {
        RemoteProbe { remote: remote.into(), outcome, detail: String::new(), elapsed_ms: 1 }
    }

    fn resolved() -> Resolution {
        Resolution {
            candidates: vec![KeyCandidate {
                origin: KeyOrigin::DwarvesHome,
                dir: PathBuf::from("/x/.dwarves/ssh"),
                private: PathBuf::from("/x/.dwarves/ssh/id_ed25519"),
                public: PathBuf::from("/x/.dwarves/ssh/id_ed25519.pub"),
                present: true,
            }],
            chosen: Some(0),
            confined: false,
        }
    }

    /// THE requirement: the four states are four states. No two of them collapse,
    /// and in particular "nothing measured" is not "all good".
    #[test]
    fn the_four_states_are_four_states() {
        let none = Resolution { candidates: vec![], chosen: None, confined: false };
        let s_none = key_state(&none, false, &[]);
        let s_unused = key_state(&resolved(), false, &[]);
        let s_rejected =
            key_state(&resolved(), false, &[probe("a", ProbeOutcome::AuthRejected)]);
        let s_ok =
            key_state(&resolved(), false, &[probe("a", ProbeOutcome::Authenticated { head: None })]);

        assert_eq!(s_none, KeyState::NoKeyFound);
        assert_eq!(s_unused, KeyState::PresentNeverUsed);
        assert_eq!(s_rejected, KeyState::RejectedByRemote { rejected: 1 });
        assert_eq!(s_ok, KeyState::Fetching { ok: 1, failing: 0 });

        let all = [&s_none, &s_unused, &s_rejected, &s_ok];
        for (i, a) in all.iter().enumerate() {
            for b in all.iter().skip(i + 1) {
                assert_ne!(a, b, "two states collapsed into one");
            }
            // Distinct labels too — the UI must not render two of them the same.
            for b in all.iter().skip(i + 1) {
                assert_ne!(a.as_str(), b.as_str(), "two states share a label");
            }
        }
    }

    /// "Key present, nothing probed" is NOT green. This is the exact conflation —
    /// no data reading as a pass — that produced a golden-file test where a missing
    /// snapshot counted as a PASS.
    #[test]
    fn no_data_is_not_good_news() {
        assert!(!key_state(&resolved(), false, &[]).is_green());
        assert!(!key_state(&resolved(), false, &[probe("a", ProbeOutcome::NotProbed)]).is_green());
        assert!(key_state(&resolved(), false, &[probe("a", ProbeOutcome::Authenticated { head: None })]).is_green());
    }

    /// A freshly minted key is its OWN state — it is not "no key" (there is one) and
    /// not "present but never used" (the reason it was never used is that it was
    /// created seconds ago and nobody has registered it).
    #[test]
    fn a_minted_key_is_not_a_used_key_and_not_a_missing_one() {
        let s = key_state(&resolved(), true, &[]);
        assert_eq!(s, KeyState::MintedNotRegistered);
        assert_ne!(s, KeyState::NoKeyFound);
        assert_ne!(s, KeyState::PresentNeverUsed);
        assert!(!s.is_green());
    }

    /// Eight failing repos beside eleven good ones must not round to "fine".
    #[test]
    fn a_partial_green_reports_both_numbers() {
        let mut ps: Vec<RemoteProbe> = (0..11)
            .map(|i| probe(&format!("ok{i}"), ProbeOutcome::Authenticated { head: None }))
            .collect();
        ps.extend((0..8).map(|i| probe(&format!("bad{i}"), ProbeOutcome::AuthRejected)));
        let s = key_state(&resolved(), false, &ps);
        assert_eq!(s, KeyState::Fetching { ok: 11, failing: 8 });
        assert!(!s.is_green(), "a partial green is not a green");
        assert!(s.headline().contains("8"), "the failing count is in the headline: {}", s.headline());
    }

    /// An auth rejection and a connection reset are different problems. Codeberg's
    /// parallel-connection limit produces the latter and no deploy key fixes it.
    #[test]
    fn a_rejection_and_a_reset_are_told_apart() {
        assert_eq!(
            classify("ssh publickey auth rejected for git@codeberg.org"),
            ProbeOutcome::AuthRejected
        );
        assert_eq!(
            classify("kex_exchange_identification: Connection closed by remote host"),
            ProbeOutcome::Unreachable
        );
        assert_ne!(ProbeOutcome::AuthRejected, ProbeOutcome::Unreachable);
    }

    /// The ladder reports EVERY rung, not just the winner, and the default rung is
    /// dwarves' own directory — the nornir-named ones are below it and flagged.
    ///
    /// Takes `ENV_LOCK` because it calls `resolve()`, which reads process-global
    /// env that the confine tests mutate. Without the lock this test and
    /// `the_confine_removes_every_rung_that_reaches_outside_the_override` race,
    /// and the loser sees a one-rung ladder. MEASURED: it failed exactly that way
    /// the first time the confine tests ran beside it.
    #[test]
    fn the_ladder_names_every_rung_and_flags_the_legacy_ones() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        // The confine is process-global; a leaked one from another test would
        // make this ladder one rung long. Assert the precondition rather than
        // inherit it.
        assert!(!ladder_is_confined(), "precondition: the ladder is not confined here");
        let r = Resolution::resolve();
        assert!(r.candidates.len() >= 4, "every rung is reported: {:?}", r.candidates);
        let origins: Vec<KeyOrigin> = r.candidates.iter().map(|c| c.origin).collect();
        assert!(origins.contains(&KeyOrigin::DwarvesServiceUser));
        assert!(origins.contains(&KeyOrigin::NornirServiceUserLegacy));
        assert!(KeyOrigin::NornirHomeLegacy.is_legacy());
        assert!(KeyOrigin::EnvNornirLegacy.is_legacy());
        assert!(!KeyOrigin::DwarvesHome.is_legacy());
        assert!(KeyOrigin::EnvDwarves.is_escape_hatch());
        assert!(!KeyOrigin::DwarvesHome.is_escape_hatch());
    }

    /// Relabelling the identity keeps the KEY MATERIAL byte-identical — it rewrites
    /// only the trailing comment. Nothing has to be re-registered anywhere.
    #[test]
    fn relabelling_the_identity_does_not_touch_the_key_material() {
        let d = tempfile::tempdir().unwrap();
        let dir = d.path().join("ssh");
        crate::sshkey::generate_ed25519_openssh(&dir, "nornir@oden").unwrap();
        let pubp = dir.join(crate::sshkey::PUB_FILE);
        let before = std::fs::read_to_string(&pubp).unwrap();
        let blob_before = before.split_whitespace().nth(1).unwrap().to_string();
        assert!(before.trim().ends_with("nornir@oden"));

        let line = relabel_public_identity(&pubp, "dwarves@oden").unwrap();
        let after = std::fs::read_to_string(&pubp).unwrap();
        assert!(after.trim().ends_with("dwarves@oden"), "{after}");
        assert_eq!(
            after.split_whitespace().nth(1).unwrap(),
            blob_before,
            "the key blob is untouched — a comment rewrite is cosmetic"
        );
        assert_eq!(line, after.trim());
        // The PRIVATE key was not opened or rewritten.
        let privtxt = std::fs::read_to_string(dir.join(crate::sshkey::PRIV_FILE)).unwrap();
        assert!(privtxt.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----"));
    }

    /// `identity()` reads the comment, and `identity_is_stale()` recognises the
    /// nornir-era one. Cosmetic, but it is what a human reads in a deploy-key list.
    #[test]
    fn the_identity_is_the_comment_and_a_nornir_one_reads_as_stale() {
        let d = tempfile::tempdir().unwrap();
        let dir = d.path().join("ssh");
        crate::sshkey::generate_ed25519_openssh(&dir, "nornir@oden").unwrap();
        let r = Resolution {
            candidates: vec![KeyCandidate {
                origin: KeyOrigin::NornirHomeLegacy,
                dir: dir.clone(),
                private: dir.join(crate::sshkey::PRIV_FILE),
                public: dir.join(crate::sshkey::PUB_FILE),
                present: true,
            }],
            chosen: Some(0),
            confined: false,
        };
        assert_eq!(r.identity().as_deref(), Some("nornir@oden"));
        assert!(r.identity_is_stale());
        assert!(r.is_legacy_fallback());
        assert!(r.public_line().unwrap().starts_with("ssh-ed25519 "));

        relabel_public_identity(&r.chosen().unwrap().public, "dwarves@oden").unwrap();
        assert_eq!(r.identity().as_deref(), Some("dwarves@oden"));
        assert!(!r.identity_is_stale());
    }
}