crates/gunnar-ssh/tests/stock_clients.rs
//! Interoperability with the **real** OpenSSH client and the **real** `git`
//! binary (the verification rule).
//!
//! gunnar never runs a subprocess. Its tests do, because the only honest proof
//! that a git server works is that stock software talks to it. A test in which
//! gunnar's client agrees with gunnar's server proves nothing, since both halves
//! can be wrong in the same way.
//!
//! # What these tests prove, and what they do NOT
//!
//! They prove that OpenSSH 10 and git 2.53 complete a key exchange with gunnar,
//! authenticate with an ed25519 key against gunnar's key table, and reach
//! gunnar's exec dispatch with the command and repository gunnar expects.
//!
//! They do **not** prove that a clone works. `gunnar-wire` does not exist yet,
//! so the transport behind the seam is a test double that writes one line and
//! hangs up. `git ls-remote` therefore fails, and it fails *after* reaching the
//! seam, which is exactly the part this crate owns. Claiming a working
//! `git clone` from this would be a lie; the assertion is on what was actually
//! observed.
//!
//! Every test here **fails** if the binary it drives is missing. It
//! used to print `SKIPPED:` and return, which to `cargo test` is a pass with
//! zero assertions executed: a permanent green on any image without OpenSSH.
//! The shape is `crates/gunnar-cli/tests/ssh_end_to_end.rs`'s: a hard `assert!`
//! on tool presence.
#![cfg(unix)]
mod common;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
use gunnar_ssh::Access;
use russh::keys::ssh_key::{LineEnding, PrivateKey};
use common::{mint_key, start_with_key};
fn have(binary: &str) -> bool {
Command::new(binary)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|_| true)
.unwrap_or(false)
}
/// Write `key` where OpenSSH will accept it: a private key file at mode 0600.
fn write_key(dir: &std::path::Path, key: &PrivateKey) -> std::path::PathBuf {
let path = dir.join("id_ed25519");
let pem = key.to_openssh(LineEnding::LF).expect("encode");
let mut file = std::fs::File::create(&path).expect("create key file");
file.write_all(pem.as_bytes()).expect("write key");
drop(file);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod 600");
path
}
fn ssh_options(key_path: &std::path::Path) -> Vec<String> {
vec![
"-i".into(),
key_path.display().to_string(),
"-o".into(),
"IdentitiesOnly=yes".into(),
"-o".into(),
"StrictHostKeyChecking=no".into(),
"-o".into(),
"UserKnownHostsFile=/dev/null".into(),
"-o".into(),
"BatchMode=yes".into(),
]
}
#[tokio::test]
async fn openssh_authenticates_and_reaches_the_exec_dispatch() {
assert!(have("ssh"), "this test requires `ssh` on PATH");
let key = mint_key();
let dir = tempfile::tempdir().expect("scratch");
let key_path = write_key(dir.path(), &key);
let fixture = start_with_key(&key, Access::Read).await;
let port = fixture.server.local_addr().port();
let output = tokio::task::spawn_blocking(move || {
Command::new("ssh")
.args(ssh_options(&key_path))
.args(["-p", &port.to_string()])
// `git` here is the decorative username of the ACL model. It names no OS
// user; gunnar has none.
.arg("git@127.0.0.1")
.arg("git-upload-pack 'nordisk/osm-katana.git'")
.output()
.expect("run ssh")
})
.await
.expect("join");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"stock ssh failed: status {:?}\nstdout: {stdout}\nstderr: {stderr}",
output.status.code()
);
// Applied output: the bytes the wire seam wrote, carried back through
// OpenSSH's own channel handling.
assert_eq!(
stdout, "git-upload-pack nordisk/osm-katana principal-1\n",
"stderr was: {stderr}"
);
assert_eq!(
fixture.transport.served(),
vec!["git-upload-pack nordisk/osm-katana principal-1".to_string()]
);
fixture
.server
.shutdown("test over")
.await
.expect("shutdown");
}
#[tokio::test]
async fn openssh_is_refused_a_shell() {
assert!(have("ssh"), "this test requires `ssh` on PATH");
let key = mint_key();
let dir = tempfile::tempdir().expect("scratch");
let key_path = write_key(dir.path(), &key);
let fixture = start_with_key(&key, Access::Admin).await;
let port = fixture.server.local_addr().port();
let output = tokio::task::spawn_blocking(move || {
Command::new("ssh")
.args(ssh_options(&key_path))
.args(["-p", &port.to_string()])
.arg("git@127.0.0.1")
// No command at all, so OpenSSH asks for a shell.
.output()
.expect("run ssh")
})
.await
.expect("join");
assert!(
!output.status.success(),
"stock ssh got a shell: {:?}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
fixture.transport.served().is_empty(),
"a shell request reached the wire seam"
);
fixture
.server
.shutdown("test over")
.await
.expect("shutdown");
}
#[tokio::test]
async fn stock_git_ls_remote_reaches_the_wire_seam() {
assert!(
have("git") && have("ssh"),
"this test requires `git` and `ssh` on PATH"
);
let key = mint_key();
let dir = tempfile::tempdir().expect("scratch");
let key_path = write_key(dir.path(), &key);
let fixture = start_with_key(&key, Access::Read).await;
let port = fixture.server.local_addr().port();
let ssh_command = format!(
"ssh {}",
ssh_options(&key_path)
.iter()
.map(|arg| arg.as_str())
.collect::<Vec<_>>()
.join(" ")
);
let output = tokio::task::spawn_blocking(move || {
Command::new("git")
.env("GIT_SSH_COMMAND", ssh_command)
.env("GIT_TERMINAL_PROMPT", "0")
// **English, because the assertion below reads git's own words.**
//
// git is translated, and on a Swedish desktop this test failed with
// `fatal: protokollfel: felaktig radlängdstecken: git-` — the
// correct behaviour, reported in a language the assertion does not
// speak. A test whose verdict depends on the operator's locale is a
// test that says something about the operator.
//
// `LC_ALL` rather than `LANG`: it outranks every other locale
// variable, so one line settles it whatever else the environment
// has set.
.env("LC_ALL", "C")
.arg("ls-remote")
.arg(format!("ssh://git@127.0.0.1:{port}/nordisk/osm-katana.git"))
.output()
.expect("run git")
})
.await
.expect("join");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
// THE assertion. `git ls-remote` will not succeed, because the transport
// behind the seam is a double, not `gunnar-wire`. But it reached the seam
// having parsed, resolved and authorised exactly the repository it named,
// over a real SSH connection it established itself.
assert_eq!(
fixture.transport.served(),
vec!["git-upload-pack nordisk/osm-katana principal-1".to_string()],
"stock git did not reach the wire seam; its stderr was: {stderr}"
);
// And it got there by authenticating, not by being let through: no
// permission or authentication error in git's own output.
assert!(
!stderr.contains("Permission denied"),
"git reported an authentication failure: {stderr}"
);
// Pin *how* it failed, so that the day `gunnar-wire` lands this test is
// seen to change. Right now git fails reading the ref advertisement,
// because the double writes a plain line where a pkt-line belongs. Measured
// 2026-08-03 against git 2.53.0 / OpenSSH 10.2p1:
//
// fatal: protocol error: bad line length character: git-
//
// That message is proof of exactly how far it got: past the socket, past
// the key exchange, past authentication, past the exec dispatch, and into
// reading bytes the wire seam produced.
assert!(
stderr.contains("protocol error"),
"expected git to fail reading the ref advertisement, got: {stderr}"
);
assert_eq!(output.status.code(), Some(128));
fixture
.server
.shutdown("test over")
.await
.expect("shutdown");
}
/// Run the stock OpenSSH client at `port` with `extra` options and `-v`, as
/// the read principal, exec'ing `git-upload-pack`: `(exit ok, stdout, stderr)`.
async fn openssh_exec(
port: u16,
key_path: std::path::PathBuf,
extra: &'static [&'static str],
) -> (bool, String, String) {
let output = tokio::task::spawn_blocking(move || {
Command::new("ssh")
.args(ssh_options(&key_path))
.args(extra)
.args(["-v", "-p", &port.to_string()])
.arg("git@127.0.0.1")
.arg("git-upload-pack 'nordisk/osm-katana.git'")
.output()
.expect("run ssh")
})
.await
.expect("join");
(
output.status.success(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
/// The key exchange OpenSSH's `-v` says it settled on.
fn kex_line(stderr: &str) -> Option<&str> {
stderr
.lines()
.find_map(|l| l.split_once("kex: algorithm: ").map(|(_, a)| a.trim()))
}
/// **Post-quantum SSH with a stock client** (2026-10-06): OpenSSH ≥ 9.9
/// offers `mlkem768x25519-sha256` first, and gunnar's door negotiates it —
/// said by OpenSSH itself (`-v`: `kex: algorithm: …`), and the exec still
/// reaches the seam over the session it keyed.
#[tokio::test]
async fn openssh_negotiates_the_post_quantum_hybrid_mlkem768x25519_sha256() {
assert!(have("ssh"), "this test requires `ssh` on PATH");
let offered = Command::new("ssh")
.args(["-Q", "kex"])
.output()
.expect("ssh -Q kex");
assert!(
String::from_utf8_lossy(&offered.stdout)
.lines()
.any(|l| l.trim() == "mlkem768x25519-sha256"),
"this test requires OpenSSH >= 9.9 (`ssh -Q kex` lists mlkem768x25519-sha256)"
);
let key = mint_key();
let dir = tempfile::tempdir().expect("scratch");
let key_path = write_key(dir.path(), &key);
let fixture = start_with_key(&key, Access::Read).await;
let (ok, stdout, stderr) =
openssh_exec(fixture.server.local_addr().port(), key_path, &[]).await;
assert!(ok, "stock ssh failed:\nstdout: {stdout}\nstderr: {stderr}");
assert_eq!(
kex_line(&stderr),
Some("mlkem768x25519-sha256"),
"stderr: {stderr}"
);
assert_eq!(stdout, "git-upload-pack nordisk/osm-katana principal-1\n");
fixture
.server
.shutdown("test over")
.await
.expect("shutdown");
}
/// **…and an older client still connects** (prefer, never require): an
/// OpenSSH forced to the classical `curve25519-sha256` alone completes the
/// key exchange and reaches the seam.
#[tokio::test]
async fn a_classical_only_openssh_client_still_connects_on_curve25519() {
assert!(have("ssh"), "this test requires `ssh` on PATH");
let key = mint_key();
let dir = tempfile::tempdir().expect("scratch");
let key_path = write_key(dir.path(), &key);
let fixture = start_with_key(&key, Access::Read).await;
let (ok, stdout, stderr) = openssh_exec(
fixture.server.local_addr().port(),
key_path,
&["-o", "KexAlgorithms=curve25519-sha256"],
)
.await;
assert!(ok, "stock ssh failed:\nstdout: {stdout}\nstderr: {stderr}");
assert_eq!(
kex_line(&stderr),
Some("curve25519-sha256"),
"stderr: {stderr}"
);
assert_eq!(stdout, "git-upload-pack nordisk/osm-katana principal-1\n");
fixture
.server
.shutdown("test over")
.await
.expect("shutdown");
}
/// **No delayed-ACK stall in the handshake** (GUN-PERF, 2026-10-31).
///
/// OpenSSH turns `TCP_NODELAY` on only after authentication, so in the handshake two
/// of its writes in a row go under Nagle: the second waits until the first is
/// acknowledged. A server that has already sent its own message has nothing to
/// piggyback that ACK on, and the kernel delays it, about 40 ms. Measured before the
/// fix: 11 and 13 of 15 stock `ssh` exec round trips paid it (55 ms against 15 ms), and
/// every `git` over SSH did on the real server (`ls-remote` 50 to 70 ms over SSH, 15 to
/// 20 ms over HTTP). A quarter of the round trips is the line: a stall that is the
/// rule and not a scheduling accident.
///
/// **Read against the box's own speed, and by the stall's own size** (GUN-FIELD-TEST,
/// 2026-10-31). The first form counted round trips over a fixed 35 ms, and on a loaded box
/// that counts the box, not the ACK: MEASURED on oden at load 110 to 136 with the fix in, the
/// fastest round trip was 26 ms and 7 of 15 were over 35 ms (139, 119, 214, 121 ms among them).
/// A delayed ACK is not just slow, it is ONE size: the kernel's delayed-ACK timer, 40 ms, on top
/// of the round trip (with the fix taken out, oden at load 60 to 80: 55 to 62 ms against a
/// fastest 14 to 16 ms). A scheduling delay is any size. So a stall is a round trip 33 to 60 ms
/// over the fastest one of this run (a loaded box moves that floor with it), and the line is a
/// quarter of 30 round trips.
#[tokio::test]
async fn a_stock_openssh_exec_pays_no_delayed_ack_in_the_handshake() {
assert!(have("ssh"), "this test requires `ssh` on PATH");
let key = mint_key();
let dir = tempfile::tempdir().expect("scratch");
let key_path = write_key(dir.path(), &key);
let fixture = start_with_key(&key, Access::Read).await;
let port = fixture.server.local_addr().port();
const ROUND_TRIPS: usize = 30;
let mut walls = Vec::new();
for _ in 0..ROUND_TRIPS {
let started = std::time::Instant::now();
let (ok, stdout, stderr) = openssh_exec(port, key_path.clone(), &[]).await;
walls.push(started.elapsed());
assert!(ok, "stock ssh failed:\nstdout: {stdout}\nstderr: {stderr}");
}
let floor = *walls.iter().min().expect("round trips");
let ms = std::time::Duration::from_millis;
let stalled = walls
.iter()
.filter(|w| (floor + ms(33)..=floor + ms(60)).contains(*w))
.count();
assert!(
stalled * 4 < ROUND_TRIPS,
"{stalled} of {ROUND_TRIPS} stock ssh exec round trips took 33 to 60 ms over the fastest one ({floor:?}): {walls:?}: a delayed ACK (~40 ms) in the handshake"
);
fixture
.server
.shutdown("test over")
.await
.expect("shutdown");
}