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

nordisk/edda

crates/nornir-git/src/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");
    }
}