mod common;
use gunnar_core::model::Level;
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::{Access, GitService, Principal, RepoRef};
use common::{plane_with_key, principal, Plane};
fn dispatch(level: Access) -> (Dispatch, Principal) {
let plane = Plane::empty()
.with_the_repo()
.with_principal("principal-1")
.with_grant("principal-1", level);
(Dispatch::new(plane.control), principal("principal-1"))
}
#[test]
fn a_legitimate_nested_path_is_served() {
let (dispatch, who) = dispatch(Access::Read);
let request = dispatch
.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("a registered repository with a read grant must be served");
assert_eq!(request.service, GitService::UploadPack);
assert_eq!(request.repo.key(), "nordisk/osm-katana");
assert_eq!(request.principal.id, "principal-1");
let via_url = dispatch
.authorise(&who, None, b"git-upload-pack '/nordisk/osm-katana'")
.expect("the ssh:// spelling names the same repository");
assert_eq!(via_url.repo, request.repo);
}
#[test]
fn a_nested_namespace_resolves_and_is_served() {
let plane = Plane::empty().with_principal("principal-1");
let deep = RepoRef::new("team/sub", "project").expect("nested namespaces are legal");
plane
.catalog()
.create_namespace("team/sub")
.expect("create the nested namespace");
plane
.catalog()
.create(gunnar_core::NewRepo::new(deep.clone()))
.expect("create");
plane
.auth()
.grant(gunnar_core::model::Grant::repo(
"principal-1",
deep,
Level::Read,
))
.expect("grant");
let dispatch = Dispatch::new(plane.control);
let request = dispatch
.authorise(
&principal("principal-1"),
None,
b"git-upload-pack 'team/sub/project.git'",
)
.expect("a nested namespace is legal");
assert_eq!(request.repo.namespace, "team/sub");
assert_eq!(request.repo.name, "project");
}
#[test]
fn a_grant_on_the_parent_namespace_does_not_reach_a_nested_one() {
let plane = Plane::empty().with_principal("principal-1");
for (namespace, name) in [("team", "shallow"), ("team/sub", "deep")] {
plane
.catalog()
.create_namespace(namespace)
.expect("create namespace");
plane
.catalog()
.create(gunnar_core::NewRepo::new(
RepoRef::new(namespace, name).expect("legal"),
))
.expect("create");
}
let plane = plane.with_namespace_grant("principal-1", "team", Level::Admin);
let dispatch = Dispatch::new(plane.control);
let who = principal("principal-1");
dispatch
.authorise(&who, None, b"git-receive-pack 'team/shallow.git'")
.expect("an admin grant on `team` must serve a repository in `team`");
let err = dispatch
.authorise(&who, None, b"git-upload-pack 'team/sub/deep.git'")
.expect_err("a grant on `team` must NOT reach `team/sub`");
assert!(
matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
"nested namespaces must not inherit grants: {err}"
);
}
#[test]
fn escapes_never_reach_a_repository() {
let (dispatch, who) = dispatch(Access::Admin);
for raw in [
&b"git-upload-pack '../../etc/passwd'"[..],
b"git-upload-pack 'nordisk/../../etc/passwd'",
b"git-upload-pack '/nordisk/../osm-katana'",
b"git-upload-pack '.git/../..'",
b"git-upload-pack '~/nordisk/osm-katana.git'",
b"git-upload-pack 'nordisk/osm-katana.git\0'",
b"git-upload-pack 'nordisk/osm-katana.lock'",
] {
let err = dispatch.authorise(&who, None, raw).expect_err(&format!(
"should have refused {:?}",
String::from_utf8_lossy(raw)
));
assert!(
matches!(err, DispatchError::Command(_)),
"{:?} should have failed at the parser, not later: {err}",
String::from_utf8_lossy(raw)
);
}
}
#[test]
fn an_absolute_system_path_is_refused_because_it_names_nothing() {
let (dispatch, who) = dispatch(Access::Admin);
let err = dispatch
.authorise(&who, None, b"git-upload-pack '/etc/passwd'")
.expect_err("must be refused");
match err {
DispatchError::NoSuchRepo(path) => {
assert_eq!(path, RepoRef::new("etc", "passwd").unwrap());
}
other => panic!("expected NoSuchRepo, got {other}"),
}
}
#[test]
fn upload_pack_needs_read_and_receive_pack_needs_write() {
let (read_only, who) = dispatch(Access::Read);
read_only
.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("read is enough to fetch");
let err = read_only
.authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
.expect_err("read must NOT be enough to push");
assert!(
matches!(
&err,
DispatchError::Denied { needed: Access::Write, held, .. } if held == "read"
),
"{err}"
);
let (writable, who) = dispatch(Access::Write);
writable
.authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
.expect("write is enough to push");
writable
.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("write implies read");
}
#[test]
fn a_principal_with_no_grant_at_all_is_denied() {
let plane = Plane::empty().with_the_repo().with_principal("stranger");
let dispatch = Dispatch::new(plane.control);
let err = dispatch
.authorise(
&principal("stranger"),
None,
b"git-upload-pack 'nordisk/osm-katana.git'",
)
.expect_err("no grant means no access");
assert!(
matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
"{err}"
);
}
#[test]
fn not_found_and_not_allowed_are_indistinguishable_to_the_client() {
let (dispatch, who) = dispatch(Access::Read);
let missing = dispatch
.authorise(&who, None, b"git-upload-pack 'nordisk/does-not-exist.git'")
.expect_err("no such repo");
let denied = dispatch
.authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
.expect_err("no write grant");
assert!(matches!(missing, DispatchError::NoSuchRepo(_)));
assert!(matches!(denied, DispatchError::Denied { .. }));
assert_ne!(missing.to_string(), denied.to_string());
let missing_msg = missing.client_message().expect("a git client is listening");
let denied_msg = denied.client_message().expect("a git client is listening");
assert!(missing_msg.starts_with("repository not found, or you do not have access:"));
assert!(denied_msg.starts_with("repository not found, or you do not have access:"));
let strip = |m: &str| m.split(':').next().unwrap().to_string();
assert_eq!(strip(&missing_msg), strip(&denied_msg));
}
#[test]
fn an_unreadable_catalog_is_a_fault_and_not_a_denial() {
let plane = plane_with_key(&common::mint_key(), Access::Read);
let dispatch = Dispatch::new(plane.control.clone());
let who = principal("principal-1");
dispatch
.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("served while the catalog is readable");
plane.corrupt_the_catalog();
let err = dispatch
.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
.expect_err("an unreadable catalog must not be answered at all");
assert!(
matches!(err, DispatchError::Store(_)),
"a storage fault reported as {err:?}; it must not be a Denied or a NoSuchRepo, \
or an outage becomes a silent permission denial"
);
let message = err.client_message().expect("say something");
assert!(
!message.contains("do not have access"),
"a storage fault must not be reported to the client as a permission problem: {message}"
);
}
#[test]
fn an_unreadable_identity_table_is_a_fault_and_not_a_denial() {
let plane = plane_with_key(&common::mint_key(), Access::Read);
let dispatch = Dispatch::new(plane.control.clone());
let who = principal("principal-1");
dispatch
.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("served while the table is readable");
plane.corrupt_the_auth_table();
assert!(
matches!(
dispatch.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'"),
Err(DispatchError::Store(_))
),
"an unreadable identity table must be a fault, not `you have no grant`"
);
}
#[test]
fn a_shell_gets_no_message_at_all() {
let (dispatch, who) = dispatch(Access::Admin);
let err = dispatch
.authorise(&who, None, b"bash")
.expect_err("no shell, ever");
assert!(err.client_message().is_none(), "{err}");
}
#[test]
fn lfs_download_needs_read_and_lfs_upload_needs_write() {
const DOWNLOAD: &[u8] = b"git-lfs-transfer nordisk/osm-katana.git download";
const UPLOAD: &[u8] = b"git-lfs-transfer nordisk/osm-katana.git upload";
let (read_only, who) = dispatch(Access::Read);
read_only
.authorise(&who, None, DOWNLOAD)
.expect("read is enough to download an LFS object");
let err = read_only
.authorise(&who, None, UPLOAD)
.expect_err("read must NOT be enough to upload an LFS object");
assert!(
matches!(
&err,
DispatchError::Denied { needed: Access::Write, held, .. } if held == "read"
),
"an LFS upload refused a read-only principal for the wrong reason: {err}"
);
let (writable, who) = dispatch(Access::Write);
writable
.authorise(&who, None, UPLOAD)
.expect("write is enough to upload an LFS object");
writable
.authorise(&who, None, DOWNLOAD)
.expect("write implies read for LFS too");
let plane = Plane::empty().with_the_repo().with_principal("stranger");
let ungranted = Dispatch::new(plane.control);
for line in [DOWNLOAD, UPLOAD] {
let err = ungranted
.authorise(&principal("stranger"), None, line)
.expect_err("no grant must reach no LFS operation");
assert!(
matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
"{err}"
);
}
}
#[test]
fn the_lfs_operation_named_on_the_exec_line_is_the_one_that_is_checked() {
let (read_only, who) = dispatch(Access::Read);
let allowed = read_only
.authorise(
&who,
None,
b"git-lfs-transfer nordisk/osm-katana.git download",
)
.expect("download is a read");
assert!(
matches!(
allowed.service,
GitService::LfsTransfer(gunnar_lfs::Operation::Download)
),
"the dispatch carried the wrong operation forward: {:?}",
allowed.service
);
read_only
.authorise(
&who,
None,
b"git-lfs-transfer nordisk/osm-katana.git upload",
)
.expect_err("upload must be judged as a write even though download was allowed");
}