crates/nornir-git/tests/ssh_exec.rs
//! `connect_exec` against a **real SSH server running the real `git` binary**.
//!
//! The point of this file is that nothing here is a stand-in. A test where
//! nornir's client agrees with a hand-written fake server proves nothing — both
//! halves can be wrong the same way. So:
//!
//! * the server is a genuine `russh` SSH server (key exchange, publickey auth,
//! channel, `env`, `exec`) bound on loopback;
//! * what it `exec`s is stock **`git upload-pack`** / **`git receive-pack`**,
//! which speak the smart protocol on stdin/stdout — exactly what an SSH channel
//! gives them;
//! * the client is `nornir_git::ssh::connect_exec` itself, unmodified.
//!
//! Hermetic: a temporary directory, a freshly minted ed25519 key, an ephemeral
//! port, `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` pointed at nothing. No network,
//! no `sshd`, no ambient key or agent.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use nornir_git::ssh::{
connect_exec, parse_ssh_url, receive_pack_command, upload_pack_command, SshAuth,
};
use russh::server::Server as _;
// ── the test SSH server ─────────────────────────────────────────────────────
/// Hermetic git environment for every child `git` this file spawns.
const GIT_ENV: &[(&str, &str)] = &[
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_SYSTEM", "/dev/null"),
("GIT_AUTHOR_NAME", "Fixture"),
("GIT_AUTHOR_EMAIL", "fixture@example.invalid"),
("GIT_AUTHOR_DATE", "1700000000 +0000"),
("GIT_COMMITTER_NAME", "Fixture"),
("GIT_COMMITTER_EMAIL", "fixture@example.invalid"),
("GIT_COMMITTER_DATE", "1700000000 +0000"),
];
/// What the server saw, so the test can assert on the far end rather than only on
/// the near end.
#[derive(Debug, Default)]
struct Seen {
/// Every `exec` command string, verbatim.
commands: Vec<String>,
/// Every `env` request, as `(name, value)`.
env: Vec<(String, String)>,
}
type SeenLog = Arc<std::sync::Mutex<Seen>>;
struct Handler {
seen: SeenLog,
/// The running git child's stdin, once `exec` has started one.
stdin: Option<tokio::process::ChildStdin>,
}
/// Undo git's scp-style quoting: `git-upload-pack 'path'` → `("upload-pack", "path")`.
fn split_command(cmd: &str) -> Option<(String, String)> {
let (verb, rest) = cmd.split_once(' ')?;
let service = verb.strip_prefix("git-")?;
if service != "upload-pack" && service != "receive-pack" {
return None;
}
let path = rest.trim().trim_matches('\'').to_string();
Some((service.to_string(), path))
}
impl russh::server::Handler for Handler {
type Error = russh::Error;
/// Any key is accepted. Authorisation is not what this file is testing, and a
/// server that checked would only be asserting its own fixture.
async fn auth_publickey(
&mut self,
_user: &str,
_key: russh::server::VerifiedPublicKey<'_>,
) -> Result<russh::server::Auth, Self::Error> {
Ok(russh::server::Auth::Accept)
}
// russh 0.63: a session is accepted through its `ChannelOpenHandle` (dropping it
// rejects); same accept-everything test server as before. 2026-10-02, W3 bump.
async fn channel_open_session(
&mut self,
_channel: russh::Channel<russh::server::Msg>,
reply: russh::server::ChannelOpenHandle,
_session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
async fn env_request(
&mut self,
channel: russh::ChannelId,
name: &str,
value: &str,
session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
self.seen
.lock()
.expect("seen log")
.env
.push((name.to_string(), value.to_string()));
session.channel_success(channel)?;
Ok(())
}
async fn exec_request(
&mut self,
channel: russh::ChannelId,
data: &[u8],
session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
let cmd = String::from_utf8_lossy(data).to_string();
let env: Vec<(String, String)> = self.seen.lock().expect("seen log").env.clone();
self.seen.lock().expect("seen log").commands.push(cmd.clone());
let Some((service, path)) = split_command(&cmd) else {
session.channel_failure(channel)?;
return Ok(());
};
let mut child = tokio::process::Command::new("git");
child
.arg(&service)
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
for (k, v) in GIT_ENV {
child.env(k, v);
}
// Forward exactly the environment the client asked for — that is how a
// real git server makes `GIT_PROTOCOL=version=2` reach upload-pack.
child.env_remove("GIT_PROTOCOL");
for (k, v) in &env {
child.env(k, v);
}
let mut child = child.spawn().expect("spawn git");
self.stdin = child.stdin.take();
let mut stdout = child.stdout.take().expect("child stdout");
session.channel_success(channel)?;
// Pump the child's stdout onto the channel, then report its exit.
let handle = session.handle();
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; 32 * 1024];
loop {
match stdout.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if handle.data(channel, buf[..n].to_vec()).await.is_err() {
break;
}
}
}
}
let status = child.wait().await.ok().and_then(|s| s.code()).unwrap_or(0);
let _ = handle.eof(channel).await;
let _ = handle.exit_status_request(channel, status as u32).await;
let _ = handle.close(channel).await;
});
Ok(())
}
async fn data(
&mut self,
_channel: russh::ChannelId,
data: &[u8],
_session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
use tokio::io::AsyncWriteExt;
if let Some(stdin) = self.stdin.as_mut() {
let _ = stdin.write_all(data).await;
let _ = stdin.flush().await;
}
Ok(())
}
async fn channel_eof(
&mut self,
_channel: russh::ChannelId,
_session: &mut russh::server::Session,
) -> Result<(), Self::Error> {
// Closing the child's stdin is how `receive-pack` learns the push is
// over; without it the process would wait forever.
drop(self.stdin.take());
Ok(())
}
}
struct Factory {
seen: SeenLog,
}
impl russh::server::Server for Factory {
type Handler = Handler;
fn new_client(&mut self, _peer: Option<std::net::SocketAddr>) -> Handler {
Handler {
seen: self.seen.clone(),
stdin: None,
}
}
}
/// A running test sshd: the port it bound, and what it has seen.
struct TestSshd {
port: u16,
seen: SeenLog,
_thread: std::thread::JoinHandle<()>,
}
impl TestSshd {
/// Bind on an ephemeral loopback port and serve until the process ends.
///
/// The server gets its **own thread and runtime** on purpose: the client
/// under test builds a runtime of its own and blocks the calling thread on
/// it, which cannot be done from inside another runtime's worker.
///
/// `host_key_dir` is where the server's own ed25519 host key is minted —
/// through this crate's `sshkey`, so the test needs no second key generator.
fn start(host_key_dir: &Path) -> TestSshd {
nornir_git::sshkey::generate_ed25519_openssh(host_key_dir, "testsshd@localhost")
.expect("mint host key");
let host_key_pem = std::fs::read_to_string(host_key_dir.join(nornir_git::sshkey::PRIV_FILE))
.expect("read host key");
let seen: SeenLog = Arc::new(std::sync::Mutex::new(Seen::default()));
let seen_for_server = seen.clone();
let (tx, rx) = std::sync::mpsc::channel::<u16>();
let thread = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("server runtime");
rt.block_on(async move {
let host_key =
russh::keys::PrivateKey::from_openssh(&host_key_pem).expect("parse host key");
let config = Arc::new(russh::server::Config {
keys: vec![host_key],
inactivity_timeout: Some(std::time::Duration::from_secs(120)),
auth_rejection_time: std::time::Duration::ZERO,
nodelay: true,
..Default::default()
});
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind loopback");
tx.send(listener.local_addr().expect("addr").port())
.expect("publish port");
let mut factory = Factory {
seen: seen_for_server,
};
let _ = factory.run_on_socket(config, &listener).await;
});
});
let port = rx.recv().expect("the server thread must publish its port");
TestSshd {
port,
seen,
_thread: thread,
}
}
/// An `ssh://` URL for `repo` on this server.
fn url(&self, repo: &Path) -> String {
format!("ssh://git@127.0.0.1:{}{}", self.port, repo.display())
}
fn commands(&self) -> Vec<String> {
self.seen.lock().expect("seen log").commands.clone()
}
fn env(&self) -> Vec<(String, String)> {
self.seen.lock().expect("seen log").env.clone()
}
}
// ── fixtures ────────────────────────────────────────────────────────────────
/// Mint a fresh ed25519 key and return its private path.
fn mint_key(dir: &Path) -> PathBuf {
nornir_git::sshkey::generate_ed25519_openssh(dir, "nornir-git-test@localhost")
.expect("mint ed25519 key");
dir.join(nornir_git::sshkey::PRIV_FILE)
}
/// A repository with one commit, built with nornir-git's own writers.
fn repo_with_a_commit(root: &Path) -> String {
nornir_git::gitio::init(root).expect("git init");
std::fs::write(root.join("README.md"), b"hello\n").expect("write README");
nornir_git::gitio::commit_all(root, "initial").expect("commit")
}
/// Read pkt-lines until the first flush (`0000`), i.e. one protocol section.
fn read_section(reader: &mut impl Read) -> Vec<u8> {
let mut out = Vec::new();
loop {
let mut len_hex = [0u8; 4];
if reader.read_exact(&mut len_hex).is_err() {
return out;
}
out.extend_from_slice(&len_hex);
let len = usize::from_str_radix(
std::str::from_utf8(&len_hex).expect("pkt-line length is ascii hex"),
16,
)
.expect("pkt-line length is hex");
if len == 0 {
return out; // flush-pkt
}
if len <= 4 {
continue; // delim-pkt / response-end
}
let mut body = vec![0u8; len - 4];
if reader.read_exact(&mut body).is_err() {
return out;
}
out.extend_from_slice(&body);
}
}
// ── the tests ───────────────────────────────────────────────────────────────
/// `connect_exec` running **`git-upload-pack`**, end to end over real SSH.
///
/// The assertion is on the far end's *answer*, not on the call returning `Ok`: a
/// channel that connected but ran nothing at all would also return `Ok`, and its
/// empty read is shaped exactly like a clean one. So this asserts the server saw
/// the quoted command, that `GIT_PROTOCOL=version=2` reached it, and that what
/// came back is upload-pack's protocol-v2 capability advertisement — which
/// receive-pack can never produce.
#[test]
fn connect_exec_runs_git_upload_pack() {
let td = tempfile::tempdir().expect("tempdir");
let key = mint_key(&td.path().join("keys"));
let repo = td.path().join("repo");
let head = repo_with_a_commit(&repo);
let sshd = TestSshd::start(&td.path().join("hostkey"));
let url = sshd.url(&repo);
let loc = parse_ssh_url(&url).expect("parse url");
let mut ch = connect_exec(&loc, &key, &upload_pack_command(&loc.path).expect("command"))
.expect("connect_exec upload-pack");
let (reader, writer) = ch.io();
let section = read_section(reader);
let text = String::from_utf8_lossy(§ion).to_string();
// Politely end the conversation so upload-pack exits rather than being killed.
let _ = writer.write_all(b"0000");
let _ = writer.flush();
drop(ch);
assert_eq!(
sshd.commands(),
vec![format!("git-upload-pack '{}'", repo.display())],
"the server must see git's own scp-style quoting"
);
assert!(
sshd.env()
.contains(&("GIT_PROTOCOL".to_string(), "version=2".to_string())),
"connect_exec must send GIT_PROTOCOL before exec: {:?}",
sshd.env()
);
assert!(
text.contains("version 2"),
"upload-pack with GIT_PROTOCOL=version=2 answers with a v2 capability \
advertisement; got {text:?}"
);
assert!(
text.contains("ls-refs") && text.contains("fetch"),
"the v2 capabilities must include the upload-pack commands; got {text:?}"
);
assert!(
!text.contains("report-status"),
"`report-status` is receive-pack's capability — seeing it here would mean \
the wrong service ran; got {text:?}"
);
assert_eq!(head.len(), 40, "sha1 fixture, so the oids below are 40 hex");
}
/// `connect_exec` running **`git-receive-pack`** — the push side, the one that
/// could not be reached before because the command was baked in.
///
/// Asserted on receive-pack's own advertisement: `report-status-v2` and
/// `delete-refs` are capabilities upload-pack never sends, and the repository's
/// real `refs/heads/main` oid has to be in it.
#[test]
fn connect_exec_runs_git_receive_pack() {
let td = tempfile::tempdir().expect("tempdir");
let key = mint_key(&td.path().join("keys"));
let repo = td.path().join("repo");
let head = repo_with_a_commit(&repo);
let sshd = TestSshd::start(&td.path().join("hostkey"));
let url = sshd.url(&repo);
let loc = parse_ssh_url(&url).expect("parse url");
let mut ch = connect_exec(&loc, &key, &receive_pack_command(&loc.path).expect("command"))
.expect("connect_exec receive-pack");
let (reader, writer) = ch.io();
let section = read_section(reader);
let text = String::from_utf8_lossy(§ion).to_string();
// A flush is how a client says "never mind" without a broken pipe.
let _ = writer.write_all(b"0000");
let _ = writer.flush();
drop(ch);
assert_eq!(
sshd.commands(),
vec![format!("git-receive-pack '{}'", repo.display())],
"the server must see the receive-pack command"
);
assert!(
text.contains("report-status-v2") && text.contains("delete-refs"),
"receive-pack's advertisement carries push-only capabilities; got {text:?}"
);
assert!(
!text.contains("ls-refs"),
"`ls-refs` is upload-pack's — seeing it here would mean the wrong service \
ran; got {text:?}"
);
assert!(
text.contains(&head) && text.contains("refs/heads/main"),
"receive-pack must advertise the repository's real HEAD {head}; got {text:?}"
);
}
/// The whole fetch path, over the same real SSH server: `clone_or_fetch` clones
/// the fixture and lands its commit and its file on disk.
///
/// This is the one that proves the refactor did not break the caller everything
/// else in nornir depends on — `connect_upload_pack` is now a one-line call to
/// `connect_exec`, and a mistake there would surface as a clone that connects and
/// produces nothing.
#[test]
fn clone_over_ssh_still_works_through_connect_exec() {
let td = tempfile::tempdir().expect("tempdir");
let key = mint_key(&td.path().join("keys"));
let origin = td.path().join("origin");
let head = repo_with_a_commit(&origin);
let sshd = TestSshd::start(&td.path().join("hostkey"));
let url = sshd.url(&origin);
let dest = td.path().join("clone");
let cloned = nornir_git::gitio::clone_or_fetch(&url, &dest, Some(&key)).expect("clone over ssh");
assert_eq!(cloned, head, "the clone lands the origin's HEAD commit");
assert_eq!(
std::fs::read_to_string(dest.join("README.md")).expect("worktree file"),
"hello\n",
"the worktree is materialised, not merely the objects"
);
assert_eq!(
nornir_git::gitio::head_sha(&dest).expect("head of the clone"),
head
);
assert!(
sshd.commands()
.iter()
.any(|c| c.starts_with("git-upload-pack ")),
"the clone must have run upload-pack: {:?}",
sshd.commands()
);
}
/// **`ssh-agent` authentication.** A dedicated agent is started for this test on
/// its own socket and loaded with a key minted here, so it never touches the
/// developer's `$SSH_AUTH_SOCK` and never depends on one existing.
///
/// It is `#[ignore]`d only because it shells out to `ssh-agent`/`ssh-add`, which
/// not every machine has. It **panics loudly** rather than skipping when they are
/// missing: a test that quietly passed with no agent to talk to would assert
/// nothing at all, which is the failure mode this whole file exists to avoid.
///
/// Run it with:
/// `cargo test -p nornir-git --test ssh_exec -- --ignored --nocapture`
#[test]
#[ignore = "spawns ssh-agent/ssh-add; run explicitly to verify agent auth"]
fn connect_exec_authenticates_through_ssh_agent() {
for tool in ["ssh-agent", "ssh-add"] {
assert!(
std::process::Command::new(tool)
.arg("-h")
.output()
.is_ok(),
"`{tool}` is not on PATH, so this test has no agent to talk to and \
would otherwise pass without testing anything"
);
}
let td = tempfile::tempdir().expect("tempdir");
let key = mint_key(&td.path().join("keys"));
let repo = td.path().join("repo");
repo_with_a_commit(&repo);
// A private agent on its own socket, so nothing here reads $SSH_AUTH_SOCK.
let sock = td.path().join("agent.sock");
let out = std::process::Command::new("ssh-agent")
.args(["-a", sock.to_str().expect("utf8 socket path")])
.output()
.expect("start ssh-agent");
assert!(
out.status.success(),
"ssh-agent refused to start: {}",
String::from_utf8_lossy(&out.stderr)
);
// `ssh-agent -a` prints `SSH_AGENT_PID=<pid>; export …` — keep the pid so the
// agent is killed even when an assertion below fails.
let pid: Option<u32> = String::from_utf8_lossy(&out.stdout)
.split("SSH_AGENT_PID=")
.nth(1)
.and_then(|s| s.split(';').next())
.and_then(|s| s.trim().parse().ok());
let _agent = AgentGuard(pid);
let added = std::process::Command::new("ssh-add")
.env("SSH_AUTH_SOCK", &sock)
.env_remove("DISPLAY")
.env_remove("SSH_ASKPASS")
.arg(&key)
.output()
.expect("run ssh-add");
assert!(
added.status.success(),
"ssh-add could not load the minted key: {}",
String::from_utf8_lossy(&added.stderr)
);
let sshd = TestSshd::start(&td.path().join("hostkey"));
let url = sshd.url(&repo);
let loc = parse_ssh_url(&url).expect("parse url");
// AGENT ONLY — no key file at all. If the agent path were dead this could not
// authenticate, because there is nothing else to authenticate with.
let auth = SshAuth::agent().with_host_key_policy(nornir_git::ssh::HostKeyPolicy::AcceptAny);
let prev = std::env::var_os("SSH_AUTH_SOCK");
// SAFETY-ish: this test is the only one touching SSH_AUTH_SOCK, and it is
// `#[ignore]`d, so it never races the rest of the suite.
unsafe { std::env::set_var("SSH_AUTH_SOCK", &sock) };
let connected = nornir_git::ssh::connect_upload_pack_auth(&loc, &auth);
match prev {
Some(v) => unsafe { std::env::set_var("SSH_AUTH_SOCK", v) },
None => unsafe { std::env::remove_var("SSH_AUTH_SOCK") },
}
let mut ch = connected.expect("agent auth must succeed");
assert!(
ch.authenticated_with.starts_with("ssh-agent:"),
"the connection must have been authenticated BY THE AGENT, not by a key \
file: {:?}",
ch.authenticated_with
);
let (reader, _writer) = ch.io();
let section = read_section(reader);
assert!(
String::from_utf8_lossy(§ion).contains("version 2"),
"and the channel really is running upload-pack"
);
}
/// Kills the private `ssh-agent` on the way out, on every path.
struct AgentGuard(Option<u32>);
impl Drop for AgentGuard {
fn drop(&mut self) {
if let Some(pid) = self.0 {
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.status();
}
}
}