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/handshake.rs

//! End to end: a real SSH connection, a real exec request, and bytes that
//! actually cross the seam (the ACL model, the two-listener split).
//!
//! What this proves that a unit test cannot: the parsed command and the
//! resolved repository reach [`gunnar_ssh::seam::GitTransport`] *and* the
//! channel's byte stream is live in both directions afterwards. The transport
//! double writes a line naming what it was asked for, and the client reads that
//! line back over SSH: applied output, not a flag (measured, not asserted).

mod common;

use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::Access;

use common::{
    authenticate, exec, mint_key, plane_with_key, start, start_with_key, start_with_transport,
    ExecOutcome, Plane,
};

#[tokio::test]
async fn upload_pack_reaches_the_wire_seam_with_the_right_request() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert_eq!(
                stdout, "git-upload-pack nordisk/osm-katana principal-1\n",
                "the transport's own bytes must arrive over the SSH channel"
            );
            assert_eq!(exit_status, Some(0));
        }
        ExecOutcome::Refused => panic!("a read grant must serve git-upload-pack"),
    }

    // And the seam saw the same thing the client did.
    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 receive_pack_reaches_the_wire_seam_when_the_grant_allows_it() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Write).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-receive-pack '/nordisk/osm-katana.git'")
        .await
        .expect("exec");
    assert!(
        matches!(&outcome, ExecOutcome::Accepted { stdout, .. }
            if stdout == "git-receive-pack nordisk/osm-katana principal-1\n"),
        "{outcome:?}"
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// **The exit-status ordering guard, and the only version of it that can go
/// red.**
///
/// A git service that writes a correct answer and then loses its exit status is
/// worse than one that fails outright: the server did the work, the client
/// reports failure, and the two disagree. That is exactly what happened while
/// `exec_request` handed the transport a `Channel::into_stream()`. russh's
/// `ChannelCloseOnDrop` spawned a `Close` when the transport let the stream go,
/// and the `Close` beat the `exit-status` to the client. Measured 2026-08-03
/// over stock OpenSSH: a complete 244-byte advertisement, exit **255**.
///
/// The tests above assert `exit_status == Some(0)` too, and they were **green
/// with the bug present**: on a current-thread runtime the server's own send
/// takes a free mpsc permit without yielding, so the spawned close never got to
/// run first. This one uses [`BannerTransport::settling_after_it_releases_the_stream`],
/// which drops the stream and then yields, so a close-on-drop has every
/// opportunity to arrive first, and a server that closes nothing on drop is
/// unaffected by the wait.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_exit_status_survives_the_transport_letting_the_stream_go() {
    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::settling_after_it_releases_the_stream(),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            // Both, and in this order: a missing status only matters because the
            // work behind it succeeded, and a test asserting the status alone
            // could be satisfied by a server that answered nothing at all.
            assert_eq!(
                stdout, "git-upload-pack nordisk/osm-katana principal-1\n",
                "the transport's bytes must still arrive"
            );
            assert_eq!(
                exit_status,
                Some(0),
                "gunnar served {} bytes and then lost the exit status — every successful push \
                 over this server would report failure",
                stdout.len()
            );
        }
        ExecOutcome::Refused => panic!("a read grant must serve git-upload-pack"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// The same guard, the other way round: a **non-zero** status has to survive the
/// release too.
///
/// Without this, everything above would pass equally well against a server that
/// hard-coded zero, and a failed transfer would be reported to the client as a
/// success. This one goes through the same path (the transport is handed the
/// stream, lets it go, and *then* the status is sent), so it is red under the
/// same bug.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_failed_transfer_reports_a_non_zero_exit_status() {
    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::settling_after_it_releases_the_stream().that_then_fails(),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted { exit_status, .. } => assert_eq!(
            exit_status,
            Some(gunnar_ssh::server::EXIT_TRANSPORT_FAILED),
            "a transfer that failed must say so in its exit status"
        ),
        ExecOutcome::Refused => panic!("the exec itself was authorised; only the transfer failed"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// A push the grant check refuses reports [`gunnar_ssh::server::EXIT_DENIED`].
///
/// **This one is NOT a guard for the ordering bug** and is not claimed to be:
/// the denial is answered before any stream exists, so nothing here can race a
/// close-on-drop. It guards the other half of "non-zero on a refused push":
/// that the refusal path reports a status at all, and reports one that is not
/// zero.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_refused_push_reports_a_non_zero_exit_status() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-receive-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted { exit_status, .. } => {
            assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
            assert_ne!(exit_status, Some(0), "a refusal must not report success");
        }
        ExecOutcome::Refused => panic!("a denial is answered in git's own protocol, not refused"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// A transfer refused **after** authorisation, before a byte was written, must
/// reach the client as words and not only as an exit status.
///
/// The defect this guards, found on 2026-08-05: `exec_request`'s spawned task
/// logged the `TransportError` and sent `exit-status 1` and nothing else. So
/// every post-authorisation refusal — most concretely a `gunnar gc` holding the
/// repository's `O_EXCL` store lock, which is the *only* thing that makes a
/// repack and a transfer mutually exclusive — arrived at the user as
/// `fatal: the remote end hung up unexpectedly`, with the entire reason sitting
/// in the server log. The same class as `report_error`'s zero call sites.
///
/// Two gates in that same function already answered in git's own protocol, which
/// is why this was invisible: the shape of a correct refusal was right there, one
/// screen up.
///
/// # Seen red
///
/// With the `if let Some(message) = told` block removed from
/// `Handler::exec_request`, `stdout` is `""` and the first assertion fails
/// naming the subject. With `TransportError::client_message` returning `None`
/// for `Refused`, likewise. The exit-status assertion alone is **not** enough
/// and is deliberately kept second: `a_failed_transfer_reports_a_non_zero_exit_status`
/// was already green over this bug for a whole day.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_transfer_refused_before_its_first_byte_tells_the_client_why() {
    // The sentence is distinctive on purpose: an assertion on `"ERR "` alone
    // would be satisfied by the *authorisation* denial, which is a different
    // code path answered by a different gate, and this test would then pass
    // while proving nothing about the one under it.
    const WHY: &str = "the repository is locked by maintenance on the server";

    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::refusing(WHY),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert!(
                stdout.contains(WHY),
                "a transfer refused before its first byte must tell the client why; the client \
                 got {stdout:?}"
            );
            // And in git's own framing, not as bare prose: a pkt-line is four hex
            // length bytes and then `ERR <message>`. Prose on the data band is
            // not an error message to a git client, it is a protocol violation.
            assert!(
                stdout.len() > 4 && stdout[4..].starts_with("ERR "),
                "the reason must be an ERR pkt-line, got {stdout:?}"
            );
            let declared = usize::from_str_radix(&stdout[..4], 16).expect("pkt-line length");
            assert_eq!(
                declared,
                stdout.len(),
                "the pkt-line length must cover exactly what was written, got {stdout:?}"
            );
            assert_eq!(
                exit_status,
                Some(gunnar_ssh::server::EXIT_TRANSPORT_FAILED),
                "and it still has to be a failure"
            );
        }
        ExecOutcome::Refused => {
            panic!("the exec was authorised; only the transfer was refused")
        }
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// The other direction, and the reason [`TransportError::client_message`] is not
/// simply "always tell them".
///
/// A transport that has already written its banner and *then* fails must get
/// **no** pkt-line appended. The client is mid-stream; four hex digits and `ERR`
/// spliced after an advertisement is corruption that a git client reads as a
/// malformed packet, which is a worse answer than silence. `report_error`'s
/// sideband is the only correct channel once a transfer is under way, and it
/// belongs to `gunnar-wire`, not to the session.
///
/// # Seen red
///
/// Making `client_message()` return `Some` for `TransportError::Protocol` puts an
/// `ERR` packet after the banner and this fails, naming the banner it was
/// appended to.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_transfer_that_already_wrote_gets_no_pkt_line_spliced_after_it() {
    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::settling_after_it_releases_the_stream().that_then_fails(),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            // The banner did arrive: this is a control on the control. Without it
            // an empty `stdout` would satisfy `!contains("ERR ")` and the test
            // would be green against a transport that never ran at all.
            assert!(
                stdout.contains("git-upload-pack nordisk/osm-katana"),
                "the banner has to have been served, or this test proves nothing: {stdout:?}"
            );
            assert!(
                !stdout.contains("ERR "),
                "a failure after the first byte must NOT splice a pkt-line into the stream: \
                 {stdout:?}"
            );
            assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_TRANSPORT_FAILED));
        }
        ExecOutcome::Refused => panic!("the exec was authorised"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_read_only_key_cannot_push_and_is_told_why_in_git_s_own_protocol() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-receive-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            // A pkt-line: four hex length bytes, then `ERR <message>`. Stock git
            // prints this instead of "Could not read from remote repository".
            assert!(
                stdout.starts_with("0060ERR ") || stdout[4..].starts_with("ERR "),
                "expected an ERR pkt-line, got {stdout:?}"
            );
            assert!(
                stdout.contains(
                    "repository not found, or you do not have access: nordisk/osm-katana"
                ),
                "{stdout:?}"
            );
            assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
        }
        ExecOutcome::Refused => panic!(
            "a git client asking for a repository it may not have should be answered in its own \
             protocol, not with an opaque channel failure"
        ),
    }

    // And nothing reached the wire.
    assert!(fixture.transport.served().is_empty());

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn no_shell_ever() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Admin).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    for command in [
        "bash",
        "sh -c id",
        "/bin/sh",
        "scp -t /tmp",
        "git-upload-pack",
        "git-upload-pack '../../etc/passwd'",
        "git upload-pack 'nordisk/osm-katana.git'",
    ] {
        let outcome = exec(&session, command).await.expect("exec");
        assert_eq!(
            outcome,
            ExecOutcome::Refused,
            "{command:?} must be refused outright"
        );
    }

    // `git-upload-archive` is in `command::NOT_SERVED` since 2026-08-30
    // (2026-08-30): it is refused BY NAME, in git's own protocol, so the user
    // reads a sentence instead of `exec request failed on channel 0`. Still not
    // served — the transport assertion below covers every command in this test
    // — but the channel opens far enough to carry the ERR line, which is the
    // whole point of naming it.
    let archive = exec(&session, "git-upload-archive 'nordisk/osm-katana.git'")
        .await
        .expect("exec");
    match archive {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert!(
                stdout.contains("upload-archive is not served"),
                "the refusal must say why: {stdout:?}"
            );
            assert_eq!(exit_status, Some(128), "a denial, not a busy signal");
        }
        other => panic!("git-upload-archive must be refused by NAME, not silently: {other:?}"),
    }

    // Not one of them reached the wire seam.
    assert!(
        fixture.transport.served().is_empty(),
        "something got through: {:?}",
        fixture.transport.served()
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_shell_request_is_refused_rather_than_left_hanging() {
    // russh's default for `shell_request` answers nothing at all, which leaves a
    // client waiting forever. A hung client and a refused one look the same in
    // a test that only checks "no shell ran".
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Admin).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let mut channel = session.channel_open_session().await.expect("open");
    channel.request_shell(true).await.expect("send request");

    let answer = tokio::time::timeout(std::time::Duration::from_secs(5), async {
        while let Some(msg) = channel.wait().await {
            match msg {
                russh::ChannelMsg::Success => return Some(true),
                russh::ChannelMsg::Failure => return Some(false),
                _ => {}
            }
        }
        None
    })
    .await
    .expect("the server must answer a shell request, not ignore it");
    assert_eq!(answer, Some(false), "a shell request must be FAILED");

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_subsystem_request_is_refused() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Admin).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let mut channel = session.channel_open_session().await.expect("open");
    channel
        .request_subsystem(true, "sftp")
        .await
        .expect("send request");

    let answer = tokio::time::timeout(std::time::Duration::from_secs(5), async {
        while let Some(msg) = channel.wait().await {
            match msg {
                russh::ChannelMsg::Success => return Some(true),
                russh::ChannelMsg::Failure => return Some(false),
                _ => {}
            }
        }
        None
    })
    .await
    .expect("the server must answer a subsystem request");
    assert_eq!(answer, Some(false), "sftp must be FAILED");

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn an_unknown_repository_is_refused_even_for_an_admin() {
    let key = mint_key();
    // A registered key with a server-wide **admin** grant, and an empty
    // catalog. Authorisation is not the thing standing in the way here; the
    // repository simply does not exist.
    let plane = Plane::empty()
        .with_principal("principal-1")
        .with_key("principal-1", &key)
        .with_namespace_grant("principal-1", "nordisk", gunnar_core::model::Level::Admin);
    let fixture = start(plane.control, None).await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");
    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");
    assert!(
        matches!(&outcome, ExecOutcome::Accepted { stdout, .. } if stdout.contains("ERR ")),
        "{outcome:?}"
    );
    assert!(fixture.transport.served().is_empty());

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}