use gunnar_core::PathReject;
use gunnar_ssh::command::{parse, CommandError, GitService, Operation, MAX_EXEC_LEN, SERVICES};
use gunnar_ssh::Access;
const LEGAL: &[(&str, GitService, &str, &str)] = &[
(
"git-upload-pack 'nordisk/osm-katana.git'",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
(
"git-upload-pack '/nordisk/osm-katana.git'",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
(
"git-upload-pack 'nordisk/osm-katana'",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
(
"git-upload-pack nordisk/osm-katana.git",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
(
"git-receive-pack 'nordisk/osm-katana.git'",
GitService::ReceivePack,
"nordisk",
"osm-katana",
),
(
"git-receive-pack '/nordisk/osm-katana.git'",
GitService::ReceivePack,
"nordisk",
"osm-katana",
),
(
"git-upload-pack 'team-1/my_repo.v2.git'",
GitService::UploadPack,
"team-1",
"my_repo.v2",
),
(
"git-upload-pack 'team/sub/repo.git'",
GitService::UploadPack,
"team/sub",
"repo",
),
(
"git-receive-pack '/team/sub/deeper/repo.git'",
GitService::ReceivePack,
"team/sub/deeper",
"repo",
),
(
"git-lfs-transfer /nordisk/osm-katana.git download",
GitService::LfsTransfer(Operation::Download),
"nordisk",
"osm-katana",
),
(
"git-lfs-transfer /nordisk/osm-katana.git upload",
GitService::LfsTransfer(Operation::Upload),
"nordisk",
"osm-katana",
),
(
"git-lfs-transfer nordisk/osm-katana.git upload",
GitService::LfsTransfer(Operation::Upload),
"nordisk",
"osm-katana",
),
(
"git-lfs-transfer '/nordisk/osm-katana.git' download",
GitService::LfsTransfer(Operation::Download),
"nordisk",
"osm-katana",
),
(
"git-lfs-transfer /team/sub/repo.git upload",
GitService::LfsTransfer(Operation::Upload),
"team/sub",
"repo",
),
(
"git-lfs-transfer /nordisk/osm-katana download",
GitService::LfsTransfer(Operation::Download),
"nordisk",
"osm-katana",
),
];
#[test]
fn legal_forms_are_accepted() {
for (raw, service, namespace, repo) in LEGAL {
let parsed = parse(raw.as_bytes())
.unwrap_or_else(|err| panic!("should have accepted {raw:?}, got {err}"));
assert_eq!(parsed.service, *service, "{raw:?}");
assert_eq!(parsed.repo.namespace(), *namespace, "{raw:?}");
assert_eq!(parsed.repo.name(), *repo, "{raw:?}");
}
}
#[test]
fn the_required_access_travels_with_the_command() {
assert_eq!(
parse(b"git-upload-pack 'nordisk/osm-katana.git'")
.unwrap()
.required,
Access::Read
);
assert_eq!(
parse(b"git-receive-pack 'nordisk/osm-katana.git'")
.unwrap()
.required,
Access::Write
);
assert_eq!(
parse(b"git-lfs-transfer /nordisk/osm-katana.git download")
.unwrap()
.required,
Access::Read
);
assert_eq!(
parse(b"git-lfs-transfer /nordisk/osm-katana.git upload")
.unwrap()
.required,
Access::Write
);
}
#[test]
fn the_lfs_operation_and_the_grant_it_implies_both_come_from_the_exec_line() {
let download = parse(b"git-lfs-transfer /nordisk/osm-katana.git download").unwrap();
let upload = parse(b"git-lfs-transfer /nordisk/osm-katana.git upload").unwrap();
assert_eq!(
download.service,
GitService::LfsTransfer(Operation::Download)
);
assert_eq!(upload.service, GitService::LfsTransfer(Operation::Upload));
assert_eq!(download.service.operation(), Some(Operation::Download));
assert_eq!(upload.service.operation(), Some(Operation::Upload));
assert_ne!(download.service, upload.service);
assert_ne!(download.required, upload.required);
assert_eq!(download.required, Access::Read);
assert_eq!(upload.required, Access::Write);
assert_eq!(download.repo.key(), "nordisk/osm-katana");
assert_eq!(upload.repo.key(), download.repo.key());
assert_eq!(
parse(b"git-upload-pack 'nordisk/osm-katana.git'")
.unwrap()
.service
.operation(),
None
);
}
#[test]
fn a_malformed_lfs_operation_is_refused_by_name() {
assert!(
matches!(
parse(b"git-lfs-transfer /nordisk/osm-katana.git"),
Err(CommandError::MissingOperation {
verb: "git-lfs-transfer",
..
})
),
"got {:?}",
parse(b"git-lfs-transfer /nordisk/osm-katana.git")
);
for bad in [
&b"git-lfs-transfer /nordisk/osm-katana.git UPLOAD"[..],
b"git-lfs-transfer /nordisk/osm-katana.git Download",
b"git-lfs-transfer /nordisk/osm-katana.git lock",
b"git-lfs-transfer /nordisk/osm-katana.git upload2",
b"git-lfs-transfer /nordisk/osm-katana.git ''",
] {
match parse(bad) {
Err(CommandError::UnknownOperation { verb, .. }) => {
assert_eq!(verb, "git-lfs-transfer")
}
other => panic!(
"{:?} should have been UnknownOperation, got {other:?}",
String::from_utf8_lossy(bad)
),
}
}
assert!(
matches!(
parse(b"git-lfs-transfer upload"),
Err(CommandError::MissingPath("git-lfs-transfer"))
),
"got {:?}",
parse(b"git-lfs-transfer upload")
);
assert!(matches!(
parse(b"git-lfs-transfer"),
Err(CommandError::MissingPath("git-lfs-transfer"))
));
assert!(parse(b"git-lfs-transfer /nordisk/osm-katana.git upload").is_ok());
assert!(parse(b"git-lfs-transfer /nordisk/osm-katana.git download").is_ok());
}
fn rejection_table() -> Vec<(String, &'static str)> {
let mut cases: Vec<(String, &'static str)> = vec![
("bash".into(), "a shell"),
("bash -c 'id'".into(), "a shell with arguments"),
("sh".into(), "a shell"),
("/bin/sh -i".into(), "an absolute shell path"),
("scp -t /tmp".into(), "another exec-based protocol"),
(
"git-upload-archive 'ns/repo.git'".into(),
"a git verb gunnar does not serve",
),
(
"git upload-pack 'ns/repo.git'".into(),
"the spaced spelling, which is a different verb",
),
("GIT-UPLOAD-PACK 'ns/repo.git'".into(), "case is not folded"),
(
"git-upload-pack2 'ns/repo.git'".into(),
"a verb with a suffix",
),
(
"xgit-upload-pack 'ns/repo.git'".into(),
"a verb with a prefix",
),
("".into(), "an empty request"),
("git-upload-pack".into(), "no path at all"),
("git-receive-pack".into(), "no path at all"),
("git-upload-pack ''".into(), "an empty quoted path"),
("git-upload-pack '/'".into(), "a bare root"),
("git-upload-pack '//'".into(), "a doubled root"),
(
"git-upload-pack 'repo.git'".into(),
"no namespace (multi-tenancy)",
),
(
"git-upload-pack 'a/b/c/d/e.git'".into(),
"deeper than MAX_COMPONENTS",
),
(
"git-upload-pack 'ns/repo.lock'".into(),
"git's lock-file suffix",
),
(
"git-upload-pack 'ns.lock/repo.git'".into(),
"the lock suffix on a namespace",
),
(
"git-upload-pack '../../etc/passwd'".into(),
"parent traversal",
),
(
"git-upload-pack 'ns/../../etc/shadow'".into(),
"embedded traversal",
),
("git-upload-pack 'ns/..'".into(), "a `..` component"),
(
"git-upload-pack '.git/../..'".into(),
"the spec's traversal case",
),
("git-upload-pack '~/ns/repo.git'".into(), "home-relative"),
(
"git-upload-pack '~root/ns/repo.git'".into(),
"another user's home",
),
(
"git-upload-pack 'ns/repo.git; id'".into(),
"a command separator",
),
(
"git-upload-pack 'ns/repo.git && id'".into(),
"a command chain",
),
(
"git-upload-pack 'ns/$(id).git'".into(),
"command substitution",
),
(
"git-upload-pack 'ns/`id`.git'".into(),
"backtick substitution",
),
("git-upload-pack 'ns/repo.git|nc host 1'".into(), "a pipe"),
(
"git-upload-pack 'ns/repo.git > /etc/cron.d/x'".into(),
"a redirect",
),
("git-upload-pack 'ns/*'".into(), "a glob"),
(
"git-upload-pack 'ns/repo.git".into(),
"an unterminated quote",
),
(
"git-upload-pack 'ns/repo.git' extra".into(),
"trailing bytes",
),
(
"git-upload-pack 'ns/repo.git''".into(),
"a doubled closing quote",
),
(
"git-upload-pack \"ns/repo.git\"".into(),
"double quotes, which git does not send",
),
(
"git-upload-pack 'nordisk/\u{0441}sm-katana.git'".into(),
"a Cyrillic homoglyph of an ASCII name",
),
(
"git-upload-pack 'nordisk/repo\u{202e}tig.'".into(),
"a right-to-left override",
),
(
"git-upload-pack 'nordisk/re\u{00ad}po.git'".into(),
"a soft hyphen",
),
];
cases.push((
String::from_utf8(b"git-upload-pack 'ns/repo\0.git'".to_vec()).unwrap(),
"an embedded NUL",
));
cases.push((
String::from_utf8(b"git-upload-pack 'ns/repo.git'\0bash".to_vec()).unwrap(),
"a NUL-hidden second command",
));
cases.push((
"git-upload-pack 'ns/re\npo.git'".into(),
"an embedded newline",
));
cases.push((
"git-upload-pack\t'ns/repo.git'".into(),
"a tab where the separator should be",
));
cases.push((
format!("git-upload-pack 'ns/{}.git'", "a".repeat(MAX_EXEC_LEN)),
"over the exec length limit",
));
cases.push((
format!("git-upload-pack 'ns/{}.git'", "a".repeat(200)),
"over the component length limit",
));
cases.push((
format!("{} 'ns/repo.git'", "x".repeat(MAX_EXEC_LEN)),
"an absurd verb",
));
for bad_operation in ["", "UPLOAD", "Upload", "up load", "upload extra", "lock"] {
cases.push((
format!("git-lfs-transfer /ns/repo.git {bad_operation}"),
"not an lfs operation",
));
}
cases.push((
"git-lfs-transfer /ns/repo.git".into(),
"no operation at all",
));
cases.push((
"git-lfs-transfer upload".into(),
"an operation with no path: the whole argument is one token",
));
cases.push((
"git-lfs-transfer upload".into(),
"an empty path before the operation",
));
for escape in [
"/ns/../../etc/passwd",
"~/ns/repo.git",
"//ns/repo.git",
"repo.git",
"a/b/c/d/e.git",
"ns/repo.lock",
"ns/.hidden",
"ns/-rf",
] {
cases.push((
format!("git-lfs-transfer {escape} download"),
"a path the sandbox must refuse, through the lfs verb",
));
}
cases.push((
"git-lfs-transfer /ns/re\npo.git upload".into(),
"an embedded newline in an lfs path",
));
cases.push((
String::from_utf8(b"git-lfs-transfer /ns/repo.git\0 upload".to_vec()).unwrap(),
"a NUL in an lfs path",
));
cases.push((
"git-lfs-transfer-x /ns/repo.git upload".into(),
"a verb that merely starts with the lfs verb",
));
cases
}
#[test]
fn hostile_forms_are_refused() {
for (raw, why) in rejection_table() {
assert!(
parse(raw.as_bytes()).is_err(),
"should have refused {raw:?} ({why})"
);
}
}
#[test]
fn the_parser_unquotes_and_the_sandbox_refuses_what_it_is_handed() {
assert_eq!(
parse(b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("unquoted here")
.repo
.key(),
"nordisk/osm-katana"
);
assert_eq!(
parse(b"git-upload-pack \"nordisk/osm-katana.git\"")
.expect_err("git does not send double quotes"),
CommandError::Path {
reason: Some(PathReject::ForbiddenCharacter),
message: parse(b"git-upload-pack \"nordisk/osm-katana.git\"")
.unwrap_err()
.to_string(),
}
);
}
#[test]
fn the_sandbox_reason_reaches_the_command_error() {
let cases: &[(&[u8], PathReject)] = &[
(b"git-upload-pack 'ns/../../etc/passwd'", PathReject::DotDot),
(
b"git-upload-pack '~/ns/repo.git'",
PathReject::HomeReference,
),
(b"git-upload-pack '//ns/repo.git'", PathReject::Absolute),
(b"git-upload-pack 'repo.git'", PathReject::TooFewComponents),
(
b"git-upload-pack 'a/b/c/d/e.git'",
PathReject::TooManyComponents,
),
(b"git-upload-pack 'ns/repo.lock'", PathReject::LockSuffix),
(b"git-upload-pack 'ns/.git'", PathReject::EmptyComponent),
(b"git-upload-pack 'ns/.hidden'", PathReject::DotComponent),
(b"git-upload-pack 'ns/-rf'", PathReject::LeadingDash),
];
for (raw, expected) in cases {
match parse(raw) {
Err(CommandError::Path { reason, .. }) => assert_eq!(
reason,
Some(*expected),
"{:?} was refused for the wrong reason",
String::from_utf8_lossy(raw)
),
other => panic!(
"{:?} should have been a sandbox rejection, got {other:?}",
String::from_utf8_lossy(raw)
),
}
}
}
#[test]
fn a_shell_is_refused_as_an_unknown_command_not_as_a_bad_path() {
assert!(matches!(
parse(b"bash"),
Err(CommandError::UnknownCommand(verb)) if verb == "bash"
));
assert!(matches!(
parse(b"git-upload-pack"),
Err(CommandError::MissingPath("git-upload-pack"))
));
assert!(matches!(
parse(b"git-upload-pack 'ns/repo\0'"),
Err(CommandError::ControlByte { byte: 0, .. })
));
assert!(matches!(
parse(format!("git-upload-pack '{}'", "a".repeat(MAX_EXEC_LEN)).as_bytes()),
Err(CommandError::TooLong { .. })
));
}
#[test]
fn the_rejection_table_can_go_red() {
fn naive(raw: &[u8]) -> Result<(), ()> {
let text = String::from_utf8_lossy(raw);
let (verb, rest) = text.split_once(' ').ok_or(())?;
if !SERVICES.iter().any(|spec| spec.verb == verb) {
return Err(());
}
let path = rest.trim_matches('\'');
if path.is_empty() {
return Err(());
}
Ok(())
}
let table = rejection_table();
let survivors: Vec<&str> = table
.iter()
.filter(|(raw, _)| naive(raw.as_bytes()).is_ok())
.map(|(raw, _)| raw.as_str())
.collect();
assert!(
survivors.len() >= 20,
"the rejection table barely tests anything: only {} of {} cases survive a naive parser",
survivors.len(),
table.len()
);
for (raw, _, _, _) in LEGAL {
assert!(
naive(raw.as_bytes()).is_ok(),
"the legal case {raw:?} is not what separates the two parsers"
);
}
}
fn services_block() -> (String, usize, usize) {
let path = format!("{}/src/command.rs", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).expect("the dispatch module is readable");
let lines: Vec<&str> = text.lines().collect();
let first = lines
.iter()
.position(|line| line.starts_with("pub const SERVICES"))
.expect("the dispatch table is declared as `pub const SERVICES`");
let last = first
+ lines[first..]
.iter()
.position(|line| line.trim_end() == "];")
.expect("the dispatch table is closed by `];` in column 0");
(path, first + 1, last + 1)
}
fn crate_sources() -> Vec<(String, String)> {
fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) {
for entry in std::fs::read_dir(dir).expect("the source tree is readable") {
let path = entry.expect("a readable directory entry").path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path).expect("a readable source file");
out.push((path.display().to_string(), text));
}
}
}
let mut out = Vec::new();
walk(
std::path::Path::new(&format!("{}/src", env!("CARGO_MANIFEST_DIR"))),
&mut out,
);
assert!(
out.len() > 5,
"the source walk found only {} files, so it is not looking at the crate",
out.len()
);
out
}
#[test]
fn the_dispatch_table_is_the_only_place_a_verb_is_named() {
assert_eq!(SERVICES.len(), 3);
assert_eq!(SERVICES[0].verb, "git-upload-pack");
assert_eq!(SERVICES[1].verb, "git-receive-pack");
assert_eq!(SERVICES[2].verb, "git-lfs-transfer");
assert_eq!(GitService::UploadPack.verb(), "git-upload-pack");
assert_eq!(GitService::ReceivePack.verb(), "git-receive-pack");
assert_eq!(
GitService::LfsTransfer(Operation::Download).verb(),
"git-lfs-transfer"
);
assert_eq!(
GitService::LfsTransfer(Operation::Upload).verb(),
"git-lfs-transfer"
);
let (table_path, first, last) = services_block();
for (path, text) in crate_sources() {
for (index, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or("");
for spec in SERVICES {
if !code.contains(&format!("{:?}", spec.verb)) {
continue;
}
let number = index + 1;
let in_table = path == table_path && (first..=last).contains(&number);
assert!(
in_table,
"`{}` is named at {path}:{number}, outside the dispatch table at \
{table_path}:{first}-{last}. A verb spelled twice is a verb that can be \
dispatched without the grant level its row carries.",
spec.verb
);
}
}
}
}