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

nordisk/edda

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(&section).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(&section).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(&section).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();
        }
    }
}