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

nordisk/gunnar

crates/gunnar-client/src/ssh.rs

//! The SSH transport, and **not one line of it is implemented here**.
//!
//! `nornir-git` owns git-over-SSH for this constellation (reused, not written: one
//! implementation): the connect, the publickey/`ssh-agent` authentication, the
//! `known_hosts` policy, the russh→blocking-I/O bridge, and the `Drop` that
//! tears the channel down *inside* the driving runtime. [`crate::fetch`] and
//! [`crate::send_pack`] both run over it.
//!
//! # What used to be here, and why it is gone
//!
//! This module carried a second copy of that sequence (connect, authenticate,
//! open a channel, `exec`, bridge, tear down) for one reason only: `nornir-git
//! 0.1.5` exposed `connect_upload_pack`, whose command string was baked in, so
//! push (`git-receive-pack`) could not reach it. About 250 lines of copy, kept in
//! step by hand, including the `Drop` discipline whose absence is a real bug:
//!
//! > russh closes a channel by `tokio::spawn`-ing its `Close` message from
//! > `Drop`. Dropping the channel halves on a non-runtime thread makes that
//! > spawn panic with *"there is no reactor running"*.
//!
//! `nornir-git 0.1.6` exposes [`nornir_git::ssh::connect_exec`], the same
//! channel with the command left to the caller, plus [`nornir_git::ssh::SshAuth`]
//! for agent-or-key authentication and host-key policy. The copy is deleted. What
//! remains here is the part that is genuinely gunnar's: **which keys to offer**,
//! and the [`Transport`] impl that plugs the channel into this crate's wire code.
//!
//! Host keys and keys on disk are used **directly**: `~/.ssh/known_hosts`,
//! `~/.ssh/id_ed25519`, `$SSH_AUTH_SOCK`. That is not a leak of an abstraction;
//! the client runs on a normal OS, always, and a git client that ignored those
//! files would be the wrong program — that is the server's constraint, not the client's.
//!
//! # …but only when nobody has said which identity to use
//!
//! [`resolve_identity`] is the one place that decides, and its rule is one
//! sentence: **a named identity is the only identity.** `-i` (or
//! `$GUNNAR_SSH_KEY`) means that key, with the agent off and the ladder unused,
//! and a refusal is a refusal. Read its docs before changing anything here; the
//! substitution that rule forbids shipped, reached production, and made every
//! authorization test written in the obvious shape a false green.

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

use nornir_git::ssh::SshAuth;

use crate::error::{Error, Result};
use crate::transport::Transport;

/// What to do about the server's host key. It is **`nornir-git`'s**, re-exported
/// so callers have one import surface and there is exactly one such enum.
pub use nornir_git::ssh::HostKeyPolicy;

/// An authenticated SSH channel running one remote git command, as blocking I/O.
/// **`nornir-git`'s**, re-exported.
///
/// The runtime is owned inside it and dropped last; that teardown order is
/// load-bearing rather than tidy (see the module docs).
pub use nornir_git::ssh::SshChannel;
/// Where [`connect_exec`] dials — re-exported so a caller that execs a verb of
/// its own (`gunnar control tunnel --ssh`) does not have to link `nornir-git`.
pub use nornir_git::ssh::SshLocation;

/// How to authenticate and how much to trust the far end.
///
/// The shape a *user* of this crate sets: one explicit key, a passphrase, the
/// agent on or off, a host-key policy. [`SshOptions::auth`] expands it into the
/// [`SshAuth`] `nornir-git` acts on, which is where the key **ladder** below is
/// applied.
#[derive(Debug, Clone, Default)]
pub struct SshOptions {
    /// An explicit private key. Tried first when set.
    pub key_path: Option<PathBuf>,
    /// Passphrase for an encrypted private key.
    pub passphrase: Option<String>,
    /// Try `$SSH_AUTH_SOCK` before falling back to keys on disk. On by default
    /// via [`SshOptions::new`].
    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>,
}

impl SshOptions {
    /// Defaults a developer would expect: agent first, then keys on disk,
    /// trust-on-first-use host keys.
    pub fn new() -> Self {
        SshOptions {
            key_path: None,
            passphrase: None,
            use_agent: true,
            host_key: HostKeyPolicy::default(),
            known_hosts: None,
        }
    }

    /// Use exactly this private key.
    pub fn with_key(mut self, path: impl Into<PathBuf>) -> Self {
        self.key_path = Some(path.into());
        self
    }

    /// Set the host-key policy.
    pub fn with_host_key_policy(mut self, policy: HostKeyPolicy) -> Self {
        self.host_key = policy;
        self
    }

