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