facett-deploykey/src/lib.rs
//! **`facett-deploykey`** — the 🔑 SSH deploy-key surface, as three real panes.
//!
//! ## The problem this pane exists for
//!
//! The forge fetches its repos over SSH (the nordisk organisation, polled every
//! 60 s). On 2026-07-22, **eight repos failed every single poll** for most of a day
//! with `SSH remote … needs a deploy key, but none was found`, and **nothing in the
//! UI said so.** The only way to learn it was `journalctl`. Everything below is in
//! service of making that self-diagnosable and self-fixable:
//!
//! | idx | pane | what it owns |
//! |---|---|---|
//! | 0 | **Key** | the state, the identity, the public key, Copy, and where to paste it |
//! | 1 | **Remotes** | the REAL per-remote fetch state, with the actual error |
//! | 2 | **Where it came from** | the resolution ladder, every rung, and which one answered |
//!
//! ## Five states, and they are five
//!
//! [`nornir_git::deploykey::KeyState`] — reused, not redefined here:
//!
//! * **NO KEY FOUND** — nothing on any rung. Every fetch fails before it starts.
//! * **MINTED — NOT REGISTERED** — a key was just created and no remote has been
//! told about it. Its auth failures look identical to a missing key's, which is
//! why it is its own state and not folded into the next one.
//! * **KEY PRESENT — NEVER USED** — a key exists and *nothing has been probed*.
//! This renders as its own thing and **is not green**.
//! * **KEY REJECTED BY REMOTE** — a remote refused it. The one a deploy key fixes.
//! * **FETCHING** — at least one remote authenticated; the pane reports the failing
//! count beside the good one, because 11 green and 8 red is not "fine".
//!
//! The third is the load-bearing one. Conflating "no data" with "all good" is the
//! bug pattern that bit this codebase repeatedly in one day — a golden-file test
//! where a missing snapshot counted as PASS, an undeclared feature space reading as
//! vacuously green, a coverage table empty because nobody ran the command. So
//! [`KeyState::is_green`](nornir_git::deploykey::KeyState::is_green) is false for
//! everything except a probe that actually came back, and the pane paints
//! never-probed rows in their own colour with the words "not probed".
//!
//! ## The identity is `dwarves`, and the pane says which path answered
//!
//! nornir used to be its own systemd service; today dwarves compiles nornir in and
//! there is ONE service. So the default key is `~/.dwarves/ssh/id_ed25519` with the
//! comment `dwarves@<host>`, and no environment variable is involved in the happy
//! path. The nornir-named paths still resolve — a live box must keep fetching — and
//! pane 2 shows exactly which rung answered, flagging a legacy or env-override
//! answer. That is the property that turns a stale config from a mystery into a line
//! of text.
//!
//! ## `Facet`, deliberately, and not `Panel`
//!
//! [`DeployKeyDeck`] implements **`Facet` only**. facett-core carries a blanket
//! `impl<T: Facet> Panel for T`, so it is a `Panel` for free with nothing to
//! disambiguate — implementing both by hand is what produced `E0034` in dwarves'
//! pane. Same rule, same reason, as `facett_buildthing::BuildThingDeck`, which this
//! deck's `view_pane(idx, ui) -> Vec<Msg>` seam mirrors so a `facett_app::scene` host
//! gets real panes with its own chrome, fold, minimise and maximise.
//!
//! ## What this crate does NOT do
//!
//! It never touches the network and never runs a probe: `view_pane` returns
//! [`Msg::ProbeAll`] / [`Msg::Mint`] / [`Msg::Relabel`] and the **host** does the
//! blocking work on a named worker thread. It also never reads the PRIVATE key —
//! the private path is displayed as a path and nothing more. There is no code path
//! in this crate that can put private key material on screen, in a log, or on a
//! clipboard.
use egui::{Sense, Ui};
use facett_core::{Facet, FacetCaps};
use nornir_git::deploykey::{
key_state, KeyCandidate, KeyOrigin, KeyState, ProbeOutcome, RemoteProbe, Resolution,
};
/// What a deck pane asks the host to do. Returned by [`DeployKeyDeck::view_pane`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Msg {
/// Put this text on the clipboard (the public key line).
Copy(String),
/// Mint dwarves' own deploy key at the default path (`~/.dwarves/ssh`).
Mint,
/// Rewrite the public key's OpenSSH comment to `dwarves@<host>`. Cosmetic: the
/// key material is unchanged and nothing has to be re-registered.
Relabel,
/// Probe every known remote for real.
ProbeAll,
/// Probe one remote.
Probe(String),
}
/// The key pane's index.
pub const PANE_KEY: usize = 0;
/// The remotes pane's index.
pub const PANE_REMOTES: usize = 1;
/// The resolution-ladder pane's index.
pub const PANE_LADDER: usize = 2;
/// How many panes the deck holds.
pub const PANE_COUNT: usize = 3;
/// Everything the host measured, handed to the deck as one value.
///
/// The host owns the filesystem and the network; the deck owns the pixels. Every
/// field here is a MEASUREMENT, including the absences — `probes` carries a row per
/// known remote with [`ProbeOutcome::NotProbed`] until one is actually run, so a
/// fleet nobody has asked about renders as a fleet nobody has asked about.
#[derive(Debug, Clone, Default)]
pub struct KeySnapshot {
/// The ladder and which rung answered.
pub resolution: Resolution,
/// The public key line to paste (`ssh-ed25519 AAAA… dwarves@oden`), if any.
pub public_line: Option<String>,
/// The OpenSSH comment — the identity a human reads in a deploy-key list.
pub identity: Option<String>,
/// One row per known remote.
pub probes: Vec<RemoteProbe>,
/// A key was minted in this session and has not been registered anywhere.
pub minted_this_session: bool,
/// A probe sweep is in flight (the host's worker is running).
pub probing: bool,
/// The last action's outcome, verbatim (a mint error, a relabel confirmation).
pub notice: Option<String>,
}
impl KeySnapshot {
/// The state — derived from the same reused model the CLI uses.
pub fn state(&self) -> KeyState {
key_state(&self.resolution, self.minted_this_session, &self.probes)
}
/// **The identity still names the old service** (`nornir@…`) on a box whose only
/// service is dwarves.
///
/// Reads [`identity`](Self::identity) — the value the HOST measured and put in
/// this snapshot — and NOT `Resolution::identity_is_stale`, which re-reads the
/// `.pub` file from disk. Both answers are correct in their own place (the CLI
/// has no snapshot and should hit the disk), but a pane that renders one field
/// and tests another is a pane that can disagree with itself on screen. One
/// source per surface.
pub fn identity_is_stale(&self) -> bool {
self.identity.as_deref().is_some_and(|i| i.starts_with("nornir@"))
}
}
/// **The deploy-key deck.**
pub struct DeployKeyDeck {
/// What the host measured.
pub snap: KeySnapshot,
/// Divider fractions for the single-body path: key | remotes | ladder.
ratios: [f32; 2],
/// Messages raised this frame, drained by the host.
pending: Vec<Msg>,
/// The text the next Ctrl-C should yield (the public key, never the private).
copy_buffer: Option<String>,
}
impl Default for DeployKeyDeck {
fn default() -> Self {
DeployKeyDeck {
snap: KeySnapshot::default(),
ratios: [0.44, 0.34],
pending: Vec::new(),
copy_buffer: None,
}
}
}
impl DeployKeyDeck {
/// A deck over a host-supplied snapshot.
pub fn new(snap: KeySnapshot) -> Self {
DeployKeyDeck { snap, ..Default::default() }
}
/// Replace the snapshot (the host calls this when its worker reports).
pub fn set_snapshot(&mut self, snap: KeySnapshot) {
self.snap = snap;
}
/// A **seeded demo deck** — the fixture the facett-demo gallery mounts, so the
/// surface renders POPULATED with no host, no filesystem and no network. The
/// public line + `dwarves@oden` identity, and a mixed probe fleet: two remotes
/// authenticated, one rejected (the state a deploy key fixes), one never probed
/// (rendered as its own thing, NOT green). Overall state: `FETCHING`, with the
/// failing count beside the good one — because 2 green and 1 red is not "fine".
///
/// ## The ladder is the WHOLE ladder now
///
/// It used to be one rung. That made [`PANE_LADDER`] — the pane literally
/// titled "Where the key came from" — vacuous: a ladder with one rung shows
/// nothing about precedence, which is the only thing that pane exists to show.
/// It was also a state the real resolver can never produce. `Resolution`
/// gained `confined` (nornir-git, 2026-09-10) and made that visible: a
/// resolution is one-rung ONLY when the ladder was confined to an explicit
/// override, and this one is not confined. `confined: false` with one
/// candidate depicted something impossible.
///
/// So the fixture now carries the four rungs an unconfigured box actually
/// has, in real precedence order, with `DwarvesHome` — the no-configuration
/// default — present and chosen, and the rest absent. That is what an ordinary box looks like, it makes the pane
/// demonstrate the thing it advertises, and it makes `confined: false`
/// truthful rather than merely well-reading.
///
/// ## Why the paths are fictional
///
/// The home rungs were hardcoded to `~/...` — one developer's
/// real key directory, baked into a shipped demo gallery. Nothing ever READ
/// it (this is a fabricated struct, not a resolver run), so it was never the
/// credential hazard the test fixtures were; it was a real person's home path
/// on display in a demo. `/home/demo` matches the `DEMOfixture` convention the
/// seeded public line already uses, and is nobody's.
///
/// The two absolute rungs keep their real values: `/home/dwarves/.ssh` and
/// `/home/nornir/.ssh` are not personal paths, they are the system-user rungs
/// the ladder genuinely has, and showing them is the point of the pane.
#[must_use]
pub fn demo() -> Self {
let home = std::path::Path::new("/home/demo");
let dir = home.join(".dwarves/ssh");
// The real ladder, in the real order. Only the default rung holds a key.
let rung = |origin: KeyOrigin, d: std::path::PathBuf, present: bool| KeyCandidate {
private: d.join("id_ed25519"),
public: d.join("id_ed25519.pub"),
dir: d,
origin,
present,
};
let resolution = Resolution {
// FOUR rungs, not six: the two `$..._SSH_DIR` rungs are pushed only
// when those vars are SET, so depicting them would depict a box that
// has escape hatches configured — the opposite of the no-configuration
// default this fixture is showing.
candidates: vec![
rung(KeyOrigin::DwarvesHome, dir.clone(), true),
rung(KeyOrigin::DwarvesServiceUser, "/home/dwarves/.ssh".into(), false),
rung(KeyOrigin::NornirHomeLegacy, home.join(".nornir/ssh"), false),
rung(KeyOrigin::NornirServiceUserLegacy, "/home/nornir/.ssh".into(), false),
],
chosen: Some(0),
// A depicted resolution, and what it depicts is an ordinary box: the
// ladder ran in full and the default rung answered.
confined: false,
};
let probe = |remote: &str, outcome: ProbeOutcome, detail: &str, ms: u64| RemoteProbe {
remote: remote.into(),
outcome,
detail: detail.into(),
elapsed_ms: ms,
};
DeployKeyDeck::new(KeySnapshot {
resolution,
public_line: Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDEMOfixture dwarves@oden".into()),
identity: Some("dwarves@oden".into()),
probes: vec![
probe(
"ssh://git@git.gunnar.rs:2222/nordisk/nornir.git",
ProbeOutcome::Authenticated { head: Some("84ab12cd".into()) },
"84 ref(s) advertised",
412,
),
probe(
"ssh://git@git.gunnar.rs:2222/nordisk/facett.git",
ProbeOutcome::Authenticated { head: Some("ca0b1129".into()) },
"61 ref(s) advertised",
388,
),
probe(
"ssh://git@git.gunnar.rs:2222/nordisk/holger.git",
ProbeOutcome::AuthRejected,
"ssh publickey auth rejected for git@git.gunnar.rs",
530,
),
probe("ssh://git@git.gunnar.rs:2222/nordisk/skade.git", ProbeOutcome::NotProbed, "", 0),
],
minted_this_session: false,
probing: false,
notice: None,
})
}
/// How many panes — what a host declares scene slots from.
pub fn pane_count(&self) -> usize {
PANE_COUNT
}
/// The pane title at `idx`.
pub fn pane_title(&self, idx: usize) -> Option<String> {
match idx {
PANE_KEY => Some(format!("Deploy key — {}", self.snap.state().as_str())),
PANE_REMOTES => {
let probed =
self.snap.probes.iter().filter(|p| p.outcome != ProbeOutcome::NotProbed).count();
Some(format!("Remotes — {probed}/{} probed", self.snap.probes.len()))
}
PANE_LADDER => Some("Where the key came from".to_string()),
_ => None,
}
}
/// Take the messages raised since the last call.
pub fn take_msgs(&mut self) -> Vec<Msg> {
std::mem::take(&mut self.pending)
}
/// **Render ONE pane into the rect the caller has already decided** — the seam a
/// `facett_app::scene` host needs to make a deck out of real panes with their own
/// chrome. Mirrors `facett_buildthing::BuildThingDeck::view_pane` exactly.
///
/// An out-of-range `idx` draws a visible marker rather than nothing: a blank rect
/// reads as "this pane has no data", which is the confusion this whole crate is
/// about.
pub fn view_pane(&mut self, idx: usize, ui: &mut Ui) -> Vec<Msg> {
match idx {
PANE_KEY => self.ui_key(ui),
PANE_REMOTES => self.ui_remotes(ui),
PANE_LADDER => self.ui_ladder(ui),
_ => {
ui.weak(format!("no deck pane at index {idx} (deck holds {PANE_COUNT})"));
}
}
self.take_msgs()
}
// ── pane 0: the key, the state, and where to paste it ────────────────────
fn ui_key(&mut self, ui: &mut Ui) {
let state = self.snap.state();
ui.horizontal_wrapped(|ui| {
chip(ui, state.as_str(), state_color(&state));
if self.snap.probing {
ui.spinner();
ui.label(egui::RichText::new("probing…").small().weak());
}
});
ui.label(egui::RichText::new(state.headline()).small());
ui.separator();
// The identity — what a human will see in Codeberg's deploy-key list.
match self.snap.identity.as_deref() {
Some(id) => {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("identity").small().weak());
ui.label(egui::RichText::new(id).monospace().strong());
if self.snap.identity_is_stale() {
chip(ui, "STALE — says nornir", WARN);
}
});
if self.snap.identity_is_stale() {
ui.label(
egui::RichText::new(
"this box's only service is dwarves — nornir is compiled IN, not \
run separately. Renaming the identity is COSMETIC (the comment \
is not part of the key and not part of authentication): the key \
material is unchanged and nothing has to be re-registered.",
)
.small()
.weak(),
);
if ui.button("Rename identity to dwarves@<host>").clicked() {
self.pending.push(Msg::Relabel);
}
}
}
None if self.snap.public_line.is_some() => {
ui.label(
egui::RichText::new("identity: <none> — the key carries no comment")
.small()
.color(WARN),
);
}
None => {}
}
// The public key + the copy action.
match self.snap.public_line.clone() {
Some(line) => {
ui.add_space(4.0);
ui.label(egui::RichText::new("public key — paste THIS").small().strong());
// Selectable so a user can drag-select it even without the button, and
// wrapped so a narrow pane never truncates the blob into a key that
// silently does not work when pasted.
ui.add(
egui::Label::new(egui::RichText::new(&line).monospace().small())
.wrap()
.sense(Sense::click()),
);
ui.horizontal_wrapped(|ui| {
if ui.button("📋 Copy public key").clicked() {
self.copy_buffer = Some(line.clone());
self.pending.push(Msg::Copy(line.clone()));
}
ui.label(
egui::RichText::new("(the PRIVATE half is never read or shown)")
.small()
.weak(),
);
});
ui.add_space(4.0);
paste_guidance(ui);
}
None => {
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"no public key to show — there is nothing to paste anywhere yet",
)
.small()
.color(RED),
);
if self.snap.resolution.chosen().is_some() {
ui.label(
egui::RichText::new(
"a PRIVATE key resolved but its `.pub` half is missing (a truncated \
install). That is not the same as having no key: the fetch will \
still authenticate — you just cannot read out what to register.",
)
.small()
.color(WARN),
);
}
}
}
// Minting — an explicit action, never a side effect of looking.
if self.snap.resolution.chosen().is_none() {
ui.add_space(6.0);
ui.label(
egui::RichText::new(
"dwarves can mint its own key at ~/.dwarves/ssh/id_ed25519 (ed25519, \
0600, pure Rust — no ssh-keygen). You will then need to register the \
public half; until you do, the state below reads MINTED — NOT \
REGISTERED, not 'working'.",
)
.small()
.weak(),
);
if ui
.button(egui::RichText::new("🔑 Mint dwarves' deploy key").strong())
.clicked()
{
self.pending.push(Msg::Mint);
}
}
if let Some(n) = &self.snap.notice {
ui.add_space(4.0);
ui.label(egui::RichText::new(n).small().monospace());
}
}
// ── pane 1: what the remotes actually say ────────────────────────────────
fn ui_remotes(&mut self, ui: &mut Ui) {
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(!self.snap.probing, egui::Button::new("↻ Probe all remotes"))
.clicked()
{
self.pending.push(Msg::ProbeAll);
}
if self.snap.probing {
ui.spinner();
}
ui.label(
egui::RichText::new("a real SSH ref advertisement, with this key")
.small()
.weak(),
);
});
ui.separator();
if self.snap.probes.is_empty() {
// An EMPTY list is a claim about the world, so say which claim. "No repos
// configured" and "repos exist, none listed here" are different bugs.
ui.label(
egui::RichText::new(
"no remotes known to this pane — that is an ABSENT repo list, not a \
clean bill of health. Nothing here has been checked.",
)
.small()
.color(WARN),
);
return;
}
let mut probe_one: Option<String> = None;
egui::ScrollArea::vertical().id_salt("dk-remotes").show(ui, |ui| {
for p in &self.snap.probes {
ui.horizontal_wrapped(|ui| {
chip(ui, p.outcome.as_str(), outcome_color(&p.outcome));
if ui
.add(
egui::Label::new(egui::RichText::new(&p.remote).monospace().small())
.sense(Sense::click()),
)
.on_hover_text("click to probe just this remote")
.clicked()
{
probe_one = Some(p.remote.clone());
}
if p.elapsed_ms > 0 {
ui.label(
egui::RichText::new(format!("{} ms", p.elapsed_ms)).small().weak(),
);
}
});
// The compiler's / server's own words, verbatim. An enum groups; this
// is what someone actually fixes from.
if !p.detail.is_empty() {
let col = if p.outcome.is_green() { None } else { Some(outcome_color(&p.outcome)) };
let t = egui::RichText::new(format!(" {}", p.detail)).small().monospace();
ui.label(if let Some(c) = col { t.color(c) } else { t.weak() });
}
}
});
if let Some(r) = probe_one {
self.pending.push(Msg::Probe(r));
}
}
// ── pane 2: which rung of the ladder answered ────────────────────────────
fn ui_ladder(&mut self, ui: &mut Ui) {
let res = &self.snap.resolution;
match res.chosen_origin() {
Some(o) => {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("resolved via").small().weak());
chip(ui, o.as_str(), if o.is_legacy() { WARN } else { TEAL });
if o.is_escape_hatch() {
chip(ui, "ENV OVERRIDE", WARN);
}
});
ui.label(egui::RichText::new(o.explain()).small().weak());
if o.is_legacy() {
ui.label(
egui::RichText::new(
"this is a nornir-era path, kept working on purpose. The default \
needs no configuration: ~/.dwarves/ssh/id_ed25519.",
)
.small()
.color(WARN),
);
}
}
None => {
ui.label(
egui::RichText::new("NO rung answered — every path below was empty")
.small()
.color(RED),
);
}
}
ui.separator();
ui.label(
egui::RichText::new("every path that was looked at, in order:").small().weak(),
);
egui::ScrollArea::vertical().id_salt("dk-ladder").show(ui, |ui| {
for (i, c) in res.candidates.iter().enumerate() {
let won = res.chosen == Some(i);
ui.horizontal_wrapped(|ui| {
if won {
chip(ui, "USED", TEAL);
} else if c.present {
// A key sitting on a rung that did NOT win is worth seeing: it
// is the shape of "you edited the wrong file".
chip(ui, "present, outranked", WARN);
} else {
chip(ui, "absent", GREY);
}
ui.label(egui::RichText::new(c.origin.as_str()).small().strong());
});
ui.label(
egui::RichText::new(format!(" {}", c.private.display()))
.small()
.monospace()
.weak(),
);
}
});
}
// ── the single-body path: three panes, two draggable dividers ────────────
/// Draw all three panes stacked with draggable dividers. Used only when a host
/// mounts the deck as ONE tab body; a scene host calls
/// [`view_pane`](Self::view_pane) per pane instead and gets the scene's own
/// splitters, chrome, fold and maximise.
fn ui_all(&mut self, ui: &mut Ui) {
let total = ui.available_height().max(160.0);
let mut heights = [total * self.ratios[0], total * self.ratios[1]];
for idx in 0..PANE_COUNT {
let title = self.pane_title(idx).unwrap_or_default();
ui.label(egui::RichText::new(title).small().strong());
// The two resizable panes have a height; the last one takes the rest.
if let Some(height) = heights.get_mut(idx) {
let h = height.clamp(60.0, total - 80.0);
egui::ScrollArea::vertical()
.id_salt(("dk-body", idx))
.max_height(h)
.show(ui, |ui| {
ui.set_min_height(h);
self.view_pane_body(idx, ui);
});
if let Some(dy) = divider(ui) {
let f = (self.ratios[idx] + dy / total).clamp(0.12, 0.70);
self.ratios[idx] = f;
*height = total * f;
}
} else {
egui::ScrollArea::vertical().id_salt(("dk-body", idx)).show(ui, |ui| {
self.view_pane_body(idx, ui);
});
}
}
}
/// The pane body without the `take_msgs` drain (the single-body path drains once
/// at the end, via `Facet::ui`'s caller).
fn view_pane_body(&mut self, idx: usize, ui: &mut Ui) {
match idx {
PANE_KEY => self.ui_key(ui),
PANE_REMOTES => self.ui_remotes(ui),
PANE_LADDER => self.ui_ladder(ui),
_ => {}
}
}
/// The observable state — LAW 6 / FC-6. Everything a headless driver needs to
/// assert that the five states render as five, including the counts that make a
/// partial green legible.
pub fn state_json(&self) -> serde_json::Value {
let st = self.snap.state();
let probed: Vec<&RemoteProbe> = self
.snap
.probes
.iter()
.filter(|p| p.outcome != ProbeOutcome::NotProbed)
.collect();
serde_json::json!({
"state": st,
"state_label": st.as_str(),
"headline": st.headline(),
// Deliberately NOT `state == Fetching`: `is_green` is false for every
// state in which nothing was measured.
"green": st.is_green(),
"identity": self.snap.identity,
"identity_stale": self.snap.identity_is_stale(),
"has_public_key": self.snap.public_line.is_some(),
// The public key IS publishable — that is its entire job. The private
// path appears as a PATH; its bytes are never read by this crate.
"public_line": self.snap.public_line,
"private_path": self.snap.resolution.private_path().map(|p| p.display().to_string()),
"origin": self.snap.resolution.chosen_origin().map(|o| o.as_str()),
"origin_is_legacy": self.snap.resolution.is_legacy_fallback(),
"origin_is_env_override": self.snap.resolution.chosen_origin().is_some_and(KeyOrigin::is_escape_hatch),
"ladder": self.snap.resolution.candidates.iter().map(|c| serde_json::json!({
"origin": c.origin.as_str(),
"path": c.private.display().to_string(),
"present": c.present,
})).collect::<Vec<_>>(),
"remotes_known": self.snap.probes.len(),
"remotes_probed": probed.len(),
"remotes_ok": probed.iter().filter(|p| p.outcome.is_green()).count(),
"remotes_rejected": probed.iter().filter(|p| p.outcome == ProbeOutcome::AuthRejected).count(),
"remotes_unreachable": probed.iter().filter(|p| p.outcome == ProbeOutcome::Unreachable).count(),
"minted_this_session": self.snap.minted_this_session,
"probing": self.snap.probing,
})
}
}
fn paste_guidance(ui: &mut Ui) {
ui.label(egui::RichText::new("where to paste it").small().strong());
ui.label(
egui::RichText::new(
"• PREFER a per-repo DEPLOY KEY. Codeberg: repo → Settings → Deploy Keys → \
Add Deploy Key. GitHub: repo → Settings → Deploy keys → Add deploy key. \
Leave write access OFF — the forge only fetches. A deploy key is scoped to \
ONE repository, so a leaked forge key cannot touch anything else you own.",
)
.small(),
);
ui.label(
egui::RichText::new(
"• An ACCOUNT SSH key (Codeberg/GitHub → Settings → SSH keys) works for every \
repo at once and is the reason you would only need to paste this in one \
place — which is also exactly why it is the riskier choice: it carries your \
whole account, not one repo.",
)
.small(),
);
ui.label(
egui::RichText::new(
"• A plain SSH server: append the line to ~/.ssh/authorized_keys of the \
account the forge logs in as.",
)
.small(),
);
ui.label(
egui::RichText::new(
"Codeberg rejects the SAME deploy key on two repositories by default — add it \
per repo, or use one account key.",
)
.small()
.weak(),
);
}
/// A draggable horizontal divider; returns the vertical drag delta when moved.
fn divider(ui: &mut Ui) -> Option<f32> {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), Sense::drag());
let hot = resp.hovered() || resp.dragged();
let col = if hot { ui.visuals().selection.bg_fill } else { ui.visuals().weak_text_color() };
ui.painter().rect_filled(rect.shrink2(egui::vec2(0.0, 2.0)), 2.0, col);
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
resp.dragged().then(|| resp.drag_delta().y)
}
const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
/// **A rejection is not an absence.** `NoKeyFound` and `RejectedByRemote` are both
/// bad and both used to be plain RED — which made two states that need two different
/// fixes (mint one vs. register the one you have) look like one state. The colour
/// test below is what caught it.
const REJECT: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x0a);
const WARN: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);
/// "Nobody asked" — its OWN colour, never green and never red. A never-probed row
/// that borrowed either colour would be making a claim nobody measured.
const GREY: egui::Color32 = egui::Color32::from_rgb(0x45, 0x4a, 0x4d);
const BLUE: egui::Color32 = egui::Color32::from_rgb(0x2e, 0x50, 0x6a);
fn state_color(s: &KeyState) -> egui::Color32 {
match s {
KeyState::NoKeyFound => RED,
KeyState::RejectedByRemote { .. } => REJECT,
KeyState::MintedNotRegistered => BLUE,
// Unknown is unknown. Not green, not red.
KeyState::PresentNeverUsed => GREY,
KeyState::Fetching { failing: 0, .. } => TEAL,
KeyState::Fetching { .. } => WARN,
}
}
fn outcome_color(o: &ProbeOutcome) -> egui::Color32 {
match o {
ProbeOutcome::Authenticated { .. } => TEAL,
ProbeOutcome::AuthRejected => RED,
ProbeOutcome::Unreachable => WARN,
ProbeOutcome::NoKey => RED,
ProbeOutcome::NotProbed => GREY,
}
}
use facett_core::look::chip;
/// **`Facet` only** — facett-core's blanket `impl<T: Facet> Panel for T` supplies
/// `Panel`, so there is nothing to disambiguate at a call site. Implementing both by
/// hand is what produced `E0034` on `ui` / `update_json` in dwarves' pane.
impl Facet for DeployKeyDeck {
fn title(&self) -> &str {
"Deploy key"
}
fn ui(&mut self, ui: &mut Ui) {
self.ui_all(ui);
}
fn state_json(&self) -> serde_json::Value {
DeployKeyDeck::state_json(self)
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if v.get("probe_all").is_some() {
self.pending.push(Msg::ProbeAll);
}
if let Some(r) = v.get("probe").and_then(|x| x.as_str()) {
self.pending.push(Msg::Probe(r.to_string()));
}
if v.get("mint").is_some() {
self.pending.push(Msg::Mint);
}
if v.get("relabel").is_some() {
self.pending.push(Msg::Relabel);
}
if v.get("copy").is_some() && let Some(line) = self.snap.public_line.clone() {
self.copy_buffer = Some(line.clone());
self.pending.push(Msg::Copy(line));
}
if let Some(r) = v.get("set_ratio").and_then(|x| x.as_array())
&& let (Some(i), Some(f)) =
(r.first().and_then(|x| x.as_u64()), r.get(1).and_then(|x| x.as_f64()))
&& (i as usize) < self.ratios.len()
{
self.ratios[i as usize] = (f as f32).clamp(0.12, 0.70);
}
}
fn caps(&self) -> FacetCaps {
FacetCaps { copyable: true, resizable: true, themeable: true, ..FacetCaps::NONE }
}
/// Ctrl-C yields the PUBLIC key — the one thing on this pane whose whole purpose
/// is to be copied elsewhere. There is no branch here that can reach the private
/// half: this deck never reads it.
fn copy(&mut self) -> Option<String> {
self.copy_buffer.clone().or_else(|| self.snap.public_line.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_git::deploykey::{KeyCandidate, KeyOrigin};
use std::path::PathBuf;
fn res_with_key(origin: KeyOrigin) -> Resolution {
let dir = PathBuf::from("/x/ssh");
Resolution {
candidates: vec![KeyCandidate {
origin,
dir: dir.clone(),
private: dir.join("id_ed25519"),
public: dir.join("id_ed25519.pub"),
present: true,
}],
chosen: Some(0),
// A single-rung resolution is what a CONFINED ladder produces, and
// this helper builds exactly one rung on purpose — it is testing how
// one origin renders, not how a ladder resolves.
confined: true,
}
}
fn probe(remote: &str, outcome: ProbeOutcome, detail: &str) -> RemoteProbe {
RemoteProbe {
remote: remote.into(),
outcome,
detail: detail.into(),
elapsed_ms: 7,
}
}
fn deck(snap: KeySnapshot) -> DeployKeyDeck {
DeployKeyDeck::new(snap)
}
/// **THE requirement.** The four states the pane must not collapse render as four
/// distinguishable states in `state_json` — different label, different headline,
/// and only one of them green.
#[test]
fn the_four_states_render_as_four_distinguishable_things() {
let no_key = deck(KeySnapshot::default());
let never_used = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
probes: vec![probe("ssh://git@git.gunnar.rs:2222/nordisk/nornir.git", ProbeOutcome::NotProbed, "")],
..Default::default()
});
let rejected = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
probes: vec![probe(
"ssh://git@git.gunnar.rs:2222/nordisk/nornir.git",
ProbeOutcome::AuthRejected,
"ssh publickey auth rejected for git@git.gunnar.rs",
)],
..Default::default()
});
let fetching = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
probes: vec![probe(
"ssh://git@git.gunnar.rs:2222/nordisk/nornir.git",
ProbeOutcome::Authenticated { head: Some("abc".into()) },
"84 ref(s) advertised",
)],
..Default::default()
});
let decks = [&no_key, &never_used, &rejected, &fetching];
let labels: Vec<String> = decks
.iter()
.map(|d| d.state_json()["state_label"].as_str().unwrap().to_string())
.collect();
let heads: Vec<String> = decks
.iter()
.map(|d| d.state_json()["headline"].as_str().unwrap().to_string())
.collect();
for i in 0..decks.len() {
for j in (i + 1)..decks.len() {
assert_ne!(labels[i], labels[j], "two states share a label: {labels:?}");
assert_ne!(heads[i], heads[j], "two states share a headline");
assert_ne!(
state_color(&decks[i].snap.state()),
state_color(&decks[j].snap.state()),
"two states share a colour: {} vs {}",
labels[i],
labels[j]
);
}
}
assert_eq!(labels[0], "NO KEY FOUND");
assert_eq!(labels[1], "KEY PRESENT — NEVER USED");
assert_eq!(labels[2], "KEY REJECTED BY REMOTE");
assert_eq!(labels[3], "FETCHING");
// Exactly ONE of them is green, and it is the one where a remote answered.
let greens: Vec<bool> =
decks.iter().map(|d| d.state_json()["green"].as_bool().unwrap()).collect();
assert_eq!(greens, vec![false, false, false, true]);
}
/// A remote that has never been probed is counted as KNOWN but NOT PROBED, and
/// contributes to neither the ok nor the rejected tally. `remotes_probed` is the
/// denominator that makes an all-quiet fleet legible instead of vacuously fine.
#[test]
fn a_never_probed_remote_is_not_a_passing_remote() {
let d = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
probes: (0..8)
.map(|i| probe(&format!("ssh://git@git.gunnar.rs:2222/nordisk/r{i}.git"), ProbeOutcome::NotProbed, ""))
.collect(),
..Default::default()
});
let s = d.state_json();
assert_eq!(s["remotes_known"], 8);
assert_eq!(s["remotes_probed"], 0);
assert_eq!(s["remotes_ok"], 0);
assert_eq!(s["green"], false);
assert_eq!(s["state_label"], "KEY PRESENT — NEVER USED");
}
/// The exact 2026-07-22 incident shape: eight repos rejected beside eleven that
/// authenticate. The pane must report BOTH numbers and must not be green.
#[test]
fn eight_failing_beside_eleven_good_is_not_green() {
let mut probes: Vec<RemoteProbe> = (0..11)
.map(|i| {
probe(
&format!("ssh://git@git.gunnar.rs:2222/nordisk/ok{i}.git"),
ProbeOutcome::Authenticated { head: None },
"refs advertised",
)
})
.collect();
probes.extend((0..8).map(|i| {
probe(
&format!("ssh://git@git.gunnar.rs:2222/nordisk/bad{i}.git"),
ProbeOutcome::AuthRejected,
"SSH remote needs a deploy key, but none was found",
)
}));
let d = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
probes,
..Default::default()
});
let s = d.state_json();
assert_eq!(s["remotes_ok"], 11);
assert_eq!(s["remotes_rejected"], 8);
assert_eq!(s["green"], false, "a partial green is not a green");
assert!(s["headline"].as_str().unwrap().contains('8'));
}
/// The fifth state: minted-here-and-never-registered is its own thing, because a
/// key nobody has been told about fails exactly like a key that does not exist.
#[test]
fn a_freshly_minted_key_says_it_is_not_registered_yet() {
let d = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
minted_this_session: true,
..Default::default()
});
let s = d.state_json();
assert_eq!(s["state_label"], "MINTED — NOT REGISTERED");
assert_eq!(s["green"], false);
assert_ne!(s["state_label"], "NO KEY FOUND");
assert_ne!(s["state_label"], "KEY PRESENT — NEVER USED");
}
/// Which rung answered is observable — a legacy or env-override resolution is a
/// FLAG a driver can assert on, not a thing a human has to infer.
#[test]
fn the_pane_says_which_rung_answered() {
let d = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::NornirHomeLegacy),
public_line: Some("ssh-ed25519 AAAA nornir@oden".into()),
identity: Some("nornir@oden".into()),
..Default::default()
});
let s = d.state_json();
assert_eq!(s["origin"], "~/.nornir/ssh (legacy)");
assert_eq!(s["origin_is_legacy"], true);
assert_eq!(s["origin_is_env_override"], false);
assert_eq!(s["identity"], "nornir@oden");
assert_eq!(s["identity_stale"], true, "the identity still names the old service");
let env = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::EnvNornirLegacy),
..Default::default()
});
assert_eq!(env.state_json()["origin_is_env_override"], true);
let dflt = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
identity: Some("dwarves@oden".into()),
public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
..Default::default()
});
let s = dflt.state_json();
assert_eq!(s["origin_is_legacy"], false);
assert_eq!(s["origin_is_env_override"], false);
assert_eq!(s["identity_stale"], false);
}
/// Copy yields the PUBLIC key and there is no path by which it yields anything
/// else — the deck holds no private material to leak.
#[test]
fn copy_yields_the_public_key_only() {
let mut d = deck(KeySnapshot {
resolution: res_with_key(KeyOrigin::DwarvesHome),
public_line: Some("ssh-ed25519 AAAAC3Nz dwarves@oden".into()),
..Default::default()
});
assert!(d.caps().copyable);
assert_eq!(d.copy().as_deref(), Some("ssh-ed25519 AAAAC3Nz dwarves@oden"));
Facet::update_json(&mut d, r#"{"copy":true}"#);
assert_eq!(d.take_msgs(), vec![Msg::Copy("ssh-ed25519 AAAAC3Nz dwarves@oden".into())]);
// state_json publishes the public line and the private PATH — never bytes.
let s = d.state_json();
assert!(s["public_line"].as_str().unwrap().starts_with("ssh-ed25519 "));
assert_eq!(s["private_path"], "/x/ssh/id_ed25519");
assert!(!s.to_string().contains("BEGIN OPENSSH PRIVATE KEY"));
}
/// An empty deck with NO key still has three named panes and says, in words, that
/// nothing has been checked — a blank pane would read as "fine".
#[test]
fn an_empty_deck_says_nothing_was_checked_rather_than_nothing() {
let d = deck(KeySnapshot::default());
assert_eq!(d.pane_count(), 3);
assert_eq!(d.pane_title(PANE_KEY).unwrap(), "Deploy key — NO KEY FOUND");
assert_eq!(d.pane_title(PANE_REMOTES).unwrap(), "Remotes — 0/0 probed");
assert!(d.pane_title(PANE_LADDER).is_some());
assert!(d.pane_title(3).is_none());
let s = d.state_json();
assert_eq!(s["green"], false);
assert_eq!(s["has_public_key"], false);
}
/// The messages the host acts on come back from `view_pane`, and an out-of-range
/// index draws a marker instead of silently nothing.
#[test]
fn the_deck_drives_through_update_json() {
let mut d = deck(KeySnapshot::default());
Facet::update_json(&mut d, r#"{"mint":true}"#);
Facet::update_json(&mut d, r#"{"probe_all":true}"#);
Facet::update_json(&mut d, r#"{"probe":"ssh://git@git.gunnar.rs:2222/nordisk/nornir.git"}"#);
Facet::update_json(&mut d, r#"{"relabel":true}"#);
assert_eq!(
d.take_msgs(),
vec![
Msg::Mint,
Msg::ProbeAll,
Msg::Probe("ssh://git@git.gunnar.rs:2222/nordisk/nornir.git".into()),
Msg::Relabel
]
);
}
}