    /// Expand these options into what `nornir-git` authenticates with, through
    /// [`resolve_identity`] — the **one** place in gunnar that decides which
    /// identity a connection may use (one implementation).
    ///
    /// Fallible on purpose. The whole defect this replaces was an infallible
    /// expansion: a named key that could not be used was dropped from the list
    /// and the connection went ahead as somebody else. There is now no way to
    /// obtain an [`SshAuth`] that has quietly done that.
    pub fn auth(&self) -> Result<SshAuth> {
        let identity = resolve_identity(self.key_path.as_deref())?;
        let mut auth = SshAuth::default()
            // A named identity means *this identity*. The agent holds other
            // keys, and offering them here is the same silent substitution the
            // key ladder used to make.
            .with_agent(self.use_agent && !identity.is_exclusive())
            .with_host_key_policy(self.host_key)
            .with_keys(identity.into_keys());
        auth.passphrase = self.passphrase.clone();
        auth.known_hosts = self.known_hosts.clone();
        Ok(auth)
    }
}

/// Which private keys a connection may authenticate with.
///
/// Two cases, and the difference between them is the whole of this module's
/// security contract:
///
/// * [`Identity::Named`] — the caller said **which** identity to use. Exactly
///   that key, nothing else, and a refusal is a refusal.
/// * [`Identity::Ladder`] — the caller named none, so gunnar may look: the
///   agent, then the conventional key files.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Identity {
    /// Exactly this key. No `ssh-agent`, no `~/.ssh`.
    Named(PathBuf),
    /// Nobody named an identity: try these, in order, agent first.
    Ladder(Vec<PathBuf>),
}

impl Identity {
    /// Is this an identity the caller **named**, so nothing else may be tried?
    pub fn is_exclusive(&self) -> bool {
        matches!(self, Identity::Named(_))
    }

    /// The key paths to offer, in order.
    pub fn into_keys(self) -> Vec<PathBuf> {
        match self {
            Identity::Named(path) => vec![path],
            Identity::Ladder(paths) => paths,
        }
    }
}

/// **The one function that decides which ssh identity gunnar authenticates
/// with**, for every code path in this crate that opens a session (one implementation).
///
/// # The defect it exists to make impossible
///
/// Until 2026-08-06 this was a `candidate_key_paths(explicit)` that put the
/// caller's key at the *head of a ladder* — `$GUNNAR_SSH_KEY`, `nornir-git`'s
/// deploy key, `~/.ssh/{id_ed25519,id_ecdsa,id_rsa}` — with `ssh-agent` still
/// switched on in front of all of it. Two things followed, and both were
/// silent:
///
/// 1. `gunnar ls-remote -i <a key the server does not know>` **succeeded**,
///    because the client went on to offer `~/.ssh/id_ed25519` and the server
///    accepted *that*. MEASURED from a laptop against the live server on
///    2026-08-06: the server's own log for the one call reads
///    `ssh.auth.refused reason="unknown-key"` four times for the named key and
///    then `ssh.auth.accepted` for a key the caller had explicitly asked not to
///    use. Exit 0, refs printed.
/// 2. A path that did not exist was filtered out by an `exists()` check, so a
///    typo in `-i` degraded to *"no identity given"* rather than to an error.
///
/// The reason it mattered more than a mis-parsed flag: an authorization test
/// written as `ls-remote -i <disallowed key>` was **green on the machine of
/// anyone whose own key was enrolled**, in exactly the test meant to prove the
/// door was shut. gunnar's whole ACL rests on the key being the identity.
///
/// # Why `clone` was right and `ls-remote` was wrong
///
/// They did not share this code. Plain `gunnar clone` hands the key path
/// straight to `nornir_git::gitio::clone_or_fetch`, whose `SshAuth::key(path)`
/// means *exactly this key, no agent*. Everything else in gunnar —
/// `ls-remote`, `fetch`, `push`, `mirror sync`, and `clone --depth/--filter`,
/// which takes the `SshOptions` branch — went through the ladder. One
/// behaviour written twice, which is the shape one implementation forbids;
/// this function is the single writer both now route through, and it agrees
/// with `nornir-git`'s meaning of a key path rather than inventing a second
/// one.
///
/// # Named beats found, and `$GUNNAR_SSH_KEY` counts as named
///
/// `$GUNNAR_SSH_KEY` is somebody stating an identity as deliberately as `-i`
/// does, so it is exclusive too — falling back from it to `~/.ssh` is the same
/// substitution wearing a different hat. `-i` wins over it when both are set.
pub fn resolve_identity(explicit: Option<&Path>) -> Result<Identity> {
    resolve_identity_in(explicit, &IdentityEnv::current())
}

