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