//! The git-over-SSH command grammar, parsed once for every door that serves it. //! //! A git client asks for a repository by sending one line: //! //! ```text //! git-upload-pack '/max/shop.git' //! ``` //! //! Two hosts receive that line. `mnw-cli`'s russh server is what //! `ssh.makenot.work` runs, and `mnw-admin git-auth` is what sshd's `command=` //! prefix invokes. Both parse it here, so the grammar has one implementation. //! A second parser is a source of divergence on leading whitespace, unbalanced //! quotes, repeated leading slashes, and the order of the `.git` strip against //! the `..` check: the same push accepted by one host and refused by the //! other. //! //! ## What this crate promises, and what it leaves to the caller //! //! It promises **path safety**: an accepted [`Request`] has an owner and a repo //! that are each a single, non-empty, non-traversing path segment, so //! [`Request::repo_dir`] cannot leave the root it is given. That is the property //! the fuzz target asserts, and it is the one every caller depends on before it //! joins anything. //! //! It does not decide **identity policy**. Whether `max` is a real user, whether //! a username may contain a hyphen, whether the caller may push here: all of //! that is the server's, and `Username::new` runs on top of this. The split is //! deliberate: path safety is a property of the string and belongs where the //! string is parsed, while identity is a property of the deployment. //! //! use std::path::{Path, PathBuf}; /// The three verbs a git client may ask for. /// /// Nothing else is served. The management verbs (`repo list`, `key rm`) moved to /// `mnw-cli`'s own command surface in 2026-07 and never travelled through this /// grammar. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Operation { /// `git fetch` / `git clone`, the read side. UploadPack, /// `git push`, the write side. ReceivePack, /// `git archive --remote`, read-only and rarely used. UploadArchive, } impl Operation { /// The wire spelling, which is also the binary name both doors exec. #[must_use] pub fn command(self) -> &'static str { match self { Self::UploadPack => "git-upload-pack", Self::ReceivePack => "git-receive-pack", Self::UploadArchive => "git-upload-archive", } } fn from_wire(s: &str) -> Option { match s { "git-upload-pack" => Some(Self::UploadPack), "git-receive-pack" => Some(Self::ReceivePack), "git-upload-archive" => Some(Self::UploadArchive), _ => None, } } } /// Why a command line was refused. /// /// Callers map every variant to the same client-visible answer — a git client /// learns "repository not found" and nothing more, so a probe cannot tell a /// malformed name from a private repository. The distinction exists for logs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ParseError { /// No space, so there is no path argument. NoArgument, /// The verb is not one of the three. UnsupportedOperation, /// The path carries no `/`, so it names no owner. MissingOwner, /// A segment was empty, over-long, traversing, or outside the charset. InvalidSegment, } impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { Self::NoArgument => "no repository argument", Self::UnsupportedOperation => "unsupported git operation", Self::MissingOwner => "repository path names no owner", Self::InvalidSegment => "invalid owner or repository name", }; f.write_str(s) } } impl std::error::Error for ParseError {} /// A parsed, path-safe request. /// /// Borrowed from the command line rather than owned: both callers have the line /// in hand for the whole operation, and copying two short segments per /// connection buys nothing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Request<'a> { /// Which verb was asked for. pub operation: Operation, /// The namespace owner. A single validated path segment. pub owner: &'a str, /// The repository, with any `.git` suffix already removed. A single /// validated path segment. pub repo: &'a str, } impl Request<'_> { /// The bare repository's directory under `root`. /// /// The join lives here so there is one place where an owner and a repo /// become a path, and so the fuzz target can assert about the thing callers /// actually use rather than about the segments in isolation. Both segments /// passed [`valid_segment`], so the result is always `root/owner/repo.git` /// with no way out of `root`. #[must_use] pub fn repo_dir(&self, root: impl AsRef) -> PathBuf { root.as_ref() .join(self.owner) .join(format!("{}.git", self.repo)) } /// The command to hand `git-shell -c`, rebuilt from validated parts. /// /// Rebuilt rather than forwarded: the original line is attacker-controlled /// and forwarding it is how argument injection gets in. Every byte here came /// through [`valid_segment`]. #[must_use] pub fn shell_command(&self) -> String { format!( "{} '/{}/{}.git'", self.operation.command(), self.owner, self.repo ) } } /// Longest a segment may be. Matches the server's `validate_segment` and /// `validate_git_repo_name`, both of which cap at 64. const SEGMENT_MAX: usize = 64; /// Is this string safe to use as one path component? /// /// The whole safety argument of this crate reduces to this function, so it is /// deliberately a whitelist and deliberately boring. Rejects: empty, over-long, /// anything outside `[A-Za-z0-9._-]`, any `..` anywhere, and a dot at either /// end. /// /// The dot rules do more than block `.` and `..`: /// /// - A **leading** dot is hidden on disk, and `.git` as a repository name would /// put a repository's own metadata directory name into the namespace. /// - A **trailing** dot is what the fuzz target found on its first run /// (`max/v1.2.`). It passes every other rule, and then [`Request::repo_dir`] /// and [`Request::shell_command`] append `.git` and produce `v1.2..git` — a /// name this parser itself refuses, because the `..` test in [`parse`] fires /// on it. A grammar that will not re-read its own output is one where two /// callers can disagree about what a name means, which is the class of defect /// this crate exists to remove. Trailing dots are also unrepresentable on /// Windows and collide with the undotted name on some filesystems, so nothing /// is lost by refusing them. /// /// Excluding whole classes is cheaper to defend than enumerating bad members, /// and the charset rule already excludes `/` so no segment can silently become /// two. #[must_use] pub fn valid_segment(s: &str) -> bool { if s.is_empty() || s.len() > SEGMENT_MAX { return false; } if s.starts_with('.') || s.ends_with('.') || s.contains("..") { return false; } s.bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') } /// Parse one `SSH_ORIGINAL_COMMAND` / russh `exec` line. /// /// # The four reconciled divergences /// /// Each was a real difference between the two former parsers, and each is /// settled here in the direction named: /// /// 1. **Surrounding whitespace is trimmed.** `mnw-cli` trimmed, the server did /// not. Trimming is the live door's behaviour and costs nothing. /// 2. **Quotes must balance, and only one pair is stripped.** The server used /// `trim_matches`, which strips greedily and does not care whether the quotes /// pair up, so `'/max/shop.git` (unterminated) parsed. Requiring a pair is /// `mnw-cli`'s rule and the stricter one. /// 3. **At most one leading slash is stripped.** The server stripped every /// leading slash, so `//max/shop.git` parsed. Git sends at most one. /// 4. **`..` cannot survive the `.git` strip.** The two parsers tested for `..` /// on opposite sides of the suffix strip, and this was the only class of the /// four with teeth: `mnw-cli` stripped first, so `/max/shop..git` became repo /// `shop.` and was accepted, safe only because a charset guard further in /// happened to catch what was left. /// /// There is no ordering here to get wrong, because there is one check and it /// runs last. [`valid_segment`] refuses `..` anywhere and a dot at either /// end, and those two rules together close the strip entirely: if the raw /// remainder contains `..` and the stripped name does not, the `..` must /// straddle the boundary, so the remainder ends `..git` and the stripped name /// ends with a dot. `.git` carries no `..` of its own, so there is no other /// way across. /// /// There is deliberately no separate pre-strip guard: every input one would /// reject is already rejected below, and two overlapping guards is the same /// defect as two parsers, one level down. pub fn parse(command_line: &str) -> Result, ParseError> { let (verb, rest) = command_line.split_once(' ').ok_or(ParseError::NoArgument)?; let operation = Operation::from_wire(verb).ok_or(ParseError::UnsupportedOperation)?; // (1) Whitespace, then (2) one balanced quote pair. let path = unquote(rest.trim()); // (3) One leading slash. `git` sends the path as the client wrote it after // the colon, so both `max/shop.git` and `/max/shop.git` are ordinary. let path = path.strip_prefix('/').unwrap_or(path); let (owner, rest) = path.split_once('/').ok_or(ParseError::MissingOwner)?; // `rest` may still contain `/` here; `valid_segment` is what refuses it, so // a nested path never becomes a repo name. let repo = rest.strip_suffix(".git").unwrap_or(rest); // (4) The single gate. Both segments, after the strip. if !valid_segment(owner) || !valid_segment(repo) { return Err(ParseError::InvalidSegment); } Ok(Request { operation, owner, repo, }) } /// Strip one balanced pair of surrounding quotes, single or double. /// /// Returns the input unchanged when the quotes do not pair, which is what makes /// an unterminated quote a parse failure downstream rather than a silent strip. fn unquote(s: &str) -> &str { for q in ['\'', '"'] { if let Some(inner) = s.strip_prefix(q) && let Some(inner) = inner.strip_suffix(q) { return inner; } } s } pub mod oracle { //! The crate's contract, written as an executable assertion. //! //! This is a normal public module rather than something behind a `fuzzing` //! feature, because two callers need it and neither is the fuzzer: the //! committed regression replay in `tests/regressions.rs` runs it on stable, //! and the libFuzzer target runs it on nightly. A property asserted in one //! of those and not the other is a property that drifts. //! //! Everything here is about a *parsed* request. Whether a given line ought //! to parse is the grammar's business and is covered by unit tests; what //! this says is that nothing which does parse can hurt a caller. use super::{Request, valid_segment}; use std::path::{Component, Path}; /// Panics if an accepted request violates anything this crate promises. /// /// # Panics /// /// By design. It is an oracle, and a panic is how it reports. pub fn check(request: &Request<'_>) { // 1. Both segments are segments. Everything below rests on this. assert!( valid_segment(request.owner), "accepted an invalid owner: {:?}", request.owner ); assert!( valid_segment(request.repo), "accepted an invalid repo: {:?}", request.repo ); // 2. The join cannot leave the root. Checked structurally rather than // by touching the filesystem: a fuzz target must not depend on what // happens to exist on the box, and `canonicalize` would. let root = Path::new("/srv/git"); let dir = request.repo_dir(root); assert!( dir.starts_with(root), "repo_dir escaped the root: {}", dir.display() ); let extra: Vec<_> = dir .strip_prefix(root) .expect("starts_with just held") .components() .collect(); assert_eq!( extra.len(), 2, "repo_dir added {} components, not owner + repo: {}", extra.len(), dir.display() ); for c in extra { assert!( matches!(c, Component::Normal(_)), "repo_dir grew a non-normal component: {}", dir.display() ); } // 3. The git-shell argument round-trips. This is the injection oracle: // if a name could break out of the single quotes, or introduce a // space, or otherwise re-parse as something else, the request that // comes back would differ from the one that went in. let rebuilt = request.shell_command(); match super::parse(&rebuilt) { Ok(again) => assert_eq!( &again, request, "shell_command did not round-trip: {rebuilt:?}" ), Err(e) => panic!("shell_command produced an unparsable line {rebuilt:?}: {e}"), } // 4. One quoted argument, no more. `git-shell -c` splits on whitespace // outside quotes, so a second quote pair or a stray space would be a // second argument. let arg = rebuilt .strip_prefix(request.operation.command()) .expect("rebuilt starts with the verb"); assert_eq!( arg.matches('\'').count(), 2, "shell argument is not one quoted word: {rebuilt:?}" ); assert!( arg.starts_with(" '") && arg.ends_with('\''), "shell argument is not one quoted word: {rebuilt:?}" ); } /// Parse `line` and, if it is accepted, hold it to [`check`]. /// /// The entry point both the fuzz target and the regression replay call, so /// "what the fuzzer checks" has exactly one definition. /// /// Returns whether a request was actually checked. The fuzz target ignores /// that and should: a line the grammar refuses is a fine thing to feed it. /// It exists because without a return value nothing can observe this /// function running at all: `parse` only ever yields requests the oracle /// accepts, so an empty body passes every test. A silently empty oracle is /// the one failure this whole arrangement cannot afford. pub fn check_line(line: &str) -> bool { match super::parse(line) { Ok(request) => { check(&request); true } Err(_) => false, } } } #[cfg(test)] mod tests { use super::*; fn ok(cmd: &str) -> Request<'_> { parse(cmd).expect("should parse") } // ── The shapes a real git client sends ── #[test] fn the_three_verbs() { assert_eq!( ok("git-upload-pack '/max/shop.git'").operation, Operation::UploadPack ); assert_eq!( ok("git-receive-pack '/max/shop.git'").operation, Operation::ReceivePack ); assert_eq!( ok("git-upload-archive '/max/shop.git'").operation, Operation::UploadArchive ); } /// Hyphens and underscores are in the charset. Without this, flipping an /// operator in `valid_segment` so hyphens are refused passes every other /// test here. #[test] fn the_full_charset_is_accepted() { let r = ok("git-upload-pack my_user-1/my-repo_v2.0.git"); assert_eq!((r.owner, r.repo), ("my_user-1", "my-repo_v2.0")); } #[test] fn every_parse_error_says_which_one() { // The `Display` impl reaches a git client through `anyhow`, so an // impl that returned an empty string would degrade every refusal on // the sshd door into a blank message. for (line, want) in [ ("git-upload-pack", ParseError::NoArgument), ("git-foo /max/shop.git", ParseError::UnsupportedOperation), ("git-upload-pack shop.git", ParseError::MissingOwner), ("git-upload-pack /max/../etc", ParseError::InvalidSegment), ] { let err = parse(line).expect_err("should refuse"); assert_eq!(err, want, "{line}"); assert!(!err.to_string().is_empty(), "{want:?} displays as nothing"); } // And the four are distinguishable, not one message four times. let shown = [ ParseError::NoArgument, ParseError::UnsupportedOperation, ParseError::MissingOwner, ParseError::InvalidSegment, ] .map(|e| e.to_string()); let mut uniq = shown.to_vec(); uniq.sort(); uniq.dedup(); assert_eq!(uniq.len(), 4, "ParseError messages collide: {shown:?}"); } #[test] fn quoting_and_slashes_a_client_may_send() { for cmd in [ "git-upload-pack '/max/shop.git'", "git-upload-pack \"/max/shop.git\"", "git-upload-pack /max/shop.git", "git-upload-pack max/shop.git", "git-upload-pack max/shop", ] { let r = ok(cmd); assert_eq!((r.owner, r.repo), ("max", "shop"), "{cmd}"); } } #[test] fn a_repo_may_contain_a_dot_that_is_not_at_either_end() { assert_eq!(ok("git-upload-pack max/v1.2.git").repo, "v1.2"); assert_eq!(ok("git-upload-pack max/v1.2.3").repo, "v1.2.3"); } /// `v1.2.` passes every other rule in `valid_segment`, and then `repo_dir` /// and `shell_command` append `.git` to give `v1.2..git`, which this parser /// refuses. The crate has to re-read its own output. #[test] fn a_trailing_dot_is_refused_because_the_git_suffix_would_make_it_dotdot() { assert!(parse("git-upload-pack max/v1.2.").is_err()); assert!(parse("git-upload-pack max./shop").is_err()); // The round-trip the failure showed up as. let r = ok("git-upload-pack max/v1.2.3"); assert_eq!(parse(&r.shell_command()), Ok(r)); } // ── The four reconciled divergences, one test each ── #[test] fn divergence_1_surrounding_whitespace_is_trimmed() { // Accepted by mnw-cli, refused by the server. assert_eq!(ok("git-upload-pack max/shop.git ").repo, "shop"); } #[test] fn divergence_2_unbalanced_quotes_are_refused() { // Accepted by the server's greedy `trim_matches`, refused by mnw-cli. assert!(parse("git-upload-pack '/max/shop.git").is_err()); assert!(parse("git-upload-pack /max/shop.git'").is_err()); // And only one pair comes off, so a doubled quote is now a bad segment // rather than a silently stripped one. assert!(parse("git-upload-pack ''/max/shop.git''").is_err()); } #[test] fn divergence_3_only_one_leading_slash_is_stripped() { // Accepted by the server, refused by mnw-cli. The second slash makes the // owner empty, which `valid_segment` rejects. assert!(parse("git-upload-pack //max/shop.git").is_err()); assert!(parse("git-upload-pack ///max/shop.git").is_err()); } #[test] fn divergence_4_dotdot_is_tested_before_the_git_suffix_comes_off() { // The one with teeth. mnw-cli stripped `.git` first, saw `shop.`, and // accepted it; the charset guard downstream was the only thing between // that and a name nobody meant to allow. assert!(parse("git-upload-pack /max/shop..git").is_err()); assert!(parse("git-upload-pack /max/..git").is_err()); } // ── Path safety ── #[test] fn traversal_in_either_segment() { for cmd in [ "git-upload-pack /../etc/passwd", "git-upload-pack /max/../../etc", "git-upload-pack ../max/shop.git", "git-upload-pack /max/..", ] { assert!(parse(cmd).is_err(), "{cmd} should not parse"); } } #[test] fn a_nested_path_is_not_a_repo_name() { // `rest` keeps its `/` through the `..` check; `valid_segment` is what // refuses it, and this test is what would notice if that stopped being // true. assert!(parse("git-upload-pack /max/a/b.git").is_err()); } #[test] fn leading_dot_segments() { assert!(parse("git-upload-pack /max/.hidden.git").is_err()); assert!(parse("git-upload-pack /.max/shop.git").is_err()); assert!(parse("git-upload-pack /max/.git").is_err()); } #[test] fn empty_segments() { assert!(parse("git-upload-pack /max/").is_err()); assert!(parse("git-upload-pack //shop.git").is_err()); assert!(parse("git-upload-pack /max").is_err()); } #[test] fn charset_is_a_whitelist() { for bad in [ "max/sh op", "max/sh;op", "max/sh\0op", "max/shüp", "ma x/shop", ] { assert!( parse(&format!("git-upload-pack {bad}")).is_err(), "{bad} should not parse" ); } } #[test] fn segment_length_is_capped() { let long = "a".repeat(SEGMENT_MAX + 1); assert!(parse(&format!("git-upload-pack max/{long}.git")).is_err()); let at_limit = "a".repeat(SEGMENT_MAX); assert!(parse(&format!("git-upload-pack max/{at_limit}")).is_ok()); } // ── Malformed lines ── #[test] fn a_line_with_no_argument() { assert_eq!(parse("git-upload-pack"), Err(ParseError::NoArgument)); assert_eq!(parse(""), Err(ParseError::NoArgument)); } #[test] fn a_verb_that_is_not_served() { assert_eq!( parse("git-foo /max/shop.git"), Err(ParseError::UnsupportedOperation) ); // The management verbs are not this grammar's, and never were. assert_eq!(parse("repo list"), Err(ParseError::UnsupportedOperation)); // Nor is anything a shell would enjoy. assert_eq!(parse("rm -rf /"), Err(ParseError::UnsupportedOperation)); } #[test] fn a_path_with_no_owner() { assert_eq!( parse("git-upload-pack shop.git"), Err(ParseError::MissingOwner) ); } // ── What callers do with the result ── #[test] fn repo_dir_stays_under_the_root() { let r = ok("git-receive-pack '/max/shop.git'"); assert_eq!( r.repo_dir("/var/lib/mnw/git"), PathBuf::from("/var/lib/mnw/git/max/shop.git") ); } #[test] fn shell_command_is_rebuilt_not_forwarded() { let r = ok("git-upload-pack \"max/shop\""); assert_eq!(r.shell_command(), "git-upload-pack '/max/shop.git'"); } // ── The oracle, which nothing else can prove is running ── // // `parse` only ever yields requests the oracle accepts, so every test above // passes just as well against an oracle whose body is `()`. These build the // bad requests `parse` will not, and assert the oracle rejects them. fn req(owner: &'static str, repo: &'static str) -> Request<'static> { Request { operation: Operation::UploadPack, owner, repo, } } #[test] fn the_oracle_accepts_a_good_request() { oracle::check(&req("max", "shop")); } #[test] fn the_oracle_rejects_a_traversing_owner() { assert!(std::panic::catch_unwind(|| oracle::check(&req("..", "shop"))).is_err()); } #[test] fn the_oracle_rejects_a_separator_in_a_segment() { // The shape that would put a second path component under the root. assert!(std::panic::catch_unwind(|| oracle::check(&req("max", "a/b"))).is_err()); } #[test] fn the_oracle_rejects_a_name_that_would_break_out_of_the_quotes() { // The injection shape. `shell_command` would emit // `git-upload-pack '/max/x'; rm -rf /.git'`, which is two words. assert!(std::panic::catch_unwind(|| oracle::check(&req("max", "x'; rm -rf /"))).is_err()); } #[test] fn check_line_reports_whether_it_checked() { assert!(oracle::check_line("git-upload-pack /max/shop.git")); assert!(!oracle::check_line("not a git command")); } }