crates/gunnar-ssh/tests/command_parsing.rs
//! The exec parser, table-driven (the ACL model).
//!
//! This is the most security-critical code in the crate, so the table is the
//! test rather than a handful of hand-written cases, and it has an
//! **acceptance** half as well as a rejection half. A parser that refuses
//! everything passes every rejection test ever written and serves nothing; that
//! is the identity-value trap, and `legal_forms_are_accepted` is what catches
//! it.
//!
//! `the_rejection_table_can_go_red` is the red half: it points the same
//! rejection table at a deliberately naive parser and asserts the table
//! *fails* against it. Without that, a table of inputs that a correct parser
//! rejects and a table of inputs that any parser rejects look identical.
//!
//! # What changed when the sandbox merged into `gunnar-core`
//!
//! The sandbox under this parser is now `gunnar_core::resolve_repo_path`, the
//! only one. Two rows of the table below had to change with it, and both are
//! marked in place:
//!
//! * `a/b/c.git` is **no longer refused**: nested namespaces are legal, and the
//! deleted `gunnar_ssh::path` was the copy that disagreed. It is replaced by
//! `a/b/c/d/e.git`, which is over `MAX_COMPONENTS`.
//! * `ns/repo.lock` is **newly refused**, merged in from the deleted copy.
//!
//! Everything about *quoting* stayed here, because git sends the quotes and the
//! sandbox must not know SSH exists (the one-sandbox rule).
use gunnar_core::PathReject;
use gunnar_ssh::command::{parse, CommandError, GitService, Operation, MAX_EXEC_LEN, SERVICES};
use gunnar_ssh::Access;
/// Every form a legitimate client sends. All of these MUST parse.
const LEGAL: &[(&str, GitService, &str, &str)] = &[
// scp-style: `git clone git@host:nordisk/osm-katana.git`
(
"git-upload-pack 'nordisk/osm-katana.git'",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
// ssh:// URL: the leading slash is git's, not the client's idea of absolute
(
"git-upload-pack '/nordisk/osm-katana.git'",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
// no `.git` suffix, GitHub-style, and what `git clone host:ns/repo` sends
(
"git-upload-pack 'nordisk/osm-katana'",
GitService::UploadPack,
"nordisk",
"osm-katana",
),
// unquoted, which some clients and every hand-typed `ssh host <cmd>` send
(
"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",
),
// names with the punctuation real repositories use
(
"git-upload-pack 'team-1/my_repo.v2.git'",
GitService::UploadPack,
"team-1",
"my_repo.v2",
),
// **A nested namespace.** The sandbox this crate used to carry
// refused this outright; `gunnar-core`'s (the one that survived) resolves
// it, and it is the more permissive of the two. It is here in the
// *acceptance* half so that reinstating the stricter rule cannot pass
// unnoticed.
(
"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 (LFS over SSH): the THREE-token, UNQUOTED shape ───
//
// MEASURED 2026-08-03 against git-lfs 3.7.1 on the reference desktop, by putting a script in
// `GIT_SSH_COMMAND` and logging its argv. The exec line that arrives is
// exactly:
//
// git-lfs-transfer /repo.git upload
//
// No quotes anywhere, a leading slash from the `ssh://` URL, and a third
// token. Every other verb in this table arrives as `git-upload-pack
// '/ns/repo.git'`, i.e. two tokens with literal quote characters *inside*
// the argv element. So this verb is the one whose parse is not the shape
// everything else was written against, and until now nothing here exercised
// it at all.
(
"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",
),
// Without the leading slash, which is what a `host:ns/repo.git` remote yields.
(
"git-lfs-transfer nordisk/osm-katana.git upload",
GitService::LfsTransfer(Operation::Upload),
"nordisk",
"osm-katana",
),
// Quoted anyway. git-lfs does not send this, but `unquote` runs on the LFS
// path by the same route as on a git path, and a client or a proxy that
// quoted it must not be refused for it.
(
"git-lfs-transfer '/nordisk/osm-katana.git' download",
GitService::LfsTransfer(Operation::Download),
"nordisk",
"osm-katana",
),
// A nested namespace, i.e. a path that itself contains the separator the
// operation is split on. `rsplit_once(' ')` is what makes this work and a
// `split_once` would put `sub/repo.git download` in the path.
(
"git-lfs-transfer /team/sub/repo.git upload",
GitService::LfsTransfer(Operation::Upload),
"team/sub",
"repo",
),
// No `.git` suffix, which git-lfs sends whenever the remote URL has none.
(
"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() {
// Asserted on the parse output, not on the table, because the dispatch
// reads it from the parse output. A table row that never reaches a
// `GitCommand` would prove nothing.
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
);
// LFS over SSH: the LFS verb's level is **not a constant**, it comes from the third
// token. Both directions are asserted, because a `Requirement::Fixed` on
// this row would satisfy either one of them alone.
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
);
}
/// **Which operation, not merely that one parsed.**
///
/// The failure this guards is silent and total: a parser that dropped the third
/// token, or that returned a fixed `Operation`, would still accept every legal
/// LFS form and still produce a `GitCommand`, and then every `upload` connection
/// would be served as a `download`, or worse, every `download` would be granted
/// Write. Both halves are asserted against the *same* path so nothing but the
/// operation differs between them.
#[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));
// The two must differ. `assert_eq!` on each alone would pass against a
// parser that returned the same value for both if the expectations were
// ever "fixed" to match it.
assert_ne!(download.service, upload.service);
assert_ne!(download.required, upload.required);
assert_eq!(download.required, Access::Read);
assert_eq!(upload.required, Access::Write);
// Same repository either way; the operation must not have been eaten out
// of the path, nor the path out of the operation.
assert_eq!(download.repo.key(), "nordisk/osm-katana");
assert_eq!(upload.repo.key(), download.repo.key());
// And the two git verbs carry no operation at all, so `operation()` is not
// simply answering `Some(_)` for everything.
assert_eq!(
parse(b"git-upload-pack 'nordisk/osm-katana.git'")
.unwrap()
.service
.operation(),
None
);
}
/// The malformed third token, refused by *name* rather than merely refused.
///
/// `MissingOperation` and `UnknownOperation` are different diagnoses and both
/// are reachable; a parser that collapsed them, or that folded either into
/// `MissingPath`, would still refuse the input and still pass
/// `hostile_forms_are_refused`.
#[test]
fn a_malformed_lfs_operation_is_refused_by_name() {
// Two tokens: the whole argument is a path, there is no operation.
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")
);
// A third token that is not an operation. Note the path is valid, so this
// cannot be passing for a path reason.
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)
),
}
}
// A trailing operation with nothing before it is a missing *path*, and it
// must not be mistaken for a missing operation: `rsplit_once` finds the
// space and hands the path half an empty string.
assert!(
matches!(
parse(b"git-lfs-transfer upload"),
Err(CommandError::MissingPath("git-lfs-transfer"))
),
"got {:?}",
parse(b"git-lfs-transfer upload")
);
// And the whole verb with nothing at all.
assert!(matches!(
parse(b"git-lfs-transfer"),
Err(CommandError::MissingPath("git-lfs-transfer"))
));
// The identity-value half: the well-formed forms these are variations of
// must still be accepted, or every assertion above would pass against a
// parser that refused the verb outright.
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());
}
/// Everything that must be refused, with why.
fn rejection_table() -> Vec<(String, &'static str)> {
let mut cases: Vec<(String, &'static str)> = vec![
// ── not a git command at all ───────────────────────────────────────
("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"),
// ── a legal verb, no usable path ───────────────────────────────────
("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)",
),
// `a/b/c.git` used to be here and is now LEGAL, a nested
// namespace. What is still refused is deeper than MAX_COMPONENTS.
(
"git-upload-pack 'a/b/c/d/e.git'".into(),
"deeper than MAX_COMPONENTS",
),
// Merged in from the deleted `gunnar_ssh::path`.
(
"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",
),
// ── traversal ──────────────────────────────────────────────────────
(
"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",
),
// ── shell metacharacters, in case anything downstream ever forgets ─
(
"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"),
// ── quoting ────────────────────────────────────────────────────────
(
"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",
),
// ── unicode ────────────────────────────────────────────────────────
(
"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",
),
];
// ── NUL and control bytes ──────────────────────────────────────────────
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",
));
// ── absurd length ──────────────────────────────────────────────────────
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",
));
// ── git-lfs-transfer (LFS over SSH) ────────────────────────────────────
//
// The verb whose argument is `<path> <operation>` rather than `<path>`.
// Every rule the two git verbs are held to has to hold here too, and the
// path half is the one at risk: it is split off by `rsplit_once(' ')` rather
// than being the whole argument, so it reaches the sandbox by a different
// route.
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",
));
// The sandbox must run on the LFS path exactly as it does on a git path.
// These are the same escapes the git verbs are tested with, sent through the
// three-token form.
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})"
);
}
}
/// **Unquoting happens here, and the sandbox never sees a quote.**
///
/// git wraps the path in single quotes and gunnar has no shell to take them
/// off, so this crate does, and `gunnar-core` then refuses a quote as the
/// forbidden character it is. Both halves are asserted, because either one
/// alone would look fine: if the parser stopped unquoting, every quoted form
/// would be refused (caught by `legal_forms_are_accepted`); if the sandbox
/// started unquoting again, the double-quoted form below would be *accepted*.
#[test]
fn the_parser_unquotes_and_the_sandbox_refuses_what_it_is_handed() {
// Single quotes come off here, so what reaches the sandbox is bare.
assert_eq!(
parse(b"git-upload-pack 'nordisk/osm-katana.git'")
.expect("unquoted here")
.repo
.key(),
"nordisk/osm-katana"
);
// Double quotes are NOT git's quoting, are passed through as literal
// characters, and the sandbox refuses them. This is what a second, weaker
// unquoter inside the sandbox would silently have accepted.
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(),
}
);
}
/// The sandbox's *reason* travels out through the parser, so a test can assert
/// which rule fired. A path refused for the wrong reason is one refactor away
/// from being accepted.
#[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),
// `.git` comes off the end before the components are validated, so
// `ns/.git` is refused as `ns/`: an empty component, not a dot
// component. Refused either way; pinned here so the *reason* cannot
// drift unnoticed.
(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() {
// The distinction matters on the wire: an unknown command gets a channel
// failure and no reply, because there is no git client on the other end to
// read one.
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 { .. })
));
}
/// Measure, never assert: the rejection table must be seen to fail against a parser that is
/// wrong in the way real parsers are wrong.
#[test]
fn the_rejection_table_can_go_red() {
/// What a naive implementation looks like: split on the first space, accept
/// anything that starts with a known verb, strip the quotes. It is not a
/// straw man; it is the shape the parser would have if the path rules and
/// the byte rules were left to "the store will not find it anyway".
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()
);
// And the acceptance half must be accepted by the naive parser too.
// Otherwise the table would be distinguishing "correct" from "broken" by
// accident of the legal cases rather than by the hostile ones.
for (raw, _, _, _) in LEGAL {
assert!(
naive(raw.as_bytes()).is_ok(),
"the legal case {raw:?} is not what separates the two parsers"
);
}
}
/// The line range of the `SERVICES` const in `src/command.rs`, 1-based and
/// inclusive.
///
/// Found by reading the source rather than written down, so it cannot go stale
/// the way a hardcoded row count does.
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)
}
/// Every `.rs` file under this crate's `src/`, as (path, contents).
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() {
// `git-lfs-transfer` (LFS over SSH) landed, and it was one row, which is what this
// test was written to make visible. It has to change when the table does,
// and that is the point: a verb that appeared anywhere else would not show
// up here at all.
assert_eq!(SERVICES.len(), 3);
assert_eq!(SERVICES[0].verb, "git-upload-pack");
assert_eq!(SERVICES[1].verb, "git-receive-pack");
// Spelled out rather than compared to `gunnar_lfs::TRANSFER_VERB`, which
// the row is built from: comparing a constant to itself would pass however
// it were spelled, and this verb is fixed by the real git-lfs client
// (`GetLFSExeAndArgs`), not by us.
assert_eq!(SERVICES[2].verb, "git-lfs-transfer");
// The other direction. `verb()` goes through `row_for`, which finds a row
// by *discriminant*, so both operations of the one LFS row must land on
// that row rather than only the representative value the table holds.
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"
);
// **And the property the name claims**, which no row count can express: a
// verb may be spelled as a string literal in the table and nowhere else in
// the crate. A second spelling somewhere else is how a verb comes to be
// dispatched without the grant level that travels with its row, which is
// the failure this whole module is shaped to prevent. It would not show up
// in any assertion above, because those only ever read the table.
//
// Doc comments name the verbs freely (the module docs quote the exec lines
// git actually sends), so only code is searched.
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
);
}
}
}
}