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"),
}
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");
}
#[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,
} => {
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");
}
#[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");
}
#[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");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_transfer_refused_before_its_first_byte_tells_the_client_why() {
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:?}"
);
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");
}
#[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,
} => {
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,
} => {
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"
),
}
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"
);
}
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:?}"),
}
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() {
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();
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");
}