mod common;
use gunnar_core::{Level, RepoRef};
use gunnar_ssh::Access;
use common::{authenticate, exec, mint_key, plane_with_key, start, the_repo, ExecOutcome, Plane};
fn git_says(outcome: &ExecOutcome) -> Option<String> {
let ExecOutcome::Accepted { stdout, .. } = outcome else {
return None;
};
let bytes = stdout.as_bytes();
if bytes.len() < 8 {
return None;
}
let len = usize::from_str_radix(std::str::from_utf8(&bytes[..4]).ok()?, 16).ok()?;
if len < 8 || len > bytes.len() {
return None;
}
let body = &bytes[4..len];
let text = std::str::from_utf8(body).ok()?;
let message = text.strip_prefix("ERR ")?.trim_end().to_owned();
(!message.is_empty()).then_some(message)
}
#[test]
fn the_reader_can_tell_silence_from_a_message() {
assert_eq!(git_says(&ExecOutcome::Refused), None);
assert_eq!(
git_says(&ExecOutcome::Accepted {
stdout: String::new(),
exit_status: Some(1)
}),
None,
"a channel that carried no bytes must read as silence, or every case here passes \
over a server that says nothing"
);
assert_eq!(
git_says(&ExecOutcome::Accepted {
stdout: "0000".into(),
exit_status: Some(1)
}),
None,
"a flush packet is not a message"
);
assert_eq!(
git_says(&ExecOutcome::Accepted {
stdout: "0016ERR no such thing\n".into(),
exit_status: Some(1)
})
.as_deref(),
Some("no such thing"),
);
}
fn upload(path: &RepoRef) -> String {
format!("git-upload-pack '{path}'")
}
fn receive(path: &RepoRef) -> String {
format!("git-receive-pack '{path}'")
}
#[tokio::test]
async fn a_key_with_no_grant_on_this_repository_is_refused_in_words() {
let key = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("stranger")
.with_key("stranger", &key);
let other = RepoRef::new("nordisk", "somewhere-else").expect("a legal path");
plane
.catalog()
.create(gunnar_core::NewRepo::new(other.clone()))
.expect("a second repository");
plane
.auth()
.grant(gunnar_core::Grant::repo(
"stranger",
other.clone(),
Level::Admin,
))
.expect("Admin, but on the other repository");
let control = plane.control.clone();
let fixture = start(control, None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("the key is registered, so authentication must succeed");
let allowed = exec(&session, &upload(&other)).await.expect("exec");
assert!(
matches!(allowed, ExecOutcome::Accepted { .. }) && git_says(&allowed).is_none(),
"the key was refused on the repository it holds Admin on, so the refusal below is \
not about the grant: {allowed:?}"
);
let refused = exec(&session, &upload(&the_repo())).await.expect("exec");
let said = git_says(&refused).unwrap_or_else(|| {
panic!(
"a client refused for want of a grant heard NOTHING. It will print `the remote \
end hung up unexpectedly` and the reason will be in a log it cannot read: \
{refused:?}"
)
});
assert!(
said.contains(&the_repo().to_string()),
"the refusal does not name the repository that was refused: {said:?}"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_repository_that_does_not_exist_refuses_in_the_same_words() {
let key = mint_key();
let fixture = start(plane_with_key(&key, Access::Admin).control, None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("authenticate");
let ghost = RepoRef::new("nordisk", "never-created").expect("a legal path");
let refused = exec(&session, &upload(&ghost)).await.expect("exec");
let said = git_says(&refused).unwrap_or_else(|| {
panic!("a client asking for a missing repository heard nothing: {refused:?}")
});
assert!(
said.contains(&ghost.to_string()),
"the refusal does not name what was asked for: {said:?}"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_missing_repository_and_a_forbidden_one_are_one_string_to_the_client() {
let key = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("stranger")
.with_key("stranger", &key);
let fixture = start(plane.control.clone(), None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("authenticate");
let forbidden = exec(&session, &upload(&the_repo())).await.expect("exec");
let missing_path = RepoRef::new("nordisk", "not-a-repository").expect("a legal path");
let missing = exec(&session, &upload(&missing_path)).await.expect("exec");
let forbidden_said = git_says(&forbidden).expect("the forbidden repository answered");
let missing_said = git_says(&missing).expect("the missing repository answered");
let a = forbidden_said.replace(&the_repo().to_string(), "<path>");
let b = missing_said.replace(&missing_path.to_string(), "<path>");
assert_eq!(
a, b,
"a client can tell a repository it may not have from one that does not exist:\n \
forbidden: {forbidden_said}\n missing: {missing_said}\n\
That difference is a directory listing of every private repository on the server."
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_principal_deleted_mid_session_cannot_exec_again() {
let key = mint_key();
let owned = RepoRef::new("alice", "diary").expect("a legal path");
let plane = Plane::empty()
.with_principal("alice")
.with_key("alice", &key);
plane
.catalog()
.create_namespace("alice")
.expect("namespace");
plane
.catalog()
.create(
gunnar_core::NewRepo::new(owned.clone()).visibility(gunnar_core::Visibility::Private),
)
.expect("repository");
let control = plane.control.clone();
let fixture = start(control.clone(), None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("authenticate");
let before = exec(&session, &receive(&owned)).await.expect("exec");
assert!(
matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
"the owner could not reach its own account repository, so the deletion below \
proves nothing: {before:?}"
);
assert!(
control
.auth()
.grants(Some("alice"))
.expect("grants")
.is_empty(),
"the fixture handed out a grant, so this test is about a grant and not about \
intrinsic ownership"
);
control
.auth()
.remove_principal("alice")
.expect("remove the principal");
let after = exec(&session, &receive(&owned)).await.expect("exec");
let said = git_says(&after).unwrap_or_else(|| {
panic!(
"a deleted principal was still served on its own namespace, or was refused in \
silence: {after:?}"
)
});
assert_eq!(
said,
gunnar_ssh::server::REVOKED_MESSAGE,
"the deleted principal was stopped, but not by its credential ceasing to resolve. \
Something else caught it, and the second half below is then the only thing \
standing between a deleted owner and its namespace: {said:?}"
);
let repo = control
.catalog()
.get(&owned)
.expect("the catalog is readable")
.expect("the repository is still there");
for level in [Level::Read, Level::Write, Level::Admin] {
assert!(
!control
.auth()
.may("alice", &repo, level)
.expect("the identity table is readable"),
"a deleted principal still holds {level:?} on its own namespace"
);
}
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_grant_downgraded_mid_session_stops_the_next_push() {
let key = mint_key();
let plane = plane_with_key(&key, Access::Write);
let control = plane.control.clone();
let fixture = start(control.clone(), None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("authenticate");
let before = exec(&session, &receive(&the_repo())).await.expect("exec");
assert!(
matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
"a Write grant could not push, so the downgrade below proves nothing: {before:?}"
);
assert!(
control
.auth()
.revoke_grant("principal-1", &gunnar_core::Scope::Repo(the_repo()))
.expect("revoke"),
"the fixture's grant was not where this test revoked from"
);
control
.auth()
.grant(gunnar_core::Grant::repo(
"principal-1",
the_repo(),
Level::Read,
))
.expect("re-grant at Read");
let push = exec(&session, &receive(&the_repo())).await.expect("exec");
let said = git_says(&push).unwrap_or_else(|| {
panic!("a downgraded session pushed, or was refused in silence: {push:?}")
});
assert!(
said.contains(&the_repo().to_string()),
"the refusal does not name the repository: {said:?}"
);
let fetch = exec(&session, &upload(&the_repo())).await.expect("exec");
assert!(
matches!(fetch, ExecOutcome::Accepted { .. }) && git_says(&fetch).is_none(),
"the Read grant that survived the downgrade cannot fetch either, so the refusal \
above is the session breaking and not the ACL answering: {fetch:?}"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_repository_deleted_mid_session_refuses_like_one_that_never_existed() {
let key = mint_key();
let plane = plane_with_key(&key, Access::Admin);
let control = plane.control.clone();
let fixture = start(control.clone(), None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("authenticate");
let before = exec(&session, &upload(&the_repo())).await.expect("exec");
assert!(
matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
"an Admin grant could not fetch, so the deletion below proves nothing: {before:?}"
);
control
.catalog()
.delete(&the_repo())
.expect("delete the repository");
let after = exec(&session, &upload(&the_repo())).await.expect("exec");
let said = git_says(&after).unwrap_or_else(|| {
panic!("a session holding Admin on a deleted repository heard nothing: {after:?}")
});
assert!(
said.contains(&the_repo().to_string()),
"the refusal does not name the repository: {said:?}"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_namespace_deleted_mid_session_takes_its_repositories_with_it() {
let key = mint_key();
let plane = plane_with_key(&key, Access::Admin);
let control = plane.control.clone();
let fixture = start(control.clone(), None).await;
let session = authenticate(fixture.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("authenticate");
let before = exec(&session, &upload(&the_repo())).await.expect("exec");
assert!(
matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
"the fixture cannot fetch before the namespace goes, so nothing below is measured: \
{before:?}"
);
let taken = control
.catalog()
.delete_namespace(the_repo().namespace(), true)
.expect("delete the namespace and its repositories");
assert_eq!(
taken.len(),
1,
"the namespace delete took {} repositories with it, so the fixture is not the \
shape this test describes",
taken.len()
);
let after = exec(&session, &upload(&the_repo())).await.expect("exec");
let said = git_says(&after).unwrap_or_else(|| {
panic!("a session whose namespace was deleted under it heard nothing: {after:?}")
});
assert!(
said.contains(&the_repo().to_string()),
"the refusal does not name what was asked for: {said:?}"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_revoked_key_loses_the_session_it_already_opened() {
let key = mint_key();
let plane = plane_with_key(&key, Access::Write);
let control = plane.control.clone();
let fixture = start(control.clone(), None).await;
let addr = fixture.server.local_addr();
let session = authenticate(addr, &key, "git")
.await
.expect("connect")
.expect("authenticate");
let before = exec(&session, &receive(&the_repo())).await.expect("exec");
assert!(
matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
"the fixture cannot push before the revocation: {before:?}"
);
let fingerprint = common::fingerprint_of(&key);
assert!(
control
.auth()
.revoke_key(&fingerprint)
.expect("revoke")
.is_some(),
"the fingerprint this test revoked is not the one the table holds, so nothing was \
revoked and both halves below would be vacuous"
);
let after = exec(&session, &receive(&the_repo())).await.expect("exec");
let said = git_says(&after).unwrap_or_else(|| {
panic!(
"an exec on a session whose key was revoked was answered without a word the \
client can read: {after:?}"
)
});
assert_eq!(
said,
gunnar_ssh::server::REVOKED_MESSAGE,
"the refusal reached the client, but not as the one sentence the server has for \
this — so what a user is told is decided at the refusal site and not in one place"
);
assert!(
authenticate(addr, &key, "git")
.await
.expect("the connection itself must succeed")
.is_none(),
"a revoked key opened a NEW session. The revocation is a no-op and the position \
above is a hole"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn an_expired_key_does_not_open_a_session() {
let live = mint_key();
let dead = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("principal-1")
.with_key("principal-1", &live)
.with_grant("principal-1", Access::Write);
let an_hour_ago = gunnar_core::now_ms() - 3_600_000;
plane
.auth()
.register_key(
"principal-1",
&common::authorized_keys_line(&dead),
Some(an_hour_ago),
)
.expect("register an already-expired key");
let fixture = start(plane.control.clone(), None).await;
let addr = fixture.server.local_addr();
assert!(
authenticate(addr, &dead, "git")
.await
.expect("the connection itself must succeed")
.is_none(),
"an expired key opened an SSH session. Its principal, its grant and its \
registration are all intact; the only thing wrong with it is the clock"
);
assert!(
authenticate(addr, &live, "git")
.await
.expect("connect")
.is_some(),
"the unexpired key was refused too, so the refusal above is not about expiry"
);
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_key_cannot_borrow_the_identity_its_username_names() {
let holder_key = mint_key();
let stranger_key = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("holder")
.with_key("holder", &holder_key)
.with_grant("holder", Access::Admin)
.with_principal("stranger")
.with_key("stranger", &stranger_key);
let fixture = start(plane.control.clone(), None).await;
let addr = fixture.server.local_addr();
let holder = authenticate(addr, &holder_key, "git")
.await
.expect("connect")
.expect("the holder authenticates");
let served = exec(&holder, &upload(&the_repo())).await.expect("exec");
assert!(
matches!(served, ExecOutcome::Accepted { .. }) && git_says(&served).is_none(),
"the holder cannot reach the repository it holds Admin on: {served:?}"
);
for user in ["git", "holder", "root", "stranger"] {
let session = authenticate(addr, &stranger_key, user)
.await
.expect("connect")
.unwrap_or_else(|| {
panic!("the stranger's key is registered; it must authenticate as {user:?}")
});
let outcome = exec(&session, &upload(&the_repo())).await.expect("exec");
let said = git_says(&outcome).unwrap_or_else(|| {
panic!(
"connecting as {user:?} with the stranger's key was SERVED, or refused in \
silence. The SSH username must not reach an authorisation decision: \
{outcome:?}"
)
});
assert!(
said.contains("stranger") || said.contains(&the_repo().to_string()),
"the refusal for user {user:?} names neither the principal nor the repository: \
{said:?}"
);
assert!(
!said.contains("holds admin"),
"the refusal for user {user:?} reports the HOLDER's level, so the username \
selected the identity: {said:?}"
);
}
fixture.server.shutdown("done").await.expect("shutdown");
}
#[tokio::test]
async fn a_connection_may_not_offer_keys_without_end() {
const LIMIT: usize = 3;
let good = common::mint_key();
let plane = plane_with_key(&good, Access::Write);
let mut config = gunnar_ssh::SshConfig::ephemeral_loopback();
config.max_auth_attempts = LIMIT;
config.auth_rejection_time = std::time::Duration::ZERO;
let fixture = common::start_with_config(
plane.control,
config,
gunnar_ssh::seam::BannerTransport::new(),
)
.await;
let addr = fixture.server.local_addr();
let strangers: Vec<_> = (0..LIMIT + 4).map(|_| common::mint_key()).collect();
let (answered, accepted, error) = common::offer_keys(addr, &strangers, "git").await;
assert!(!accepted, "a key nobody registered authenticated");
assert!(
error.is_some(),
"the server answered all {answered} offers and kept the connection open. \
`max_auth_attempts` is {LIMIT} and nothing is enforcing it"
);
assert!(
answered <= LIMIT,
"the server answered {answered} offers before dropping the connection, and the \
limit is {LIMIT}"
);
let (answered, accepted, error) =
common::offer_keys(addr, &strangers[..LIMIT - 1], "git").await;
assert_eq!(
(answered, accepted, error.is_none()),
(LIMIT - 1, false, true),
"a client that stayed inside the budget was cut off anyway, so the limit above is \
a server that hangs up on strangers rather than a limit"
);
let mut mixed = strangers[..LIMIT - 1].to_vec();
mixed.push(good);
let (answered, accepted, error) = common::offer_keys(addr, &mixed, "git").await;
assert!(
accepted && error.is_none(),
"a registered key offered as attempt {answered} of {LIMIT} was not accepted: \
{error:?}"
);
fixture.server.shutdown("done").await.expect("shutdown");
}