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

nordisk/gunnar

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