crates/gunnar-ssh/tests/anonymous.rs
//! **Anonymous read for public repositories, over a real SSH handshake**
//! (the ACL model).
//!
//! `tests/sandbox.rs` drives [`Dispatch::authorise`] directly, which is the
//! right level for the *decision*. This file is about the thing a decision
//! cannot be tested for: whether an unauthenticated caller can get a session at
//! all, and whether it is the session it is supposed to have. Every test here
//! completes a real russh handshake against a real listener, so the answer to
//! "how does an anonymous caller arrive" is measured rather than designed.
//!
//! The mechanism, stated once: `auth_none` is accepted for the single username
//! [`ANONYMOUS_SSH_USER`], and refused for every other. See
//! `gunnar_ssh::server`'s module docs for why the two alternatives — accepting
//! `none` for everybody, and a published well-known key — were rejected.
//!
//! **Both directions, everywhere.** An anonymous path that refused everything
//! would pass every "a stranger may not" test in this file and serve nobody;
//! one that allowed everything would pass every "a stranger may" test. Each is
//! written next to its opposite for that reason.
mod common;
use gunnar_core::model::{Grant, Level, Visibility};
use gunnar_core::{NewRepo, RepoRef, ANONYMOUS_PRINCIPAL_ID};
use gunnar_ssh::{Access, SshConfig, ANONYMOUS_SSH_USER};
use common::{
authorized_keys_line, exec, mint_key, principal, start_with_config, ExecOutcome, Fixture, Plane,
};
/// The public repository every test here reads, and the private one it must
/// not.
fn public_repo() -> RepoRef {
RepoRef::new("nordisk", "open").expect("legal")
}
fn private_repo() -> RepoRef {
RepoRef::new("nordisk", "shut").expect("legal")
}
/// A control plane with `nordisk/open` public and `nordisk/shut` private, plus
/// one registered principal holding `level` on **both** — the authenticated
/// caller whose behaviour must not change.
fn plane(level: Access, key: &russh::keys::ssh_key::PrivateKey) -> Plane {
let plane = Plane::empty().with_principal("principal-1");
plane
.catalog()
.create_namespace("nordisk")
.expect("namespace");
plane
.catalog()
.create(NewRepo::new(public_repo()).visibility(Visibility::Public))
.expect("create the public repository");
plane
.catalog()
.create(NewRepo::new(private_repo()).visibility(Visibility::Private))
.expect("create the private repository");
plane
.auth()
.register_key("principal-1", &authorized_keys_line(key), None)
.expect("register");
for path in [public_repo(), private_repo()] {
plane
.auth()
.grant(Grant::repo("principal-1", path, level))
.expect("grant");
}
plane
}
async fn start(plane: Plane, allow_anonymous: bool) -> Fixture {
let mut config = SshConfig::ephemeral_loopback();
config.allow_anonymous = allow_anonymous;
start_with_config(
plane.control,
config,
gunnar_ssh::seam::BannerTransport::new(),
)
.await
}
/// Complete a handshake with **no key at all**, as `username`.
///
/// `Ok(None)` means the server refused; `Err` means the connection failed.
async fn authenticate_anonymously(
addr: std::net::SocketAddr,
username: &str,
) -> Result<Option<russh::client::Handle<common::TrustingClient>>, russh::Error> {
let config = std::sync::Arc::new(russh::client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(15)),
..Default::default()
});
let mut session = russh::client::connect(config, addr, common::TrustingClient).await?;
let result = session.authenticate_none(username).await?;
Ok(result.success().then_some(session))
}
// ───────────────────────────────────────────────────────────────────────────
// Arriving
// ───────────────────────────────────────────────────────────────────────────
/// **The prize, and its opposite.** No key, username `anonymous` → a session
/// serving the public repository. No key, any other username → no session.
#[tokio::test]
async fn a_keyless_caller_gets_in_as_anonymous_and_only_under_that_username() {
let key = mint_key();
let fx = start(plane(Access::Write, &key), true).await;
let addr = fx.server.local_addr();
// Green: the reserved username, with nothing offered.
let session = authenticate_anonymously(addr, ANONYMOUS_SSH_USER)
.await
.expect("connect")
.expect("the anonymous username must be admitted with no key");
let outcome = exec(&session, "git-upload-pack 'nordisk/open.git'")
.await
.expect("exec");
// Applied output, not "it was accepted": the banner names the service, the
// repository and the PRINCIPAL the request was served as. A session that
// got in as somebody else would be green on acceptance alone.
assert_eq!(
outcome,
ExecOutcome::Accepted {
stdout: format!("git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}\n"),
exit_status: Some(0),
},
"an anonymous clone of a public repository must reach the wire, as the anonymous \
principal"
);
// Red: every other username. `git` is what a git URL carries by default, so
// this is the case that would break every existing client if `none` were
// accepted blanket-fashion.
for username in ["git", "", "root", "principal-1", "Anonymous", "anonymous "] {
assert!(
authenticate_anonymously(addr, username)
.await
.expect("connect")
.is_none(),
"the `none` method was accepted for username {username:?}; it is reserved for \
{ANONYMOUS_SSH_USER:?} exactly, and accepting it for `git` would silently \
downgrade every authenticated client"
);
}
}
/// The listener switch closes the door, and the same server with it open is the
/// red half.
#[tokio::test]
async fn allow_anonymous_false_refuses_even_a_public_repository() {
let key = mint_key();
let closed = start(plane(Access::Write, &key), false).await;
assert!(
authenticate_anonymously(closed.server.local_addr(), ANONYMOUS_SSH_USER)
.await
.expect("connect")
.is_none(),
"`allow_anonymous: false` must refuse the anonymous session at the listener"
);
// …and the registered key still works on that same server, so the switch
// closed one door rather than the building.
let session = common::authenticate(closed.server.local_addr(), &key, "git")
.await
.expect("connect")
.expect("a registered key must still authenticate with anonymous access off");
// The banner, not `Accepted { .. }` — a denial is `Accepted` too, because a
// refused git command is answered in the git protocol. See the note in
// `an_anonymous_session_reaches_no_command_but_the_two_git_verbs`.
assert_eq!(
exec(&session, "git-upload-pack 'nordisk/open.git'")
.await
.expect("exec"),
ExecOutcome::Accepted {
stdout: "git-upload-pack nordisk/open principal-1\n".to_string(),
exit_status: Some(0),
}
);
let open = start(plane(Access::Write, &key), true).await;
assert!(
authenticate_anonymously(open.server.local_addr(), ANONYMOUS_SSH_USER)
.await
.expect("connect")
.is_some(),
"the same configuration with the switch on must admit the anonymous session, or the \
assertion above is about a server that refuses everybody"
);
}
// ───────────────────────────────────────────────────────────────────────────
// What an anonymous session may do — and may not
// ───────────────────────────────────────────────────────────────────────────
/// **Read only, and only upload-pack.** A push is refused, and the refusal is
/// the ordinary access denial rather than a crash or a channel failure.
#[tokio::test]
async fn an_anonymous_session_may_fetch_a_public_repository_and_may_not_push_to_it() {
let key = mint_key();
let fx = start(plane(Access::Write, &key), true).await;
let session = authenticate_anonymously(fx.server.local_addr(), ANONYMOUS_SSH_USER)
.await
.expect("connect")
.expect("anonymous session");
// Green — asserted on the banner and a ZERO exit status. `Accepted { .. }`
// alone would not do: a denial is also `Accepted`, with `EXIT_DENIED`.
assert_eq!(
exec(&session, "git-upload-pack 'nordisk/open.git'")
.await
.expect("exec"),
ExecOutcome::Accepted {
stdout: format!("git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}\n"),
exit_status: Some(0),
},
"anonymous fetch of a public repository must be served"
);
// Red — and it must be refused with the SAME string a missing repository
// gets, so `git-receive-pack` cannot be used to probe what exists either.
let ExecOutcome::Accepted {
stdout,
exit_status,
} = exec(&session, "git-receive-pack 'nordisk/open.git'")
.await
.expect("exec")
else {
panic!("a denied git command is answered in the git protocol, not with a channel failure")
};
assert!(
stdout.contains("ERR repository not found, or you do not have access"),
"an anonymous PUSH must be refused: {stdout:?}"
);
assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
// The transport is the applied-output witness: it saw the fetch and did
// NOT see the push. A dispatch that authorised the push and then failed
// somewhere later would still be a server that let a stranger write.
assert_eq!(
fx.transport.served(),
vec![format!(
"git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}"
)],
"the wire was reached by something other than the one anonymous fetch"
);
}
/// A private repository is refused to an anonymous caller **byte for byte** the
/// way a repository that does not exist is.
///
/// This is the whole of `Visibility::Private` from outside: a different answer
/// for "exists but you may not" hands a stranger a repository-name oracle, and
/// names are cheap to guess.
///
/// # Why the same path twice, and not two different names
///
/// The first draft of this test asked for `nordisk/shut` and `nordisk/ghost`
/// and compared the answers with the names blanked. It failed on the pkt-line
/// **length prefix** — `0046` against `0047` — because the two names are
/// different lengths. That is not a leak (the client chose the name and knows
/// how long it is), but it does mean a comparison across two names can never be
/// byte-for-byte, and blanking enough of the string to make it pass would blank
/// the thing being measured.
///
/// So: **one name, one server, two states.** The repository is asked for while
/// it does not exist, then created — private — and asked for again with the
/// identical command. Anything that differs is something a stranger can use.
#[tokio::test]
async fn a_private_repository_and_an_absent_one_are_indistinguishable_byte_for_byte() {
let key = mint_key();
let plane = plane(Access::Write, &key);
let catalog = plane.catalog().clone();
let fx = start(plane, true).await;
let session = authenticate_anonymously(fx.server.local_addr(), ANONYMOUS_SSH_USER)
.await
.expect("connect")
.expect("anonymous session");
const COMMAND: &str = "git-upload-pack 'nordisk/hidden.git'";
let hidden = RepoRef::new("nordisk", "hidden").expect("legal");
// 1. It does not exist.
let absent = exec(&session, COMMAND).await.expect("exec");
// 2. It exists, and is private.
catalog
.create(NewRepo::new(hidden.clone()).visibility(Visibility::Private))
.expect("create it private");
let private = exec(&session, COMMAND).await.expect("exec");
assert_eq!(
private, absent,
"a private repository answers differently from an absent one under the identical \
command, which lets a stranger enumerate every private repository on this server one \
guess at a time"
);
// …and both really are refusals, not two equal successes — the identity
// value that would satisfy the comparison above without meaning anything.
let ExecOutcome::Accepted {
stdout,
exit_status,
} = &private
else {
panic!("expected a protocol answer, got a channel failure")
};
assert!(
stdout.contains("ERR repository not found, or you do not have access"),
"{stdout:?}"
);
assert_eq!(*exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
assert!(fx.transport.served().is_empty(), "the wire was reached");
// 3. **The red half of the whole test.** The same command, the same
// session, the same repository — made public. If this did not change, the
// two equal answers above would be equal because gunnar refuses everything.
catalog
.set_visibility(&hidden, Visibility::Public)
.expect("publish it");
let published = exec(&session, COMMAND).await.expect("exec");
assert_ne!(
published, private,
"publishing the repository changed nothing an anonymous caller can see, so `visibility` \
is still not being honoured"
);
assert_eq!(
published,
ExecOutcome::Accepted {
stdout: format!("git-upload-pack nordisk/hidden {ANONYMOUS_PRINCIPAL_ID}\n"),
exit_status: Some(0),
}
);
}
/// **The whole control surface, from an anonymous session.** No shell, no
/// subsystem, no arbitrary command — the same "no shell, ever" answer an
/// authenticated session gets, asserted from the one session that has no key
/// behind it.
///
/// gunnar's control plane is a separate listener on a separate port speaking
/// gRPC, and it is not reachable from an SSH channel at all; this is the SSH
/// half of "an anonymous caller cannot reach a mutation by any route". The
/// gRPC half is `gunnar-grpc`'s `tests/auth.rs` and `gunnar-cli`'s
/// `tests/anonymous_access.rs`.
#[tokio::test]
async fn an_anonymous_session_reaches_no_command_but_the_two_git_verbs() {
let key = mint_key();
let fx = start(plane(Access::Write, &key), true).await;
let session = authenticate_anonymously(fx.server.local_addr(), ANONYMOUS_SSH_USER)
.await
.expect("connect")
.expect("anonymous session");
for command in [
"bash",
"sh -c 'gunnar repo create nordisk/mine'",
"gunnar",
"git",
"scp -t /tmp",
// The control plane is gRPC and not an exec verb; if one is ever added,
// this line is what says an anonymous session must not have it.
"gunnar-control",
// The one control verb there is (2026-09-21, T17): server Admin only,
// so an anonymous session is refused the channel like a shell.
"gunnar-tunnel-address",
"gunnar-tunnel-address 10.77.0.2/16",
"",
] {
assert_eq!(
exec(&session, command).await.expect("exec"),
ExecOutcome::Refused,
"an anonymous session got something other than a channel failure for {command:?}"
);
}
// `git archive --remote=` is a git verb gunnar knows the name of and
// does not serve. It is refused BY NAME, in git's own protocol, so the
// client prints the sentence rather than `exec request failed on channel
// 0` (the site test, 2026-08-30) — still no archive, still nothing
// served, and the sentence names the alternative.
let outcome = exec(&session, "git-upload-archive 'nordisk/open.git'")
.await
.expect("exec");
match outcome {
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert!(
stdout.contains("ERR git-upload-archive: upload-archive is not served; clone and archive locally"),
"the by-name refusal must carry its sentence, got {stdout:?}"
);
assert_ne!(exit_status, Some(0), "a refused verb must not exit 0");
}
other => panic!("upload-archive must be refused by name with a sentence, got {other:?}"),
}
// The identity-value guard for the loop: the one command that IS allowed
// still is, on the same session. Without it, a server that refused every
// exec would pass everything above.
//
// Asserted on the **banner**, not on `ExecOutcome::Accepted` — a *denied*
// git command is also `Accepted`, because gunnar answers a denial in the
// git protocol (`channel_success`, then an `ERR` pkt-line) so that stock
// git prints the reason. `matches!(…, Accepted { .. })` here was green
// under a deliberate break that refused every anonymous read; it is not a
// guard, it is a coin toss.
assert_eq!(
exec(&session, "git-upload-pack 'nordisk/open.git'")
.await
.expect("exec"),
ExecOutcome::Accepted {
stdout: format!("git-upload-pack nordisk/open {ANONYMOUS_PRINCIPAL_ID}\n"),
exit_status: Some(0),
}
);
}
// ───────────────────────────────────────────────────────────────────────────
// The authenticated caller is unaffected
// ───────────────────────────────────────────────────────────────────────────
/// **The regression guard.** A registered key with a `Read` grant behaves
/// exactly as it did before the anonymous path existed — it authenticates as
/// *itself*, it may fetch, and it may not push.
#[tokio::test]
async fn a_registered_read_grant_is_unaffected_by_the_anonymous_path() {
let key = mint_key();
let fx = start(plane(Access::Read, &key), true).await;
// The username is still decorative for a key: `git`, `anything`, whatever.
for username in ["git", "whatever"] {
let session = common::authenticate(fx.server.local_addr(), &key, username)
.await
.expect("connect")
.expect("a registered key authenticates regardless of the username");
// It is served as ITSELF, not as anonymous. A publickey handler that
// fell through to the anonymous branch would be green on "it cloned".
assert_eq!(
exec(&session, "git-upload-pack 'nordisk/open.git'")
.await
.expect("exec"),
ExecOutcome::Accepted {
stdout: "git-upload-pack nordisk/open principal-1\n".to_string(),
exit_status: Some(0),
}
);
// Its grant still reaches the PRIVATE repository, which the anonymous
// principal cannot see at all.
assert_eq!(
exec(&session, "git-upload-pack 'nordisk/shut.git'")
.await
.expect("exec"),
ExecOutcome::Accepted {
stdout: "git-upload-pack nordisk/shut principal-1\n".to_string(),
exit_status: Some(0),
}
);
// …and Read is still not Write.
let ExecOutcome::Accepted { stdout, .. } =
exec(&session, "git-receive-pack 'nordisk/open.git'")
.await
.expect("exec")
else {
panic!("expected a protocol answer")
};
assert!(stdout.contains("ERR repository not found, or you do not have access"));
}
// An unregistered key is still refused, with the anonymous door open beside
// it. "No key" is admitted; "a key nobody registered" is not, and the two
// must not have collapsed into one another.
assert!(
common::authenticate(fx.server.local_addr(), &mint_key(), ANONYMOUS_SSH_USER)
.await
.expect("connect")
.is_none(),
"an unregistered key authenticated. Offering a key is a claim to be somebody, and it \
must be refused even under the anonymous username"
);
}
/// The counter an operator reads to answer *"how much of my traffic is
/// strangers"* — both halves of it.
#[tokio::test]
async fn anonymous_and_authenticated_connections_are_counted_apart() {
let key = mint_key();
let fx = start(plane(Access::Read, &key), true).await;
let addr = fx.server.local_addr();
let before = fx.server.metrics();
assert_eq!(before.connections_anonymous, 0);
let _anon = authenticate_anonymously(addr, ANONYMOUS_SSH_USER)
.await
.expect("connect")
.expect("anonymous");
let _keyed = common::authenticate(addr, &key, "git")
.await
.expect("connect")
.expect("keyed");
let after = fx.server.metrics();
assert_eq!(
(
after.connections_anonymous - before.connections_anonymous,
after.connections_accepted - before.connections_accepted,
),
(1, 1),
"one anonymous session and one keyed session must move one counter each; folding them \
together would make `how much anonymous traffic is this` unanswerable"
);
}
/// The dispatch answer for the anonymous principal, without a handshake — the
/// same question `sandbox.rs` asks for registered principals, so the two files
/// cover the same decision from both sides.
#[test]
fn the_dispatch_serves_anonymous_a_public_repo_and_refuses_it_everything_else() {
let key = mint_key();
let dispatch = gunnar_ssh::server::Dispatch::new(plane(Access::Write, &key).control);
let anonymous = gunnar_core::Principal::anonymous();
let request = dispatch
.authorise(&anonymous, None, b"git-upload-pack 'nordisk/open.git'")
.expect("anonymous may read a public repository");
assert_eq!(request.repo.key(), "nordisk/open");
assert_eq!(request.principal.id, ANONYMOUS_PRINCIPAL_ID);
assert_eq!(
request.access,
Level::Read,
"an anonymous request must be served at Read and never above it"
);
// `held` differs between these on purpose and is asserted, because it is
// the clearest statement of what rule 0 gives anonymous: `read` on the
// public repository and literally nothing on the private one. It is a
// server-side field — `client_message()` does not carry it — so saying so
// here leaks nothing to a client.
for (raw, why, held_now) in [
(
&b"git-receive-pack 'nordisk/open.git'"[..],
"a push to a public repository",
"read",
),
(
b"git-upload-pack 'nordisk/shut.git'",
"a private repository",
"none",
),
(
b"git-receive-pack 'nordisk/shut.git'",
"a push to a private repository",
"none",
),
] {
let err = dispatch
.authorise(&anonymous, None, raw)
.expect_err(&format!("anonymous must be refused {why}"));
assert!(
matches!(
&err,
gunnar_ssh::server::DispatchError::Denied { held, .. } if held == held_now
),
"{why}: {err}"
);
// Whatever the server knows internally, the client is told one thing.
assert!(err
.client_message()
.expect("a git client is listening")
.starts_with("repository not found, or you do not have access:"));
}
// Whereas the registered principal, on the same dispatch, may do all of it.
let who = principal("principal-1");
for raw in [
&b"git-upload-pack 'nordisk/open.git'"[..],
b"git-receive-pack 'nordisk/open.git'",
b"git-upload-pack 'nordisk/shut.git'",
b"git-receive-pack 'nordisk/shut.git'",
] {
dispatch
.authorise(&who, None, raw)
.expect("a Write grant covers all four");
}
}