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