crates/nornir-git/src/ssh.rs
//! Pure-Rust git-over-SSH (via `russh`) — **no `ssh` subprocess**, honoring
//! nornir's no-shellout rule (gix's own SSH transport execs `ssh`, which is why
//! [`crate::gitio`] refuses SSH and defers here).
//!
//! Implemented:
//! - **`ls-remote`** — connect, authenticate with the nornir ed25519 deploy key,
//! run `git-upload-pack '<path>'`, and parse the server's ref advertisement
//! (pkt-line) into `(sha, refname)` pairs. The cheap poll primitive.
//! - **pack transfer (fetch/clone)** — [`connect_upload_pack`] execs
//! `git-upload-pack` and exposes the channel as blocking `Read`/`Write`
//! ([`SshReader`] / [`SshWriter`], bridged from russh's async channel).
//! [`crate::gitio`] hands that to gix's blocking git transport
//! (`ConnectMode::Process`), so gix drives the full object negotiation and
//! pack indexing — no `ssh`/`git` subprocess, no hand-rolled packfile code.
//! - **any other remote git command** — [`connect_exec`] is the same channel with
//! the command left to the caller, so `git-receive-pack` (SSH **push**) needs no
//! second copy of the connect / auth / bridge / teardown sequence. That sequence
//! is ~40 lines and getting its `Drop` wrong is a real bug (see [`SshChannel`]),
//! which is exactly why it must exist once (LAW 5).
//!
//! Authentication is either a **private key on disk** or **`ssh-agent`**, or both
//! in order — see [`SshAuth`]. The key-path entry points ([`ls_remote`],
//! [`connect_upload_pack`], [`connect_exec`]) are unchanged and take the deploy-key
//! path directly; the agent is an addition, reached through the `_auth` variants.
//!
//! **A dead link ENDS a transfer, with an error naming the far end and why**
//! ([`SshLiveness`]). Before 2026-10-06 nothing did: russh was dialled with its
//! defaults (no keepalive, no inactivity bound) and russh's writer parks on the
//! SSH window with nothing to wake it when the session dies, so a WireGuard
//! tunnel that went silent for six minutes left six kit pushes parked FOREVER
//! (cov-13 finding 4: CLOSE-WAIT, 245 bytes unread, every thread asleep). See
//! [`SshLiveness`] for the bounds and what each one catches.
//!
//! The URL- and pkt-line parsers below are transport-agnostic and unit-tested.
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use tokio::io::AsyncWriteExt;
use tokio::sync::watch;
/// A parsed SSH git location.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshLocation {
pub user: String,
pub host: String,
pub port: u16,
/// Repository path as the remote `git-upload-pack` expects it.
pub path: String,
}
/// Parse `git@host:owner/repo(.git)` (scp-like) or
/// `ssh://[user@]host[:port]/path` into an [`SshLocation`]. Defaults: user
/// `git`, port `22`.
pub fn parse_ssh_url(url: &str) -> Result<SshLocation> {
if let Some(rest) = url.strip_prefix("ssh://") {
// ssh://[user@]host[:port]/path
let (authority, path) = rest
.split_once('/')
.ok_or_else(|| anyhow!("ssh url `{url}` has no path"))?;
let (user, hostport) = match authority.split_once('@') {
Some((u, hp)) => (u.to_string(), hp),
None => ("git".to_string(), authority),
};
let (host, port) = match hostport.split_once(':') {
Some((h, p)) => (
h.to_string(),
p.parse().with_context(|| format!("ssh url `{url}` bad port"))?,
),
None => (hostport.to_string(), 22),
};
if host.is_empty() {
bail!("ssh url `{url}` has empty host");
}
return Ok(SshLocation {
user,
host,
port,
path: format!("/{path}"),
});
}
// Any other explicit scheme (http://, https://, git://) is not SSH.
if url.contains("://") {
bail!("`{url}` is not an SSH url (expected host:path or ssh://…)");
}
// scp-like: [user@]host:path
let (userhost, path) = url
.split_once(':')
.ok_or_else(|| anyhow!("`{url}` is not an SSH url (expected host:path or ssh://…)"))?;
let (user, host) = match userhost.split_once('@') {
Some((u, h)) => (u.to_string(), h.to_string()),
None => ("git".to_string(), userhost.to_string()),
};
if host.is_empty() || path.is_empty() {
bail!("ssh url `{url}` has empty host or path");
}
Ok(SshLocation {
user,
host,
port: 22,
path: path.to_string(),
})
}
/// Parse a git smart-protocol **ref advertisement** (the bytes a server emits
/// right after `git-upload-pack`) into `(sha, refname)` pairs.
///
/// pkt-line framing: each line is 4 hex digits of length (counting the 4) then
/// the payload; `0000` is a flush. The first ref line carries capabilities
/// after a NUL. A leading `# service=…` line (smart-HTTP style) is skipped.
pub fn parse_ref_advertisement(mut buf: &[u8]) -> Result<Vec<(String, String)>> {
let mut refs = Vec::new();
loop {
if buf.len() < 4 {
break;
}
let len_hex = std::str::from_utf8(&buf[..4]).context("pkt-line length not utf8")?;
let len = usize::from_str_radix(len_hex, 16)
.with_context(|| format!("pkt-line length `{len_hex}` not hex"))?;
if len == 0 {
// flush-pkt — end of the advertisement (first section).
break;
}
if len < 4 || len > buf.len() {
bail!("pkt-line length {len} out of range (have {} bytes)", buf.len());
}
let payload = &buf[4..len];
buf = &buf[len..];
// Drop a trailing newline, then split off capabilities after a NUL.
let line = payload.strip_suffix(b"\n").unwrap_or(payload);
let line = line.split(|&b| b == 0).next().unwrap_or(line);
let text = std::str::from_utf8(line).context("ref line not utf8")?;
if text.starts_with("# service=") {
continue;
}
if let Some((sha, rest)) = text.split_once(' ') {
// Accept SHA-1 (40 hex) AND SHA-256 (64 hex) object ids — codeberg hosts
// sha256 repos (e.g. `mikronova/tunnr`), whose oids the old 40-only guard
// silently dropped, leaving zero refs → "advertised no HEAD".
if (sha.len() == 40 || sha.len() == 64) && sha.bytes().all(|b| b.is_ascii_hexdigit())
{
// Protocol v0: `<sha> <refname>`. Protocol v2 `ls-refs`:
// `<sha> <refname>[ symref-target:<target>][ peeled:<sha>]` — take only
// the FIRST token as the refname so `HEAD symref-target:refs/heads/main`
// is recorded as `HEAD` (else the HEAD lookup in `ssh_sync` misses).
let name = rest.split_whitespace().next().unwrap_or(rest);
refs.push((sha.to_string(), name.to_string()));
}
}
}
Ok(refs)
}
/// Index just past the first flush-pkt (`0000`) in `buf`, or `None` if the buffer does
/// not yet contain a complete section ending in a flush. Used to frame one protocol
/// section (the capability/ref advertisement, or an `ls-refs` response) out of the
/// streamed SSH channel bytes.
fn section_end(buf: &[u8]) -> Option<usize> {
let mut i = 0;
while i + 4 <= buf.len() {
let len_hex = std::str::from_utf8(&buf[i..i + 4]).ok()?;
let len = usize::from_str_radix(len_hex, 16).ok()?;
if len == 0 {
return Some(i + 4); // flush-pkt — section complete
}
if len == 1 {
i += 4; // delim-pkt (0001) — v2 section separator, no payload
continue;
}
if len < 4 || i + len > buf.len() {
return None; // incomplete pkt-line — need more bytes
}
i += len;
}
None
}
/// The message of a leading `ERR <msg>` pkt-line, when the far end answered with one.
///
/// git's upload-pack (and the gunnar forge) refuse an unknown repo, or a key with no
/// read grant, with ONE `ERR` pkt-line and then hang up without a flush. That is the
/// server's answer and must be reported as such, not as a truncated list.
fn err_pkt(buf: &[u8]) -> Option<String> {
let len = usize::from_str_radix(std::str::from_utf8(buf.get(..4)?).ok()?, 16).ok()?;
if len < 8 || buf.len() < len {
return None;
}
let line = std::str::from_utf8(&buf[4..len]).ok()?;
line.strip_prefix("ERR ").map(|m| m.trim_end().to_string())
}
/// Encode one pkt-line for a payload (adds the 4-hex length prefix).
fn pkt_line(payload: &str) -> Vec<u8> {
let mut v = format!("{:04x}", payload.len() + 4).into_bytes();
v.extend_from_slice(payload.as_bytes());
v
}
// ── authentication + host keys ──────────────────────────────────────────────
/// What to do about the server's host key.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HostKeyPolicy {
/// Trust on first use, and refuse a **changed** key.
///
/// The default, and what a developer expects: an unknown host is recorded in
/// `known_hosts`, a host whose key has changed is a hard failure. The
/// asymmetry is the point — the unknown case is normal, the changed case is
/// either a rekeyed server or an attack, and only a human can tell those
/// apart.
#[default]
TofuKnownHosts,
/// Only accept a key already recorded in `known_hosts`. Nothing is learned.
StrictKnownHosts,
/// Accept any host key without checking. What the deploy-key path has always
/// done (the bearer of the key already gates access) and therefore what
/// [`SshAuth::key`] selects, so the existing entry points behave exactly as
/// before. Never a good default for an interactive client.
AcceptAny,
/// **A declared pin, and nothing learned.** The key the far end presents
/// must be ED25519 and its SHA256 fingerprint must equal the one pin in
/// [`SshAuth::host_key_pins`] for this `host:port`. `known_hosts` is not
/// read and not written. See [`pin_verdict`] for the three refusals
/// (mismatch, absent, not ED25519) — each by name, and the mismatch shows
/// both fingerprints.
///
/// Why this exists (gunnar resume plan 2026-09-28, step 5): a forge that is
/// re-installed mints a NEW host key, and a client that keeps no record
/// (a fresh `known_hosts` per run) trusted whatever answered, EVERY run.
/// That is trust-on-first-use with no second use to catch anything. A pin
/// comes from the record of the install, so the first use is checked too.
///
/// An absent pin is refused unless [`SshAuth::trust_first_use`] is set,
/// which a caller sets only from an explicit operator flag; it is logged.
Pinned,
}
/// **One declared host-key pin**: the ED25519 fingerprint `host:port` must
/// present, `SHA256:<base64, unpadded>` exactly as `ssh-keygen -l` and
/// [`russh::keys::ssh_key::PublicKey::fingerprint`] print it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HostKeyPin {
/// The host as dialled (no user, no port).
pub host: String,
/// The port as dialled.
pub port: u16,
/// `SHA256:…`.
pub fingerprint: String,
}
impl HostKeyPin {
/// A pin, with the fingerprint's SHAPE checked: `SHA256:` and 43 base64
/// characters (32 bytes, unpadded). A pin that could never match anything
/// is refused here rather than at the first connect.
pub fn new(host: impl Into<String>, port: u16, fingerprint: impl Into<String>) -> Result<Self> {
let host = host.into();
let fingerprint = fingerprint.into().trim().to_string();
if host.trim().is_empty() {
bail!("REFUSED [host-key-pin-shape] a pin names no host");
}
let body = fingerprint.strip_prefix("SHA256:").unwrap_or("");
let b64 = |c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/';
if body.len() != 43 || !body.chars().all(b64) {
bail!(
"REFUSED [host-key-pin-shape] {host}:{port}: {fingerprint:?} is not a SHA256 fingerprint \
(`SHA256:` and 43 base64 characters)"
);
}
Ok(HostKeyPin { host, port, fingerprint })
}
/// `host:port`, the name every refusal uses.
pub fn addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
/// The `SHA256:…` fingerprint of a public key — the one spelling a pin uses.
pub fn fingerprint_of(key: &russh::keys::ssh_key::PublicKey) -> String {
key.fingerprint(russh::keys::HashAlg::Sha256).to_string()
}
/// How a presented host key was accepted — the caller logs it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinVerdict {
/// It equals the declared pin.
Pinned { fingerprint: String },
/// No pin was declared and the operator said `--trust-first-use`.
TrustedFirstUse { fingerprint: String },
}
/// **The pin decision, pure.** `Ok` says how the key was accepted; `Err` is a
/// refusal BY NAME:
///
/// * `host-key-not-ed25519` — the pin is an ED25519 fingerprint by contract,
/// and a server offering another algorithm is not the box the pin names;
/// * `host-key-pin-mismatch` — shows BOTH fingerprints, presented and pinned;
/// * `host-key-pin-ambiguous` — two pins for one `host:port` that disagree
/// (a declaration that cannot be honoured is refused, not tie-broken);
/// * `host-key-pin-absent` — no pin for this `host:port` and no
/// `trust_first_use`.
pub fn pin_verdict(
pins: &[HostKeyPin],
host: &str,
port: u16,
presented: &russh::keys::ssh_key::PublicKey,
trust_first_use: bool,
) -> std::result::Result<PinVerdict, String> {
let got = fingerprint_of(presented);
if presented.algorithm() != russh::keys::Algorithm::Ed25519 {
return Err(format!(
"REFUSED [host-key-not-ed25519] {host}:{port} presented a {} key ({got}); a pin names an ED25519 key",
presented.algorithm()
));
}
let mine: Vec<&HostKeyPin> = pins.iter().filter(|p| p.host == host && p.port == port).collect();
let mut wanted: Vec<&str> = mine.iter().map(|p| p.fingerprint.as_str()).collect();
wanted.sort_unstable();
wanted.dedup();
match wanted.as_slice() {
[] if trust_first_use => Ok(PinVerdict::TrustedFirstUse { fingerprint: got }),
[] => Err(format!(
"REFUSED [host-key-pin-absent] {host}:{port} presented {got} and NO pin is declared for it. \
Record the key from the install receipt (the install that minted it), or pass \
--trust-first-use explicitly (logged)."
)),
[one] if *one == got => Ok(PinVerdict::Pinned { fingerprint: got }),
[one] => Err(format!(
"REFUSED [host-key-pin-mismatch] {host}:{port} presented {got}, the pin declares {one}. \
Either the box was re-installed and the pin was not re-recorded from its receipt, or this \
is not the box. Nothing is learned either way."
)),
many => Err(format!(
"REFUSED [host-key-pin-ambiguous] {host}:{port} has {} pins that disagree ({}); presented {got}",
many.len(),
many.join(", ")
)),
}
}
// ── liveness ────────────────────────────────────────────────────────────────
/// **How long the transport waits before it calls a link dead** — and the
/// reason every one of these exists, because each closes a hang that was real.
///
/// The defect (cov-13 finding 4, 2026-10-06): the kit's WireGuard tunnel went
/// silent 18:33–18:39 and six pushes on this bridge hung FOREVER, their sockets
/// in CLOSE-WAIT with 245 bytes unread and every thread parked. Three things
/// were missing, and the tests in `tests/ssh_liveness.rs` reproduce each:
///
/// 1. **No keepalive, no inactivity bound.** russh's `client::Config` defaults
/// both to `None`, so a silent link was never noticed: the session waited on
/// a read that never came, or on a socket write into a peer that had stopped
/// reading (a zero window that a local tunnel endpoint keeps ACKing, so TCP
/// itself never times out). [`keepalive_interval`](Self::keepalive_interval)
/// × [`keepalive_max`](Self::keepalive_max) and
/// [`inactivity`](Self::inactivity) close that.
/// 2. **A writer parked on the SSH window is never woken when the session
/// dies.** russh's channel writer waits on a `Notify` that only a
/// window-adjust fires; a session that ends fires nothing. So even a session
/// that DID end left the push asleep. [`SshWriter`] races every write against
/// the session's end and returns the reason at once.
/// 3. **No bound on the transfer itself.** A far end that answers keepalives
/// but never moves a byte (a disk-stalled server, a wedged handler) held the
/// push forever. [`stall`](Self::stall) is the per-read / per-write bound.
///
/// Plus [`connect`](Self::connect): a far end that accepts TCP and never sends
/// the SSH banner held `connect` forever too.
///
/// The defaults are a decision, asserted by `the_shipped_bounds_are_a_decision`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SshLiveness {
/// TCP connect + version exchange + key exchange. **30 s.**
pub connect: Duration,
/// Send an SSH keepalive (`keepalive@openssh.com`, want-reply) when nothing
/// has arrived for this long. **15 s** — OpenSSH's `ServerAliveInterval`.
pub keepalive_interval: Duration,
/// Give up after this many keepalives in a row go unanswered. **3** —
/// `ServerAliveCountMax`. A silent link is called dead after
/// `keepalive_interval × (keepalive_max + 1)` = **60 s** at the defaults.
pub keepalive_max: usize,
/// The session's own bound on nothing moving at all — and the ONLY thing
/// that bounds a socket write blocked on a peer that stopped reading
/// (russh flushes under this timer and nothing else). **60 s.** A live far
/// end never trips it: its keepalive replies count as traffic.
pub inactivity: Duration,
/// One read or one write on the channel that moves no byte for this long
/// fails with `TimedOut`. **600 s** — generous on purpose: a receive-pack
/// indexing and storing a large pack sends nothing until its report, and a
/// live far end is not to be cut off for thinking (the keepalive already
/// tells live from dead). Also bounds opening the channel.
pub stall: Duration,
}
impl Default for SshLiveness {
fn default() -> Self {
SshLiveness {
connect: Duration::from_secs(30),
keepalive_interval: Duration::from_secs(15),
keepalive_max: 3,
inactivity: Duration::from_secs(60),
stall: Duration::from_secs(600),
}
}
}
impl SshLiveness {
/// How long a SILENT link takes to be called dead: the keepalive verdict or
/// the inactivity bound, whichever is shorter.
pub fn silent_link_bound(&self) -> Duration {
let keepalive = self.keepalive_interval * (self.keepalive_max as u32 + 1);
keepalive.min(self.inactivity)
}
}
/// How to authenticate, and how much to trust the far end.
///
/// Two independent sources of identity, tried in this order:
///
/// 1. **`ssh-agent`** (`use_agent`, and only when `$SSH_AUTH_SOCK` is set) — every
/// identity the agent holds is offered.
/// 2. **private keys on disk** (`keys`) — each in turn, decrypting with
/// `passphrase` when the file is encrypted.
///
/// The whole list is tried rather than the first readable one: a key that *exists*
/// is not a key the server *accepts*, and giving up after the first would fail on
/// every machine carrying a stale `id_rsa`.
#[derive(Debug, Clone)]
pub struct SshAuth {
/// Private keys to offer, in order.
pub keys: Vec<PathBuf>,
/// Passphrase for an encrypted private key.
pub passphrase: Option<String>,
/// Try `$SSH_AUTH_SOCK` before the keys on disk.
pub use_agent: bool,
/// Host-key handling.
pub host_key: HostKeyPolicy,
/// Override the `known_hosts` file. `None` means `~/.ssh/known_hosts`.
pub known_hosts: Option<PathBuf>,
/// The declared pins [`HostKeyPolicy::Pinned`] checks against. Ignored by
/// every other policy.
pub host_key_pins: Vec<HostKeyPin>,
/// Under [`HostKeyPolicy::Pinned`] only: accept (and LOG) a key for a
/// `host:port` that has no pin. Set only from an explicit operator flag.
pub trust_first_use: bool,
/// **Anonymous**: the SSH `none` method and nothing else — no agent, no
/// key. The read-only door a forge opens for a stranger (gunnar accepts
/// `none` for the username `anonymous` only and reads public repositories
/// to it). Never falls back to a key: an anonymous check that quietly
/// authenticated as somebody would prove nothing.
pub anonymous: bool,
/// When a link counts as dead ([`SshLiveness`]). Every constructor takes
/// the defaults.
pub liveness: SshLiveness,
}
impl Default for SshAuth {
/// Agent-only, trust-on-first-use. No key file is assumed to exist.
fn default() -> Self {
SshAuth {
keys: Vec::new(),
passphrase: None,
use_agent: true,
host_key: HostKeyPolicy::TofuKnownHosts,
known_hosts: None,
host_key_pins: Vec::new(),
trust_first_use: false,
anonymous: false,
liveness: SshLiveness::default(),
}
}
}
impl SshAuth {
/// The anonymous door: `none` only, trust-on-first-use host keys.
pub fn anonymous() -> Self {
SshAuth { use_agent: false, anonymous: true, ..SshAuth::default() }
}
/// Exactly one private key, no agent, no host-key checking.
///
/// **This is the shape every key-path entry point uses**, so `ls_remote(loc,
/// key)` and `connect_upload_pack(loc, key)` authenticate and trust precisely
/// as they did before the agent existed.
pub fn key(path: impl Into<PathBuf>) -> Self {
SshAuth {
keys: vec![path.into()],
passphrase: None,
use_agent: false,
host_key: HostKeyPolicy::AcceptAny,
known_hosts: None,
host_key_pins: Vec::new(),
trust_first_use: false,
anonymous: false,
liveness: SshLiveness::default(),
}
}
/// `ssh-agent` only, with trust-on-first-use host keys.
pub fn agent() -> Self {
SshAuth::default()
}
/// Append a private key to the list to try.
pub fn with_key(mut self, path: impl Into<PathBuf>) -> Self {
self.keys.push(path.into());
self
}
/// Append several private keys, in order.
pub fn with_keys<I: IntoIterator<Item = PathBuf>>(mut self, paths: I) -> Self {
self.keys.extend(paths);
self
}
/// Turn the `ssh-agent` attempt on or off.
pub fn with_agent(mut self, yes: bool) -> Self {
self.use_agent = yes;
self
}
/// Set the passphrase used to decrypt an encrypted key file.
pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> Self {
self.passphrase = Some(passphrase.into());
self
}
/// Set the host-key policy.
pub fn with_host_key_policy(mut self, policy: HostKeyPolicy) -> Self {
self.host_key = policy;
self
}
/// Override the `known_hosts` file.
pub fn with_known_hosts(mut self, path: impl Into<PathBuf>) -> Self {
self.known_hosts = Some(path.into());
self
}
/// Check the host key against these declared pins ([`HostKeyPolicy::Pinned`]).
pub fn with_host_key_pins<I: IntoIterator<Item = HostKeyPin>>(mut self, pins: I) -> Self {
self.host_key = HostKeyPolicy::Pinned;
self.host_key_pins.extend(pins);
self
}
/// Under [`HostKeyPolicy::Pinned`], accept and log a key for an unpinned
/// `host:port`. Only ever from an explicit operator flag.
pub fn with_trust_first_use(mut self, yes: bool) -> Self {
self.trust_first_use = yes;
self
}
/// Set when a link counts as dead ([`SshLiveness`]).
pub fn with_liveness(mut self, liveness: SshLiveness) -> Self {
self.liveness = liveness;
self
}
}
// ── russh client ────────────────────────────────────────────────────────────
struct Client {
host: String,
port: u16,
policy: HostKeyPolicy,
known_hosts: Option<PathBuf>,
pins: Vec<HostKeyPin>,
trust_first_use: bool,
liveness: SshLiveness,
/// **Why the session ended**, the moment it does. The session task owns this
/// handler, so the value is set by [`russh::client::Handler::disconnected`]
/// (with the reason) or, on every other way out, by `Drop`. [`SshReader`],
/// [`SshWriter`] and `ls-remote` watch it: it is what wakes a writer parked
/// on the SSH window, which russh itself never does.
life: watch::Sender<Option<String>>,
}
impl Client {
fn new(loc: &SshLocation, auth: &SshAuth, life: watch::Sender<Option<String>>) -> Self {
Client {
host: loc.host.clone(),
port: loc.port,
policy: auth.host_key,
known_hosts: auth.known_hosts.clone(),
pins: auth.host_key_pins.clone(),
trust_first_use: auth.trust_first_use,
liveness: auth.liveness,
life,
}
}
/// Record why the session ended — first reason wins.
fn ended(&self, why: String) {
self.life.send_if_modified(|v| {
if v.is_none() {
*v = Some(why);
true
} else {
false
}
});
}
/// The host-key verdict, with the *reason* for a refusal — which is the whole
/// value of the check, since "connection refused" naming no cause is the
/// message that costs an afternoon.
fn check(&self, key: &russh::keys::ssh_key::PublicKey) -> std::result::Result<bool, String> {
use russh::keys::known_hosts::{
check_known_hosts, check_known_hosts_path, learn_known_hosts, learn_known_hosts_path,
};
match self.policy {
HostKeyPolicy::AcceptAny => Ok(true),
HostKeyPolicy::Pinned => {
match pin_verdict(&self.pins, &self.host, self.port, key, self.trust_first_use)? {
PinVerdict::Pinned { .. } => {}
PinVerdict::TrustedFirstUse { fingerprint } => eprintln!(
"nornir-ssh: TRUST-FIRST-USE (explicit): {}:{} has no pin; accepted {fingerprint} this once, nothing recorded",
self.host, self.port
),
}
Ok(true)
}
HostKeyPolicy::StrictKnownHosts | HostKeyPolicy::TofuKnownHosts => {
let known = match &self.known_hosts {
Some(p) => check_known_hosts_path(&self.host, self.port, key, p),
None => check_known_hosts(&self.host, self.port, key),
};
match known {
// Recorded, and it matches.
Ok(true) => Ok(true),
// Not recorded at all.
Ok(false) => {
if self.policy == HostKeyPolicy::StrictKnownHosts {
return Err(format!(
"host key for {}:{} is not in known_hosts and the policy is strict",
self.host, self.port
));
}
let learned = match &self.known_hosts {
Some(p) => learn_known_hosts_path(&self.host, self.port, key, p),
None => learn_known_hosts(&self.host, self.port, key),
};
match learned {
Ok(()) => {
eprintln!(
"nornir-ssh: recorded a new host key for {}:{} ({}) — trust on first use",
self.host,
self.port,
key.fingerprint(Default::default())
);
Ok(true)
}
Err(e) => Err(format!("recording host key failed: {e}")),
}
}
// `KeyChanged` — recorded under a DIFFERENT key. Never learn
// over this; it is the one case that must stop the client.
Err(e) => Err(format!(
"host key for {}:{} does not match known_hosts: {e}",
self.host, self.port
)),
}
}
}
}
}
impl russh::client::Handler for Client {
type Error = russh::Error;
// russh 0.63 hands over a host CERTIFICATE as well as a plain key. 0.61 never
// did, and nothing here pins certificates, so a certificate is refused — the
// trust rule (a pinned plain host key, nothing else) is unchanged.
// 2026-10-02, W3 crypto bump.
async fn check_server_key(
&mut self,
server_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let russh::keys::PublicKeyOrCertificate::PublicKey { key: server_public_key, .. } = server_key else {
eprintln!("nornir-ssh: the server presented a host certificate; only a pinned plain host key is accepted");
return Ok(false);
};
match self.check(server_public_key) {
Ok(ok) => Ok(ok),
Err(msg) => {
eprintln!("nornir-ssh: {msg}");
Ok(false)
}
}
}
/// The session is over: say why, in words that name the cause, so the
/// reader or writer still waiting on it can end with that reason.
async fn disconnected(
&mut self,
reason: russh::client::DisconnectReason<Self::Error>,
) -> Result<(), Self::Error> {
match reason {
russh::client::DisconnectReason::ReceivedDisconnect(info) => {
self.ended(format!(
"the far end disconnected ({:?}{}{})",
info.reason_code,
if info.message.is_empty() { "" } else { ": " },
info.message
));
Ok(())
}
russh::client::DisconnectReason::Error(e) => {
self.ended(session_end_reason(&e, &self.liveness));
Err(e)
}
}
}
}
impl Drop for Client {
/// Every way out of the session task drops its handler — including the ones
/// that skip `disconnected` (an error shutting the socket down, the runtime
/// cancelling the task). So the end is ALWAYS signalled.
fn drop(&mut self) {
self.ended("the ssh session ended".to_string());
}
}
/// A russh session error, said as the CAUSE an operator acts on.
fn session_end_reason(e: &russh::Error, lv: &SshLiveness) -> String {
match e {
russh::Error::KeepaliveTimeout => format!(
"the link went silent: {} keepalives {} s apart went unanswered",
lv.keepalive_max,
lv.keepalive_interval.as_secs()
),
russh::Error::InactivityTimeout => format!(
"the link went silent: nothing moved for {} s (inactivity bound; a socket write \
into a peer that stopped reading ends here)",
lv.inactivity.as_secs()
),
russh::Error::Disconnect | russh::Error::HUP => {
"the far end closed the connection".to_string()
}
russh::Error::IO(io) => format!("the connection failed: {io}"),
other => format!("the ssh session failed: {other}"),
}
}
/// Authenticate `session` as `user`, trying the agent and then every key in
/// `auth`, and return a description of what worked (for logging).
///
/// Failing every method reports **all** of them: an "auth failed" that names no
/// path cannot be acted on.
async fn authenticate(
session: &mut russh::client::Handle<Client>,
user: &str,
auth: &SshAuth,
) -> Result<String> {
use russh::keys::{ssh_key::PrivateKey, PrivateKeyWithHashAlg};
let mut tried: Vec<String> = Vec::new();
if auth.anonymous {
return match session.authenticate_none(user).await {
Ok(r) if r.success() => Ok("anonymous (the none method)".to_string()),
Ok(_) => bail!("ssh anonymous (none) auth refused for {user}: the server offers no anonymous read here"),
Err(e) => bail!("ssh anonymous (none) auth for {user}: {e}"),
};
}
if auth.use_agent && std::env::var_os("SSH_AUTH_SOCK").is_some() {
match agent_auth(session, user).await {
Ok(Some(what)) => return Ok(what),
Ok(None) => tried.push("ssh-agent (no identity accepted)".to_string()),
Err(e) => tried.push(format!("ssh-agent ({e})")),
}
}
for path in &auth.keys {
let pem = match std::fs::read_to_string(path) {
Ok(p) => p,
Err(e) => {
tried.push(format!("{} (read: {e})", path.display()));
continue;
}
};
let key = match PrivateKey::from_openssh(&pem) {
Ok(k) if k.is_encrypted() => match auth.passphrase.as_deref() {
Some(pw) => match k.decrypt(pw) {
Ok(k) => k,
Err(e) => {
tried.push(format!("{} (decrypt: {e})", path.display()));
continue;
}
},
None => {
tried.push(format!("{} (encrypted, no passphrase given)", path.display()));
continue;
}
},
Ok(k) => k,
Err(e) => {
tried.push(format!("{} (parse: {e})", path.display()));
continue;
}
};
let hash_alg = session.best_supported_rsa_hash().await.ok().flatten().flatten();
match session
.authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg))
.await
{
Ok(r) if r.success() => return Ok(path.display().to_string()),
Ok(_) => tried.push(format!("{} (rejected)", path.display())),
Err(e) => tried.push(format!("{} ({e})", path.display())),
}
}
bail!(
"ssh publickey auth failed for {user}. Tried, in order: {}",
if tried.is_empty() {
"nothing — no agent was consulted and no key was configured".to_string()
} else {
tried.join("; ")
}
)
}
/// Offer every identity `ssh-agent` holds.
///
/// `Ok(None)` means the agent answered but none of its identities were accepted —
/// a different thing from the agent being unreachable, and reported as such.
async fn agent_auth(
session: &mut russh::client::Handle<Client>,
user: &str,
) -> std::result::Result<Option<String>, String> {
use russh::keys::agent::{client::AgentClient, AgentIdentity};
let mut agent = AgentClient::connect_env()
.await
.map_err(|e| format!("connect: {e}"))?;
let identities = agent
.request_identities()
.await
.map_err(|e| format!("request identities: {e}"))?;
for id in identities {
let (key, comment) = match id {
AgentIdentity::PublicKey { key, comment } => (key, comment),
AgentIdentity::Certificate { .. } => continue,
};
let hash_alg = session.best_supported_rsa_hash().await.ok().flatten().flatten();
match session
.authenticate_publickey_with(user, key, hash_alg, &mut agent)
.await
{
Ok(r) if r.success() => return Ok(Some(format!("ssh-agent: {comment}"))),
Ok(_) => continue,
Err(e) => return Err(format!("agent sign: {e}")),
}
}
Ok(None)
}
/// Connect to `loc` and authenticate per `auth`. Shared by [`ls_remote`] and
/// [`connect_exec`]. The returned session handle owns the connection's I/O task —
/// keep it alive while the channel is in use. The `String` names the key or agent
/// identity that worked.
async fn connect_session(
loc: &SshLocation,
auth: &SshAuth,
) -> Result<(russh::client::Handle<Client>, String, watch::Receiver<Option<String>>)> {
// TCP_NODELAY, which russh leaves OFF by default — its own doc says
// "disabled by default (i.e. Nagle's algorithm is active)".
//
// MEASURED on oden 2026-08-07 against a gunnar server, 50 pushes of one
// empty commit per client. The server stamps its own `elapsed_ms` on each
// `git.receive_pack.indexed`, so this is the server's view of the same work
// arriving from two different clients:
//
// stock git 2-3 ms (44 of 50 runs)
// this client 41-43 ms (49 of 50 runs), one at 70
//
// The packs are the same: one object, 191 vs 192 bytes mean. Identical work,
// a flat +39 ms, tightly clustered and never in between — that is not
// computation, it is the Linux delayed-ACK timer. A push writes a small
// pkt-line, then the pack, then waits for the report; Nagle holds the second
// small write until the peer ACKs the first, and the peer's ACK is itself
// delayed 40 ms because it has nothing to send back yet.
//
// The SERVER half of this was already set — `gunnar-ssh/src/server.rs` passes
// `nodelay: true` — so the two ends disagreed and only the client paid. Every
// caller here is interactive git traffic over loopback or LAN, which is
// exactly the traffic shape Nagle penalises and never the bulk-throughput one
// it helps.
//
// The liveness bounds (see [`SshLiveness`] for why each exists): russh's
// defaults are no keepalive and no inactivity bound, which is how a silent
// link came to hang a push forever.
let lv = auth.liveness;
// r5: on the post-quantum client config (KEX_ORDER, nodelay) — the bounds added.
let config = Arc::new(russh::client::Config {
keepalive_interval: Some(lv.keepalive_interval),
keepalive_max: lv.keepalive_max,
inactivity_timeout: Some(lv.inactivity),
..client_config()
});
let (life_tx, life_rx) = watch::channel(None);
let dialled = tokio::time::timeout(
lv.connect,
russh::client::connect(config, (loc.host.as_str(), loc.port), Client::new(loc, auth, life_tx)),
)
.await;
let mut session = match dialled {
Err(_) => bail!(
"ssh {}:{}: no SSH answer within {} s (TCP connect, version exchange, key exchange): \
the link or the far end is dead",
loc.host,
loc.port,
lv.connect.as_secs()
),
Ok(r) => r.with_context(|| {
format!(
"ssh connect {}:{} (a rejected host key surfaces here)",
loc.host, loc.port
)
})?,
};
let authenticated_with = match authenticate(&mut session, &loc.user, auth).await {
Ok(a) => a,
// A session that DIED during authentication reads as "auth failed";
// say the death, which is the thing to act on.
Err(e) => match life_rx.borrow().clone() {
Some(why) => bail!("ssh {}:{}: {why} (during authentication): {e:#}", loc.host, loc.port),
None => return Err(e),
},
};
Ok((session, authenticated_with, life_rx))
}
/// Wait for the session's end and return why (see [`Client::life`]).
async fn session_ended(life: &mut watch::Receiver<Option<String>>) -> String {
match life.wait_for(Option::is_some).await {
Ok(why) => why.clone().unwrap_or_default(),
Err(_) => "the ssh session ended".to_string(),
}
}
/// The next message on `channel`, or an error naming the far end once
/// `stall` passes with nothing — ls-remote's per-message no-progress bound.
async fn next_msg(
channel: &mut russh::Channel<russh::client::Msg>,
stall: Duration,
far: &str,
what: &str,
) -> Result<Option<russh::ChannelMsg>> {
match tokio::time::timeout(stall, channel.wait()).await {
Ok(m) => Ok(m),
Err(_) => bail!(
"ssh {far}: no progress for {} s waiting for {what}: the far end is stuck",
stall.as_secs()
),
}
}
/// Why the channel ended before its section did, for ls-remote's refusal.
fn cut_reason(life: &watch::Receiver<Option<String>>) -> String {
life.borrow()
.clone()
.unwrap_or_else(|| "the channel ended without its flush".to_string())
}
/// `ls-remote` over SSH: connect to `loc`, run `git-upload-pack`, and return the
/// advertised refs (including `HEAD`).
///
/// Negotiates **protocol v2** (`GIT_PROTOCOL=version=2`, as `git` itself does) and
/// falls back to **v0** transparently:
///
/// * v2 — the server first sends a `version 2` capability advertisement (NO refs); we
/// then send a `command=ls-refs` request (with `symrefs`+`peel`) and read the ref
/// list from its response. This is the path codeberg/Forgejo (and GitHub) take over
/// SSH, and the reason the old v0-only reader saw "no refs" → `advertised no HEAD` for
/// `git@codeberg.org:…` remotes (esp. cross-org like `mikronova/tunnr`).
/// * v0 — a legacy server ignores the env and advertises refs (incl. `HEAD`) up front;
/// we parse them directly and send a lone flush so upload-pack exits.
pub async fn ls_remote(loc: &SshLocation, key_path: &Path) -> Result<Vec<(String, String)>> {
ls_remote_auth(loc, &SshAuth::key(key_path)).await
}
/// [`ls_remote`] with the authentication left to the caller — a key file, an
/// `ssh-agent` identity, or both ([`SshAuth`]).
pub async fn ls_remote_auth(loc: &SshLocation, auth: &SshAuth) -> Result<Vec<(String, String)>> {
let far = format!("{}:{}", loc.host, loc.port);
let stall = auth.liveness.stall;
let (session, _authenticated_with, life) = connect_session(loc, auth).await?;
let mut channel = tokio::time::timeout(stall, session.channel_open_session())
.await
.map_err(|_| anyhow!("ssh {far}: the session channel did not open within {} s", stall.as_secs()))?
.context("ssh open session channel")?;
// Ask for protocol v2 (best-effort; a v0-only server ignores it).
channel.set_env(false, "GIT_PROTOCOL", "version=2").await.ok();
// Quoting per git's scp-style transport.
let cmd = upload_pack_command(&loc.path)?;
channel.exec(true, cmd.as_bytes()).await.context("ssh exec git-upload-pack")?;
// Read the FIRST protocol section (capability advertisement for v2, or the whole
// ref advertisement for v0) — up to and including its flush-pkt.
//
// A link that dies mid-section is an ERROR. Until 2026-10-06 it was taken for
// the end of the list, and whatever refs had arrived came back as the whole
// advertisement (`ls_remote_cut_mid_advertisement_is_an_error_not_a_short_list`).
let mut buf: Vec<u8> = Vec::new();
let mut eof = false;
let first_end = loop {
if let Some(end) = section_end(&buf) {
break Some(end);
}
match next_msg(&mut channel, stall, &far, "the ref advertisement").await? {
Some(russh::ChannelMsg::Data { ref data }) => buf.extend_from_slice(data),
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::ExitStatus { .. }) => {
eof = true;
break section_end(&buf);
}
None => bail!(
"ssh {far}: the connection ended in the middle of the ref advertisement ({} bytes in): {}",
buf.len(),
cut_reason(&life)
),
_ => {}
}
};
if let Some(msg) = err_pkt(&buf) {
bail!("ssh {far}: the server refused the ref listing: {msg}");
}
if first_end.is_none() && !buf.is_empty() {
bail!(
"ssh {far}: the ref advertisement stopped before its flush ({} bytes in): a truncated list is not a list",
buf.len()
);
}
// v2 iff the first section announces `version 2`.
let is_v2 = first_end
.map(|end| {
std::str::from_utf8(&buf[..end])
.map(|s| s.contains("version 2"))
.unwrap_or(false)
})
.unwrap_or(false);
if !is_v2 {
// v0: refs are already in `buf`. Send a flush ("no wants") so upload-pack exits,
// then drain (already have the advertisement — parse it). The drain is
// courtesy, not content: a far end that never says goodbye costs one
// keepalive interval, not a hang.
let _ = channel.data(&b"0000"[..]).await;
if !eof {
let courtesy = auth.liveness.keepalive_interval;
while let Ok(Some(msg)) = tokio::time::timeout(courtesy, channel.wait()).await {
match msg {
russh::ChannelMsg::Data { ref data } => buf.extend_from_slice(data),
russh::ChannelMsg::Eof | russh::ChannelMsg::ExitStatus { .. } => break,
_ => {}
}
}
}
return parse_ref_advertisement(&buf);
}
// The server advertises its repo's object format in the v2 caps; ECHO it (default
// sha1). Sending the wrong format (e.g. sha1 to a sha256 repo like codeberg's
// `mikronova/tunnr`) makes upload-pack return an empty ls-refs response.
let object_format = first_end
.and_then(|end| std::str::from_utf8(&buf[..end]).ok())
.and_then(|s| {
s.lines()
.find_map(|l| l.split_once("object-format=").map(|(_, v)| v.trim().to_string()))
})
.filter(|v| v == "sha1" || v == "sha256")
.unwrap_or_else(|| "sha1".to_string());
// v2: send `command=ls-refs` and read the ref list from its response.
let mut req: Vec<u8> = Vec::new();
req.extend_from_slice(&pkt_line("command=ls-refs\n"));
req.extend_from_slice(&pkt_line(&format!("object-format={object_format}\n")));
req.extend_from_slice(&b"0001"[..]); // delim-pkt: end of command, begin args
req.extend_from_slice(&pkt_line("symrefs\n"));
req.extend_from_slice(&pkt_line("peel\n"));
req.extend_from_slice(&b"0000"[..]); // flush: end of request
channel.data(&req[..]).await.context("ssh send v2 ls-refs")?;
// Read the ls-refs response section (starts AFTER the capability section already
// consumed) up to its flush. v2 ALWAYS ends it with a flush, so anything that
// ends without one is a cut list, and refused.
let resp_start = first_end.unwrap_or(0);
let mut resp: Vec<u8> = buf[resp_start..].to_vec();
loop {
if section_end(&resp).is_some() {
break;
}
match next_msg(&mut channel, stall, &far, "the ls-refs answer").await? {
Some(russh::ChannelMsg::Data { ref data }) => resp.extend_from_slice(data),
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::ExitStatus { .. }) => {
if let Some(msg) = err_pkt(&resp) {
bail!("ssh {far}: the server refused ls-refs: {msg}");
}
bail!(
"ssh {far}: the ls-refs answer ended before its flush ({} bytes in): a truncated list is not a list",
resp.len()
)
}
None => bail!(
"ssh {far}: the connection ended in the middle of the ls-refs answer ({} bytes in): {}",
resp.len(),
cut_reason(&life)
),
_ => {}
}
}
// Tell upload-pack we're done (no fetch command follows) so it exits cleanly.
let _ = channel.data(&b"0000"[..]).await;
parse_ref_advertisement(&resp)
}
/// Blocking wrapper for the sync fetch/poll path: spins a small current-thread
/// runtime and runs [`ls_remote`].
pub fn ls_remote_blocking(url: &str, key_path: &Path) -> Result<Vec<(String, String)>> {
ls_remote_blocking_auth(url, &SshAuth::key(key_path))
}
/// [`ls_remote_blocking`] with the authentication left to the caller.
pub fn ls_remote_blocking_auth(url: &str, auth: &SshAuth) -> Result<Vec<(String, String)>> {
let loc = parse_ssh_url(url)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for ssh ls-remote")?;
rt.block_on(ls_remote_auth(&loc, auth))
}
// ── the remote command ──────────────────────────────────────────────────────
/// The environment `git` itself sets before `exec`ing a remote git command, and
/// what [`connect_exec`] sends: protocol **v2**.
///
/// Only `git-upload-pack` acts on it. Measured against stock `git 2.53.0` on
/// 2026-08-02: `git receive-pack`'s advertisement is **byte-identical** with and
/// without `GIT_PROTOCOL=version=2` (push has no v2), so sending it on a push
/// channel is a no-op rather than a risk. A caller that wants an empty
/// environment anyway passes `&[]` to [`connect_exec_with`].
pub const GIT_PROTOCOL_V2: &[(&str, &str)] = &[("GIT_PROTOCOL", "version=2")];
/// The exact command string git sends to run `service` on `path`.
///
/// git quotes the path with single quotes, **always** — even when it needs no
/// quoting — and the server side (`sshd` + `git-shell`, or any git SSH server)
/// unquotes it. A path containing a single quote cannot be expressed this way and
/// is refused here rather than silently mangled into a *different* repository.
fn service_command(service: &str, path: &str) -> Result<String> {
if path.contains('\'') {
bail!(
"repository path {path:?} contains a single quote, which git's scp-style \
command quoting cannot express"
);
}
Ok(format!("{service} '{path}'"))
}
/// `git-upload-pack '<path>'` — the fetch/clone side. See [`service_command`].
pub fn upload_pack_command(path: &str) -> Result<String> {
service_command("git-upload-pack", path)
}
/// `git-receive-pack '<path>'` — the push side. See [`service_command`].
pub fn receive_pack_command(path: &str) -> Result<String> {
service_command("git-receive-pack", path)
}
/// An authenticated SSH channel running **one** remote git command, exposed as
/// blocking `Read`/`Write`.
///
/// russh is async; gix's blocking git transport wants `std::io::{Read, Write}`.
/// We own a tokio runtime that drives the russh connection and bridge the
/// channel's two halves with [`SshReader`] and [`SshWriter`]. The runtime +
/// session handle are retained so the connection stays alive for the duration of
/// the transfer; the reader/writer are handed (by `&mut`) to a `gix`
/// `git::Connection`, or to any other code that speaks the smart protocol.
///
/// **A dead link ends every read and write on it with an error** naming the far
/// end and the cause — a hang-up at once, a silent link within
/// [`SshLiveness::silent_link_bound`], a far end that moves nothing within
/// [`SshLiveness::stall`]. Until 2026-10-06 this bridge was `tokio_util`'s
/// `SyncIoBridge` over russh's `ChannelStream`, which can do neither: russh's
/// writer parks on the SSH window and nothing wakes it when the session dies.
///
/// IMPORTANT: the blocking `reader`/`writer` calls must run on a thread that is
/// **not** one of this runtime's workers (else `Handle::block_on` panics) — i.e.
/// drive gix's fetch on the calling thread, as [`crate::gitio`] does.
pub struct SshChannel {
/// Drives the russh connection; retained for liveness, dropped last.
_rt: tokio::runtime::Runtime,
/// Connection handle; retained so the channel stays open during the transfer.
/// `Option` so [`Drop`] can drop it WITHIN the runtime context.
session: Option<russh::client::Handle<Client>>,
/// Blocking reader over the channel's stdout (the pack stream). `Option` for the
/// in-context [`Drop`] teardown; use [`SshChannel::io`] to access.
reader_opt: Option<SshReader>,
/// Blocking writer over the channel's stdin (want/have negotiation, the pack).
writer_opt: Option<SshWriter>,
/// Which key file or agent identity authenticated. Diagnostics only.
pub authenticated_with: String,
}
/// The name this channel had when `git-upload-pack` was the only command it could
/// run. Kept so existing callers compile unchanged.
pub type UploadPack = SshChannel;
impl SshChannel {
/// The blocking `(reader, writer)` halves as a DISJOINT split borrow — the
/// remote command's stdout and stdin — to hand to gix's transport. Valid for
/// the lifetime of the transfer.
pub fn io(&mut self) -> (&mut SshReader, &mut SshWriter) {
(
self.reader_opt.as_mut().expect("reader present until drop"),
self.writer_opt.as_mut().expect("writer present until drop"),
)
}
}
impl Drop for SshChannel {
/// Tear the channel down FROM WITHIN the driving runtime's context — on EVERY path
/// (success, `?`-early-return, panic). Anything russh spawns from a `Drop` must
/// land on a reactor, and this thread is not one (dropping russh's channel halves
/// here once panicked with "there is no reactor running" — found on a first clone
/// of codeberg's SHA-256 `mikronova/tunnr`). The channel's `Close` is queued
/// best-effort, as russh's own close-on-drop did; `_rt` then drops, which stops
/// the session task, closes the socket and joins the runtime's threads — so a
/// transfer that ended in an error leaves no thread and no socket behind
/// (`tests/ssh_liveness_leaks.rs`).
fn drop(&mut self) {
let _guard = self._rt.enter();
drop(self.reader_opt.take());
if let Some(w) = self.writer_opt.take() {
let half = w.half;
// A32-14, stays a task: the write half's close on drop, on the transfer's own runtime, so a dropped transfer leaves no socket.
self._rt.spawn(async move {
let _ = half.close().await;
});
}
drop(self.session.take());
}
}
/// What a reader and a writer share about the link under them.
struct Link {
rt: tokio::runtime::Handle,
/// `host:port`, as every error names it.
far: String,
liveness: SshLiveness,
/// Why the session ended, once it has (see [`Client::life`]).
life: watch::Receiver<Option<String>>,
}
impl Link {
/// The session is gone: an error naming the far end, what was cut, and why.
/// Waits (briefly) for the reason: the session task signals it as it exits,
/// which can be a moment after the channel itself went quiet.
fn ended(&self, during: &str) -> io::Error {
let mut life = self.life.clone();
let why = self
.rt
.block_on(async move {
tokio::time::timeout(Duration::from_secs(1), session_ended(&mut life)).await
})
.unwrap_or_else(|_| "the channel was dropped without EOF".to_string());
self.ended_because(during, &why)
}
fn ended_because(&self, during: &str, why: &str) -> io::Error {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("ssh {}: the connection ended {during}: {why}", self.far),
)
}
fn no_progress(&self, what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::TimedOut,
format!(
"ssh {}: no progress for {} s ({what}): the far end answers keepalives but moves no data",
self.far,
self.liveness.stall.as_secs()
),
)
}
}
/// An error that stays: once a link is dead every further call says so again.
#[derive(Clone)]
struct Sticky(io::ErrorKind, String);
impl Sticky {
fn of(e: &io::Error) -> Self {
Sticky(e.kind(), e.to_string())
}
fn err(&self) -> io::Error {
io::Error::new(self.0, self.1.clone())
}
}
/// The remote command's **stdout** as blocking [`io::Read`].
///
/// End of stream is the channel's `EOF` or `Close`. A channel that VANISHES
/// without either — the session died under it — is an error naming the far end
/// and why, never a quiet end of stream: that quiet end is how a cut pack used
/// to read as "early eof" with no cause. stderr (extended data) is not the data
/// band and is skipped, as russh's own reader did.
pub struct SshReader {
half: russh::ChannelReadHalf,
/// A `Data` message not yet fully handed out, and how much of it was.
partial: Option<(russh::ChannelMsg, usize)>,
/// The channel said EOF or Close: every further read is end of stream.
done: bool,
failed: Option<Sticky>,
link: Arc<Link>,
}
impl io::Read for SshReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
loop {
if let Some((msg, off)) = self.partial.take() {
if let russh::ChannelMsg::Data { ref data } = msg {
let n = buf.len().min(data.len() - off);
buf[..n].copy_from_slice(&data[off..off + n]);
if off + n < data.len() {
self.partial = Some((msg, off + n));
}
return Ok(n);
}
}
if let Some(f) = &self.failed {
return Err(f.err());
}
if self.done {
return Ok(0);
}
let stall = self.link.liveness.stall;
// The timer is made INSIDE the runtime: a tokio timer built on this
// (non-runtime) thread panics with "no reactor running".
let half = &mut self.half;
let next = self.link.rt.block_on(async move { tokio::time::timeout(stall, half.wait()).await });
let e = match next {
Ok(Some(msg @ russh::ChannelMsg::Data { .. })) => {
self.partial = Some((msg, 0));
continue;
}
Ok(Some(russh::ChannelMsg::Eof)) | Ok(Some(russh::ChannelMsg::Close)) => {
self.done = true;
continue;
}
// stderr, exit status, window and request notes: not the data band.
Ok(Some(_)) => continue,
Ok(None) => self.link.ended("before the remote command finished"),
Err(_) => self.link.no_progress("waiting for data from the far end"),
};
self.failed = Some(Sticky::of(&e));
return Err(e);
}
}
}
/// The remote command's **stdin** as blocking [`io::Write`].
///
/// Every write is raced against the session's end and against
/// [`SshLiveness::stall`]: russh's writer parks on the SSH window and nothing
/// wakes it when the session dies, so without the race a far end that hung up
/// (or a link that went silent) while the window was shut left the writer — the
/// push — asleep forever.
pub struct SshWriter {
/// Kept for the channel's `Close` at teardown.
half: russh::ChannelWriteHalf<russh::client::Msg>,
tx: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
failed: Option<Sticky>,
link: Arc<Link>,
}
impl io::Write for SshWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
if let Some(f) = &self.failed {
return Err(f.err());
}
let link = self.link.clone();
let mut life = link.life.clone();
let tx = &mut self.tx;
let res = link.rt.block_on(async {
tokio::select! {
biased;
r = tx.write(buf) => r.map_err(|_| None),
why = session_ended(&mut life) => Err(Some(link.ended_because("mid-transfer", &why))),
_ = tokio::time::sleep(link.liveness.stall) => {
Err(Some(link.no_progress("the far end took no data")))
}
}
});
match res {
Ok(n) => Ok(n),
Err(e) => {
// russh's own refusal ("channel closed") names no cause: the
// session is gone, so say what ended it.
let e = e.unwrap_or_else(|| link.ended("mid-transfer"));
self.failed = Some(Sticky::of(&e));
Err(e)
}
}
}
/// Each write is already queued on the session; there is nothing to flush.
fn flush(&mut self) -> io::Result<()> {
match &self.failed {
Some(f) => Err(f.err()),
None => Ok(()),
}
}
}
/// Open an authenticated `git-upload-pack` channel to `loc` and return it as
/// blocking I/O for gix's transport. See [`SshChannel`].
pub fn connect_upload_pack(loc: &SshLocation, key_path: &Path) -> Result<UploadPack> {
connect_exec(loc, key_path, &upload_pack_command(&loc.path)?)
}
/// [`connect_upload_pack`] with the authentication left to the caller — so a
/// fetch can go through `ssh-agent` exactly as a push can.
pub fn connect_upload_pack_auth(loc: &SshLocation, auth: &SshAuth) -> Result<SshChannel> {
connect_exec_auth(loc, auth, &upload_pack_command(&loc.path)?)
}
/// Open an authenticated `git-receive-pack` channel to `loc` — the **push** side.
pub fn connect_receive_pack_auth(loc: &SshLocation, auth: &SshAuth) -> Result<SshChannel> {
connect_exec_auth(loc, auth, &receive_pack_command(&loc.path)?)
}
/// Open an authenticated channel to `loc` running **`command`** on the remote,
/// returning it as blocking I/O.
///
/// This is the whole of [`connect_upload_pack`] with the command lifted out, and
/// it exists so that push (`git-receive-pack`) and anything else needing a remote
/// git command reuse the connect / authenticate / `exec` / bridge / in-runtime
/// teardown sequence instead of copying it (LAW 5).
///
/// [`GIT_PROTOCOL_V2`] is sent before the `exec`, as `git` itself does. That is
/// what makes `upload-pack` speak protocol v2 (gix's v1 implementation can
/// deadlock); `receive-pack` ignores it — measured, see [`GIT_PROTOCOL_V2`]. Use
/// [`connect_exec_with`] to choose a different environment.
///
/// The blocking reads and writes must happen on a thread that is **not** one of
/// this connection's runtime workers — i.e. on the calling thread.
pub fn connect_exec(loc: &SshLocation, key_path: &Path, command: &str) -> Result<SshChannel> {
connect_exec_with(loc, &SshAuth::key(key_path), command, GIT_PROTOCOL_V2)
}
/// [`connect_exec`] with the authentication left to the caller ([`SshAuth`]).
pub fn connect_exec_auth(loc: &SshLocation, auth: &SshAuth, command: &str) -> Result<SshChannel> {
connect_exec_with(loc, auth, command, GIT_PROTOCOL_V2)
}
/// The general form: authentication **and** the environment sent before `exec`
/// are both the caller's. Everything else routes through here.
pub fn connect_exec_with(
loc: &SshLocation,
auth: &SshAuth,
command: &str,
env: &[(&str, &str)],
) -> Result<SshChannel> {
// TWO workers, not one per core: the session task is the only thing that
// runs here (the caller's thread drives the reads and writes), and a
// per-core pool put 32 idle threads behind every push on a 32-core box.
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("nornir-ssh")
.enable_all()
.build()
.context("build runtime for ssh exec")?;
let far = format!("{}:{}", loc.host, loc.port);
let stall = auth.liveness.stall;
let (session, authenticated_with, life, read_half, write_half) = rt.block_on(async {
let (session, authenticated_with, life) = connect_session(loc, auth).await?;
let opened = tokio::time::timeout(stall, async {
let channel = session
.channel_open_session()
.await
.context("ssh open session channel")?;
// Best-effort: a server that refuses an env request still gets the command.
for (k, v) in env {
channel.set_env(false, *k, *v).await.ok();
}
channel
.exec(true, command.as_bytes())
.await
.with_context(|| format!("ssh exec {command}"))?;
Ok::<_, anyhow::Error>(channel)
})
.await
.map_err(|_| {
anyhow!("ssh {far}: `{command}` did not start within {} s: the far end is stuck", stall.as_secs())
})??;
// Split, NOT `into_stream()`: the halves are what [`SshReader`] and
// [`SshWriter`] need to see the session's end (see their docs).
let (r, w) = opened.split();
Ok::<_, anyhow::Error>((session, authenticated_with, life, r, w))
})?;
let link = Arc::new(Link { rt: rt.handle().clone(), far, liveness: auth.liveness, life });
let reader = SshReader { half: read_half, partial: None, done: false, failed: None, link: link.clone() };
let tx: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>> = Box::pin(write_half.make_writer());
let writer = SshWriter { half: write_half, tx, failed: None, link };
Ok(SshChannel {
_rt: rt,
session: Some(session),
reader_opt: Some(reader),
writer_opt: Some(writer),
authenticated_with,
})
}
/// **The key exchanges this client offers, post-quantum FIRST** (2026-10-06,
/// the PQ transport lane): `mlkem768x25519-sha256` (ML-KEM-768 + X25519,
/// draft-ietf-sshm-mlkem-hybrid-kex; RustCrypto `ml-kem` inside russh), then
/// the classical exchanges, so a server without it still answers. In SSH the
/// CLIENT's order decides, so this list is what makes `gunnar`'s pushes,
/// fetches and replication post-quantum against any server that offers the
/// hybrid (gunnar's door, OpenSSH ≥ 9.9). Named here rather than inherited
/// from russh's `Preferred::DEFAULT`, so a russh bump that reorders or drops
/// it is a red test, not a silent downgrade. `sntrup761x25519-sha512`: russh
/// does not implement it.
pub static KEX_ORDER: &[russh::kex::Name] = &[
russh::kex::MLKEM768X25519_SHA256,
russh::kex::CURVE25519,
russh::kex::CURVE25519_PRE_RFC_8731,
russh::kex::DH_GEX_SHA256,
russh::kex::DH_G18_SHA512,
russh::kex::DH_G17_SHA512,
russh::kex::DH_G16_SHA512,
russh::kex::DH_G15_SHA512,
russh::kex::DH_G14_SHA256,
russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
russh::kex::EXTENSION_SUPPORT_AS_SERVER,
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
];
/// **The one client configuration every dial here uses**: [`KEX_ORDER`],
/// and `nodelay` (see [`connect_session`] for the 39 ms it costs otherwise).
pub fn client_config() -> russh::client::Config {
russh::client::Config {
nodelay: true,
preferred: russh::Preferred { kex: std::borrow::Cow::Borrowed(KEX_ORDER), ..russh::Preferred::DEFAULT },
..russh::client::Config::default()
}
}
/// **The host key `host:port` presents, and nothing else**: the key exchange
/// runs, the key is kept, and the connection is abandoned before any
/// authentication (the handler answers "not trusted"). A COMPARISON source for
/// [`pin_verdict`] — never a thing to record from, which is what a pin exists
/// to stop.
pub fn presented_host_key(host: &str, port: u16, timeout: std::time::Duration) -> Result<russh::keys::ssh_key::PublicKey> {
struct Capture(Arc<std::sync::Mutex<Option<russh::keys::ssh_key::PublicKey>>>);
impl russh::client::Handler for Capture {
type Error = russh::Error;
async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> Result<bool, Self::Error> {
// Only a plain host key is ever pinned (see `check_server_key` above).
if let russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } = key {
*self.0.lock().unwrap_or_else(|p| p.into_inner()) = Some(key.clone());
}
Ok(false)
}
}
let seen = Arc::new(std::sync::Mutex::new(None));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for the host-key read")?;
let config = Arc::new(client_config());
let outcome = rt.block_on(async {
tokio::time::timeout(timeout, russh::client::connect(config, (host, port), Capture(seen.clone()))).await
});
let key = seen.lock().unwrap_or_else(|p| p.into_inner()).take();
match (key, outcome) {
(Some(k), _) => Ok(k),
(None, Err(_)) => bail!("{host}:{port}: no host key within {} s", timeout.as_secs()),
(None, Ok(Err(e))) => bail!("{host}:{port}: no host key presented: {e}"),
(None, Ok(Ok(_))) => bail!("{host}:{port}: the session opened without a host-key check (impossible)"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **Every dial offers the post-quantum hybrid first**, from this
/// crate's own list, with the classical fallbacks and strict kex kept.
#[test]
fn the_client_offers_mlkem768x25519_first_from_its_own_list() {
let c = client_config();
assert!(std::ptr::eq(c.preferred.kex.as_ref().as_ptr(), KEX_ORDER.as_ptr()), "the client's list is KEX_ORDER, not russh's default");
let names: Vec<&str> = c.preferred.kex.iter().map(|n| n.as_ref()).collect();
assert_eq!(names[..3], ["mlkem768x25519-sha256", "curve25519-sha256", "curve25519-sha256@libssh.org"], "{names:?}");
assert!(names.contains(&"kex-strict-c-v00@openssh.com"), "strict kex stays: {names:?}");
assert!(c.nodelay, "Nagle stays off");
}
/// The shipped bounds are a DECISION (see [`SshLiveness`]), so changing one
/// is a change to this test, said out loud — not a drive-by.
#[test]
fn the_shipped_bounds_are_a_decision() {
let lv = SshLiveness::default();
assert_eq!(lv.connect, Duration::from_secs(30));
assert_eq!(lv.keepalive_interval, Duration::from_secs(15), "OpenSSH ServerAliveInterval");
assert_eq!(lv.keepalive_max, 3, "OpenSSH ServerAliveCountMax");
assert_eq!(lv.inactivity, Duration::from_secs(60));
assert_eq!(lv.stall, Duration::from_secs(600));
// A silent link is called dead within a minute, a live-but-stuck far
// end within ten — and the second is never the shorter.
assert_eq!(lv.silent_link_bound(), Duration::from_secs(60));
assert!(lv.stall > lv.silent_link_bound());
// Every constructor carries them; none quietly opts out.
for auth in [SshAuth::default(), SshAuth::key("/k"), SshAuth::agent(), SshAuth::anonymous()] {
assert_eq!(auth.liveness, lv);
}
}
#[test]
fn parse_scp_like() {
let l = parse_ssh_url("git@github.com:octocat/Hello-World.git").unwrap();
assert_eq!(l.user, "git");
assert_eq!(l.host, "github.com");
assert_eq!(l.port, 22);
assert_eq!(l.path, "octocat/Hello-World.git");
}
#[test]
fn parse_ssh_scheme_with_port_and_user() {
let l = parse_ssh_url("ssh://deploy@git.example.com:2222/srv/repos/foo.git").unwrap();
assert_eq!(l.user, "deploy");
assert_eq!(l.host, "git.example.com");
assert_eq!(l.port, 2222);
assert_eq!(l.path, "/srv/repos/foo.git");
}
#[test]
fn reject_https() {
assert!(parse_ssh_url("https://github.com/octocat/Hello-World").is_err());
}
#[test]
fn parse_advertisement() {
// Two refs; first carries a NUL-separated capability list; then flush.
let sha1 = "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d";
let sha2 = "1111111111111111111111111111111111111111";
let line1_body = format!("{sha1} HEAD\0multi_ack symref=HEAD:refs/heads/main\n");
let line2_body = format!("{sha2} refs/heads/main\n");
let mut buf = Vec::new();
for body in [line1_body, line2_body] {
let len = body.len() + 4;
buf.extend_from_slice(format!("{len:04x}").as_bytes());
buf.extend_from_slice(body.as_bytes());
}
buf.extend_from_slice(b"0000"); // flush
let refs = parse_ref_advertisement(&buf).unwrap();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0], (sha1.to_string(), "HEAD".to_string()));
assert_eq!(refs[1], (sha2.to_string(), "refs/heads/main".to_string()));
}
/// Protocol-v2 `ls-refs` response: HEAD carries a `symref-target:` attribute and a
/// branch may carry `peeled:` — the refname is the FIRST token after the sha. This is
/// the codeberg/Forgejo-over-SSH shape the old v0-only reader mis-parsed (it kept the
/// whole `HEAD symref-target:…` as the name, so the `== "HEAD"` lookup missed and
/// `ssh_sync` bailed with "advertised no HEAD").
#[test]
fn parse_v2_ls_refs_with_symref_and_peel() {
let sha1 = "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d";
let sha2 = "1111111111111111111111111111111111111111";
let l1 = format!("{sha1} HEAD symref-target:refs/heads/main\n");
let l2 = format!("{sha2} refs/tags/v1.0 peeled:{sha1}\n");
let mut buf = Vec::new();
for body in [l1, l2] {
buf.extend_from_slice(format!("{:04x}", body.len() + 4).as_bytes());
buf.extend_from_slice(body.as_bytes());
}
buf.extend_from_slice(b"0000");
let refs = parse_ref_advertisement(&buf).unwrap();
assert_eq!(refs[0], (sha1.to_string(), "HEAD".to_string()), "HEAD refname isolated");
assert_eq!(refs[1], (sha2.to_string(), "refs/tags/v1.0".to_string()), "peeled attr dropped");
}
#[test]
#[ignore = "network; run explicitly to verify SSH HEAD resolution on tunnr's home"]
fn ls_remote_resolves_head_on_tunnrs_home() {
let key = std::path::PathBuf::from(std::env::var("HOME").unwrap()).join(".dwarves/ssh/id_ed25519");
let refs =
ls_remote_blocking("ssh://git@git.gunnar.rs:2222/mikronova/tunnr.git", &key).expect("ls-remote");
eprintln!("tunnr refs: {} total", refs.len());
let head = refs.iter().find(|(_, n)| n == "HEAD");
assert!(head.is_some(), "HEAD must be advertised (v2 ls-refs): {refs:?}");
assert!(refs.iter().any(|(_, n)| n.starts_with("refs/heads/")), "branches present");
// tunnr is a SHA-256 repo — its oids are 64 hex chars. The old 40-only parser
// dropped them all → "advertised no HEAD"; the v2 ls-refs reader + 64-hex parse
// resolve it. (The full SHA-256 PACK fetch additionally needs gix-0.84 sha256
// support, which currently errors "early eof"; SHA-1 repos clone fully via the
// bridge — see the release doctors' green path.)
assert_eq!(head.unwrap().0.len(), 64, "sha256 oid parsed, not dropped");
}
/// The command string is quoted the way git quotes it — single quotes always,
/// even when the path needs none — because the far end unquotes exactly that.
#[test]
fn service_commands_quote_like_git() {
assert_eq!(
upload_pack_command("ns/repo.git").unwrap(),
"git-upload-pack 'ns/repo.git'"
);
assert_eq!(
receive_pack_command("ns/repo.git").unwrap(),
"git-receive-pack 'ns/repo.git'"
);
// A leading slash (the `ssh://` form) is preserved verbatim.
assert_eq!(
receive_pack_command("/srv/git/ns/repo.git").unwrap(),
"git-receive-pack '/srv/git/ns/repo.git'"
);
}
/// RED-WHEN-BROKEN: a path git's quoting cannot express is refused rather than
/// smuggled through as a command naming a DIFFERENT repository.
#[test]
fn an_unquotable_path_is_refused() {
assert!(upload_pack_command("ns/re'po.git").is_err());
assert!(receive_pack_command("ns/re'po.git").is_err());
}
/// The key-path constructor must keep the pre-agent behaviour exactly: one
/// key, no agent, no host-key checking. A default that quietly turned the
/// agent on would change what `connect_upload_pack(loc, key)` authenticates
/// with on every machine that happens to run one.
#[test]
fn anonymous_auth_is_none_only_and_never_a_key_or_the_agent() {
let a = SshAuth::anonymous();
assert!(a.anonymous && !a.use_agent && a.keys.is_empty());
assert!(!SshAuth::default().anonymous && !SshAuth::key("/tmp/k").anonymous, "no other door is anonymous by accident");
}
#[test]
fn key_auth_is_the_old_behaviour_and_the_default_is_not() {
let a = SshAuth::key("/tmp/k");
assert_eq!(a.keys, vec![std::path::PathBuf::from("/tmp/k")]);
assert!(!a.use_agent, "the key path must not consult the agent");
assert_eq!(a.host_key, HostKeyPolicy::AcceptAny);
let d = SshAuth::default();
assert!(d.keys.is_empty());
assert!(d.use_agent, "the agent constructor must consult the agent");
assert_eq!(d.host_key, HostKeyPolicy::TofuKnownHosts);
}
fn ed(b: u8) -> russh::keys::ssh_key::PublicKey {
use russh::keys::ssh_key::public::{Ed25519PublicKey, KeyData};
russh::keys::ssh_key::PublicKey::new(KeyData::Ed25519(Ed25519PublicKey([b; 32])), "")
}
fn pin(b: u8) -> HostKeyPin {
HostKeyPin::new("git.gunnar.rs", 2222, fingerprint_of(&ed(b))).unwrap()
}
#[test]
fn pin_match_is_accepted_as_pinned() {
let v = pin_verdict(&[pin(1)], "git.gunnar.rs", 2222, &ed(1), false).unwrap();
assert_eq!(v, PinVerdict::Pinned { fingerprint: fingerprint_of(&ed(1)) });
// trust_first_use changes nothing when a pin exists
assert!(matches!(pin_verdict(&[pin(1)], "git.gunnar.rs", 2222, &ed(1), true).unwrap(), PinVerdict::Pinned { .. }));
}
#[test]
fn pin_mismatch_is_refused_by_name_with_both_fingerprints() {
let e = pin_verdict(&[pin(1)], "git.gunnar.rs", 2222, &ed(2), false).unwrap_err();
assert!(e.starts_with("REFUSED [host-key-pin-mismatch] git.gunnar.rs:2222"), "{e}");
assert!(e.contains(&fingerprint_of(&ed(2))) && e.contains(&fingerprint_of(&ed(1))), "both fingerprints: {e}");
// --trust-first-use NEVER overrides a mismatch
assert!(pin_verdict(&[pin(1)], "git.gunnar.rs", 2222, &ed(2), true).unwrap_err().contains("[host-key-pin-mismatch]"));
}
#[test]
fn absent_pin_is_refused_unless_trust_first_use() {
// a pin for ANOTHER port is not a pin for this one
let e = pin_verdict(&[HostKeyPin::new("git.gunnar.rs", 22, fingerprint_of(&ed(1))).unwrap()], "git.gunnar.rs", 2222, &ed(1), false).unwrap_err();
assert!(e.starts_with("REFUSED [host-key-pin-absent] git.gunnar.rs:2222"), "{e}");
assert!(pin_verdict(&[], "h", 22, &ed(1), false).unwrap_err().contains("[host-key-pin-absent]"));
assert_eq!(pin_verdict(&[], "h", 22, &ed(3), true).unwrap(), PinVerdict::TrustedFirstUse { fingerprint: fingerprint_of(&ed(3)) });
}
#[test]
fn disagreeing_pins_and_bad_shapes_are_refused() {
let e = pin_verdict(&[pin(1), pin(2)], "git.gunnar.rs", 2222, &ed(1), true).unwrap_err();
assert!(e.contains("[host-key-pin-ambiguous]"), "{e}");
assert!(pin_verdict(&[pin(1), pin(1)], "git.gunnar.rs", 2222, &ed(1), false).is_ok(), "the same pin twice is one pin");
assert!(HostKeyPin::new("h", 22, "SHA256:short").unwrap_err().to_string().contains("[host-key-pin-shape]"));
assert!(HostKeyPin::new("h", 22, "MD5:aa").is_err());
assert!(HostKeyPin::new("", 22, fingerprint_of(&ed(1))).is_err());
}
#[test]
fn a_non_ed25519_key_is_refused_even_without_a_pin() {
let ecdsa = russh::keys::ssh_key::PublicKey::from_openssh(TEST_ECDSA).unwrap();
let e = pin_verdict(&[], "h", 22, &ecdsa, true).unwrap_err();
assert!(e.contains("[host-key-not-ed25519]"), "{e}");
}
const TEST_ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJzilj2WBdEDYMXVURseqvDlmw7JQT+pnrflmMY0DIwtz7xoViQ+RcV+m9KqABdgAj1Z1ZnQYRMk6FqkZcuAfGA=";
#[test]
fn the_pinned_policy_goes_through_the_client_check() {
let loc = parse_ssh_url("ssh://git@git.gunnar.rs:2222/x").unwrap();
let a = SshAuth::key("/tmp/k").with_host_key_pins([pin(1)]);
assert_eq!(a.host_key, HostKeyPolicy::Pinned);
let c = Client::new(&loc, &a, watch::channel(None).0);
assert_eq!(c.check(&ed(1)), Ok(true));
assert!(c.check(&ed(2)).unwrap_err().contains("[host-key-pin-mismatch]"));
let c = Client::new(&parse_ssh_url("ssh://git@other:2222/x").unwrap(), &a, watch::channel(None).0);
assert!(c.check(&ed(1)).unwrap_err().contains("[host-key-pin-absent]"));
let c = Client::new(&parse_ssh_url("ssh://git@other:2222/x").unwrap(), &a.clone().with_trust_first_use(true), watch::channel(None).0);
assert_eq!(c.check(&ed(1)), Ok(true));
}
#[test]
#[ignore = "network; run explicitly to read a real server's host key"]
fn presented_host_key_reads_codeberg_without_authenticating() {
let k = presented_host_key("codeberg.org", 22, std::time::Duration::from_secs(20)).expect("a key");
eprintln!("codeberg.org:22 presents {} {}", k.algorithm(), fingerprint_of(&k));
assert!(fingerprint_of(&k).starts_with("SHA256:"));
}
#[test]
fn an_err_pkt_is_the_servers_answer_not_a_truncated_list() {
// Captured 2026-10-23 from git.gunnar.rs for mikronova/tunnr with a key that
// has no read grant: 73 bytes, one ERR pkt-line, then EOF and no flush. It was
// reported as "the ref advertisement stopped before its flush (73 bytes in)",
// which sent the hunt to SHA-256 parsing instead of to the key.
let buf = b"0049ERR repository not found, or you do not have access: mikronova/tunnr\n";
assert_eq!(buf.len(), 73);
assert_eq!(section_end(buf), None, "no flush: the old path called it truncated");
assert_eq!(
err_pkt(buf).as_deref(),
Some("repository not found, or you do not have access: mikronova/tunnr")
);
// An ordinary advertisement carries no ERR line.
let mut ok = pkt_line("version 2\n");
ok.extend_from_slice(b"0000");
assert_eq!(err_pkt(&ok), None);
// A cut pkt-line is not an ERR line either.
assert_eq!(err_pkt(b"0049ERR repo"), None);
}
#[test]
fn section_end_frames_a_flushed_section() {
// Two pkt-lines then a flush; a delim-pkt (0001) is skipped, not treated as flush.
let mut buf = Vec::new();
buf.extend_from_slice(&pkt_line("version 2\n"));
buf.extend_from_slice(b"0001");
buf.extend_from_slice(&pkt_line("ls-refs=unborn\n"));
let mid = buf.len();
buf.extend_from_slice(b"0000");
buf.extend_from_slice(b"trailing");
assert_eq!(section_end(&buf), Some(mid + 4), "ends just past the flush");
// Incomplete: no flush yet.
assert_eq!(section_end(&pkt_line("version 2\n")), None);
}
}
/// Known-answer test for the SSH key surface (2026-10-02, W3 crypto bump).
///
/// `testdata/ssh-kat/` holds an OpenSSH Ed25519 private key, its public line and
/// fingerprint as written/printed by the PRE-bump stack (russh 0.61 / ssh-key
/// 0.6). Operators' existing keys on disk and every host key pinned in a
/// known_hosts were made by that stack, so a later one must parse the old key,
/// derive the same public key and print the same `SHA256:` fingerprint.
/// Regenerate ONLY on purpose: `NORNIR_SSH_KAT_REGEN=1 cargo test -p nornir-git ssh_kat`.
#[cfg(test)]
mod ssh_kat {
use super::*;
use russh::keys::ssh_key::{LineEnding, PrivateKey};
fn dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/ssh-kat")
}
#[test]
fn ssh_kat_old_key_parses_to_the_same_identity() {
if std::env::var_os("NORNIR_SSH_KAT_REGEN").is_some() {
let kp = russh::keys::ssh_key::private::Ed25519Keypair::from_seed(&[3u8; 32]);
let key = PrivateKey::from(kp);
std::fs::create_dir_all(dir()).unwrap();
std::fs::write(dir().join("id_ed25519"), key.to_openssh(LineEnding::LF).unwrap().as_bytes()).unwrap();
std::fs::write(dir().join("id_ed25519.pub"), key.public_key().to_openssh().unwrap()).unwrap();
std::fs::write(dir().join("fingerprint"), fingerprint_of(key.public_key())).unwrap();
}
let read = |n: &str| std::fs::read_to_string(dir().join(n)).unwrap_or_else(|e| panic!("fixture {n}: {e}"));
let key = PrivateKey::from_openssh(read("id_ed25519")).unwrap();
assert_eq!(key.public_key().to_openssh().unwrap(), read("id_ed25519.pub"), "public key line changed");
assert_eq!(fingerprint_of(key.public_key()), read("fingerprint"), "SHA256 fingerprint changed");
assert_eq!(key.to_openssh(LineEnding::LF).unwrap().as_str(), read("id_ed25519"), "OpenSSH private key encoding changed");
}
}