/// Everything about the machine that [`resolve_identity`] reads.
///
/// Split out for one reason: the rule above is a security boundary, and a
/// boundary needs a guard that is watched go red. A test that proved it by
/// setting `$HOME` and `$GUNNAR_SSH_KEY` would be mutating process-global state
/// under cargo's test threads — racy, then flaky, then `#[ignore]`d, then blind.
/// [`IdentityEnv::current()`] is the only place the environment is touched, and
/// it holds no logic to get wrong.
#[derive(Debug, Clone, Default)]
pub struct IdentityEnv {
    /// `$GUNNAR_SSH_KEY`, if set and non-empty.
    pub env_key: Option<PathBuf>,
    /// `nornir-git`'s deploy key, if it resolved one.
    pub deploy_key: Option<PathBuf>,
    /// `$HOME`, whose `.ssh` holds the conventional names.
    pub home: Option<PathBuf>,
}

impl IdentityEnv {
    /// Read the real machine.
    pub fn current() -> Self {
        IdentityEnv {
            env_key: std::env::var_os("GUNNAR_SSH_KEY")
                .filter(|v| !v.is_empty())
                .map(PathBuf::from),
            deploy_key: nornir_git::deploykey::ssh_key_path(),
            home: std::env::var_os("HOME")
                .filter(|v| !v.is_empty())
                .map(PathBuf::from),
        }
    }
}

/// [`resolve_identity`] against a stated machine rather than the real one.
pub fn resolve_identity_in(explicit: Option<&Path>, env: &IdentityEnv) -> Result<Identity> {
    let named = explicit
        .map(Path::to_path_buf)
        .or_else(|| env.env_key.clone());

    if let Some(path) = named {
        // Checked here, and reported as a refusal rather than dropped. The old
        // `exists()` filter is what turned a bad `-i` into a fallback.
        if !path.exists() {
            return Err(Error::Identity(format!(
                "{} does not exist, and gunnar will not authenticate as somebody else \
                 instead. Pass a private key that is there, or pass no -i at all to use \
                 ssh-agent and the ~/.ssh key ladder.",
                path.display()
            )));
        }
        return Ok(Identity::Named(path));
    }

    // Nobody named an identity, so gunnar may look. Returning the whole list
    // rather than the first hit is deliberate: a key that exists is not a key
    // the server accepts, and a client that gave up after the first readable
    // file would fail on every machine with a stale `id_rsa`.
    let mut out: Vec<PathBuf> = Vec::new();
    let mut push = |p: PathBuf| {
        if p.exists() && !out.contains(&p) {
            out.push(p);
        }
    };
    if let Some(p) = env.deploy_key.clone() {
        push(p);
    }
    if let Some(home) = &env.home {
        for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
            push(home.join(".ssh").join(name));
        }
    }
    Ok(Identity::Ladder(out))
}

// ── the channel ─────────────────────────────────────────────────────────────

/// `nornir-git`'s channel, plugged into this crate's wire seam.
///
/// The trait is local and the type is foreign, which is exactly the direction the
/// orphan rule allows, so the seam costs one impl rather than a wrapper type.
impl Transport for SshChannel {
    fn io(&mut self) -> (&mut dyn std::io::Read, &mut dyn std::io::Write) {
        let (reader, writer) = SshChannel::io(self);
        (reader, writer)
    }
}

/// Connect to `loc`, authenticate, and `exec` one command, returning the channel
/// as blocking I/O.
///
/// **Delegates to [`nornir_git::ssh::connect_exec_with`]** with an *empty*
/// environment: `git-receive-pack` has no protocol v2, so nothing is negotiated
/// before the command. (`nornir-git`'s own `connect_exec` sends
/// `GIT_PROTOCOL=version=2`, which is what a fetch wants.)
///
/// The blocking reads and writes must happen on a thread that is **not** one of
/// the connection runtime's workers, i.e. on the caller's thread, which is where
/// every user of this crate drives the protocol.
pub fn connect_exec(loc: &SshLocation, command: &str, opts: &SshOptions) -> Result<SshChannel> {
    nornir_git::ssh::connect_exec_with(loc, &opts.auth()?, command, &[]).map_err(Error::Ssh)
}

/// The exact command string git sends for a push.
///
/// **Delegates to [`nornir_git::ssh::receive_pack_command`]**, which quotes the
/// path with single quotes the way git does and refuses a path containing a
/// single quote rather than mangling it into a different repository.
pub fn receive_pack_command(path: &str) -> Result<String> {
    nornir_git::ssh::receive_pack_command(path).map_err(|e| Error::Invalid(e.to_string()))
}

/// Open an authenticated `git-receive-pack` channel, the push half.
pub fn connect_receive_pack(loc: &SshLocation, opts: &SshOptions) -> Result<SshChannel> {
    connect_exec(loc, &receive_pack_command(&loc.path)?, opts)
}

/// Open an authenticated `git-upload-pack` channel for the fetch half.
///
/// The symmetry that did not exist before: this takes the same [`SshOptions`] as
/// the push, so a fetch can authenticate through `ssh-agent` too.
pub fn connect_upload_pack(loc: &SshLocation, opts: &SshOptions) -> Result<SshChannel> {
    let command = nornir_git::ssh::upload_pack_command(&loc.path)
        .map_err(|e| Error::Invalid(e.to_string()))?;
    nornir_git::ssh::connect_exec_auth(loc, &opts.auth()?, &command).map_err(Error::Ssh)
}

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

    #[test]
    fn receive_pack_command_quotes_like_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'"
        );
    }

    #[test]
    fn receive_pack_command_refuses_an_unquotable_path() {
        let err = receive_pack_command("ns/re'po.git").unwrap_err();
        assert!(
            matches!(err, Error::Invalid(_)),
            "a quote in the path must be refused, not smuggled: {err}"
        );
    }

    /// A machine that has **every** fallback a substitution could reach for:
    /// a deploy key and all three `~/.ssh` names, each a real file.
    ///
    /// Every negative assertion below is about one of these NOT being offered,
    /// so if this helper ever stopped creating them the tests would pass on
    /// nothing. That is the identity-value trap in this file, and
    /// [`the_fallbacks_this_file_guards_against_are_real`] is the guard on the
    /// guard.
    fn machine_with_fallbacks(dir: &Path) -> (IdentityEnv, Vec<PathBuf>) {
        let home = dir.join("home");
        std::fs::create_dir_all(home.join(".ssh")).unwrap();
        let mut ladder = Vec::new();
        let deploy = dir.join("deploy-key");
        std::fs::write(&deploy, b"a tempting deploy key").unwrap();
        ladder.push(deploy.clone());
        for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
            let p = home.join(".ssh").join(name);
            std::fs::write(&p, b"a tempting fallback").unwrap();
            ladder.push(p);
        }
        (
            IdentityEnv {
                env_key: None,
                deploy_key: Some(deploy),
                home: Some(home),
            },
            ladder,
        )
    }

    /// ANTI-VACUITY, and it is not decorative: the three tests below all assert
    /// that certain keys are **absent** from a list. Against a machine with no
    /// fallbacks every one of them passes without the fix.
    #[test]
    fn the_fallbacks_this_file_guards_against_are_real() {
        let dir = tempfile::tempdir().unwrap();
        let (env, ladder) = machine_with_fallbacks(dir.path());
        assert_eq!(ladder.len(), 4, "the fixture must offer four rungs");
        assert_eq!(
            resolve_identity_in(None, &env).unwrap(),
            Identity::Ladder(ladder),
            "with no identity named, every rung must be reachable — otherwise the \
             'and nothing else' assertions elsewhere in this file are vacuous"
        );
    }

    /// RED-WHEN-BROKEN: **a named identity is the only identity.**
    ///
    /// This replaces a test that asserted the defect as if it were the rule
    /// (*"a path that does not exist is never offered"*, which is precisely how
    /// a typo'd `-i` became a fallback). Watched go red against the old
    /// `candidate_key_paths` on 2026-08-06: for a named key it returned the
    /// named key **plus all four rungs**, and `auth()` left `use_agent` on.
    #[test]
    fn a_named_identity_is_the_only_identity() {
        let dir = tempfile::tempdir().unwrap();
        let (env, ladder) = machine_with_fallbacks(dir.path());
        let key = dir.path().join("named");
        std::fs::write(&key, b"not really a key").unwrap();

        let named = resolve_identity_in(Some(&key), &env).unwrap();
        assert_eq!(named, Identity::Named(key.clone()));
        assert!(named.is_exclusive(), "a named identity forbids the agent");
        let offered = named.into_keys();
        assert_eq!(
            offered,
            vec![key.clone()],
            "the named key is offered and NOTHING else; these were also on the \
             machine and must not appear: {ladder:?}"
        );
    }

    /// RED-WHEN-BROKEN: a named key that is not there is a **refusal**, not a
    /// fallback.
    ///
    /// The old code filtered it out with `exists()` and carried on down the
    /// ladder, so `-i /typo` authenticated as `~/.ssh/id_ed25519`.
    #[test]
    fn a_named_identity_that_is_missing_is_refused() {
        let dir = tempfile::tempdir().unwrap();
        let (env, _) = machine_with_fallbacks(dir.path());
        let missing = dir.path().join("nope");

        let err = resolve_identity_in(Some(&missing), &env)
            .expect_err("a -i that does not exist must not silently become another key");
        assert!(
            matches!(err, Error::Identity(_)),
            "the refusal must be an identity refusal, not something a caller could \
             confuse with the server saying no: {err}"
        );
        assert!(
            err.to_string().contains("nope"),
            "the message must name the path that was asked for: {err}"
        );

        // …and `auth()`, the thing every connector actually calls, must refuse
        // too rather than hand out a substituted identity.
        let err = SshOptions::new()
            .with_key(&missing)
            .auth()
            .expect_err("auth() handed out an SshAuth for a key that is not there");
        assert!(matches!(err, Error::Identity(_)), "{err}");
    }

    /// `$GUNNAR_SSH_KEY` is somebody naming an identity, so it is exclusive
    /// too — and `-i` still wins over it.
    #[test]
    fn the_env_key_is_named_and_i_beats_it() {
        let dir = tempfile::tempdir().unwrap();
        let (mut env, _) = machine_with_fallbacks(dir.path());
        let env_key = dir.path().join("from-env");
        let flag_key = dir.path().join("from-flag");
        std::fs::write(&env_key, b"k").unwrap();
        std::fs::write(&flag_key, b"k").unwrap();
        env.env_key = Some(env_key.clone());

        assert_eq!(
            resolve_identity_in(None, &env).unwrap(),
            Identity::Named(env_key),
            "$GUNNAR_SSH_KEY names an identity as deliberately as -i does"
        );
        assert_eq!(
            resolve_identity_in(Some(&flag_key), &env).unwrap(),
            Identity::Named(flag_key),
            "-i must win over $GUNNAR_SSH_KEY"
        );
    }

    /// The environment reader is the only thing that touches the process
    /// environment, and it must actually read `$HOME` — a `current()` that
    /// returned `Default::default()` would make every ladder empty and every
    /// test above pass.
    #[test]
    fn the_environment_reader_reads_the_environment() {
        let env = IdentityEnv::current();
        assert_eq!(
            env.home.as_deref(),
            std::env::var_os("HOME")
                .filter(|v| !v.is_empty())
                .map(PathBuf::from)
                .as_deref(),
            "IdentityEnv::current() did not read $HOME"
        );
    }

    /// RED-WHEN-BROKEN: the options a caller sets must survive the trip into
    /// `nornir-git`'s [`SshAuth`], and the explicit key must lead the ladder.
    ///
    /// This is the seam the deletion created, and the failure it can have is
    /// silent: an `auth()` that dropped `use_agent`, or the host-key policy, or
    /// the explicit key, would still connect on a developer machine where the
    /// `~/.ssh` fallbacks happen to work.
    #[test]
    fn options_reach_nornir_gits_auth_intact() {
        let dir = tempfile::tempdir().unwrap();
        let key = dir.path().join("id_ed25519");
        std::fs::write(&key, b"not really a key").unwrap();

        let auth = SshOptions::new()
            .with_key(&key)
            .with_host_key_policy(HostKeyPolicy::StrictKnownHosts)
            .auth()
            .unwrap();
        assert_eq!(
            auth.keys,
            vec![key.clone()],
            "a named key is the whole list, not the head of a ladder: {:?}",
            auth.keys
        );
        assert!(
            !auth.use_agent,
            "a named key must switch the agent OFF: the agent holds other identities, \
             and offering them is the same substitution the ladder used to make"
        );
        assert_eq!(auth.host_key, HostKeyPolicy::StrictKnownHosts);

        // The agent stays on when nobody named an identity.
        assert!(
            SshOptions::new().auth().unwrap().use_agent,
            "`new()` with no -i asks for the agent"
        );

        // …and the off switches are carried too, not merely the on ones.
        let mut opts = SshOptions::new().with_key(&key);
        opts.use_agent = false;
        opts.known_hosts = Some(dir.path().join("known_hosts"));
        opts.passphrase = Some("hunter2".to_string());
        let auth = opts.auth().unwrap();
        assert!(!auth.use_agent, "agent-off must reach SshAuth");
        assert_eq!(auth.known_hosts, opts.known_hosts);
        assert_eq!(auth.passphrase.as_deref(), Some("hunter2"));
        assert_eq!(
            auth.host_key,
            HostKeyPolicy::TofuKnownHosts,
            "the default policy is trust-on-first-use, not accept-anything"
        );
    }
}