//! Strict parsing of the SSH `exec` request: `git-upload-pack '/owner/repo.git'`. //! //! No shell is ever involved. The accepted grammar is deliberately tiny: //! one of four service prefixes, one space, then one argument that is either //! single-quoted or a bare token with no whitespace or quoting characters. use forge_git::GitService; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ParseError { #[error("unsupported command")] UnsupportedCommand, #[error("malformed repository path")] MalformedPath, } /// Service prefixes accepted by the server. Both the hyphenated form git uses and /// the spaced form `git upload-pack` are allowed. Anything else is rejected. const PREFIXES: &[(&str, GitService)] = &[ ("git-upload-pack ", GitService::UploadPack), ("git-receive-pack ", GitService::ReceivePack), ("git upload-pack ", GitService::UploadPack), ("git receive-pack ", GitService::ReceivePack), ]; /// Returns the service and the repository path with `.git` stripped and the /// leading `/` or `~/` removed. Anything else is an error. pub fn parse_exec(cmd: &str) -> Result<(GitService, String), ParseError> { let (service, rest) = split_service(cmd)?; let raw = unquote_argument(rest)?; let path = normalize_path(raw)?; Ok((service, path)) } /// Exec prefix of the Git LFS authenticate request. pub(crate) const LFS_AUTHENTICATE_PREFIX: &str = "git-lfs-authenticate "; /// Direction of a `git-lfs-authenticate` request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LfsOperation { Download, Upload, } /// Parses `git-lfs-authenticate '/[.git]' download|upload`. The path follows the /// same rules as `parse_exec`, and the operation must be exactly `download` or `upload`. pub fn parse_lfs_authenticate(cmd: &str) -> Result<(String, LfsOperation), ParseError> { let rest = cmd .strip_prefix(LFS_AUTHENTICATE_PREFIX) .ok_or(ParseError::UnsupportedCommand)?; // The path argument ends at its closing quote, or at the first space when it is bare. let end = if let Some(quoted) = rest.strip_prefix('\'') { quoted.find('\'').ok_or(ParseError::MalformedPath)? + 2 } else { rest.find(' ').ok_or(ParseError::MalformedPath)? }; let (arg, tail) = rest.split_at(end); let operation = match tail { " download" => LfsOperation::Download, " upload" => LfsOperation::Upload, _ => return Err(ParseError::MalformedPath), }; let path = normalize_path(unquote_argument(arg)?)?; Ok((path, operation)) } fn split_service(cmd: &str) -> Result<(GitService, &str), ParseError> { PREFIXES .iter() .find_map(|(prefix, service)| cmd.strip_prefix(prefix).map(|rest| (*service, rest))) .ok_or(ParseError::UnsupportedCommand) } /// Extracts the argument. A quoted argument may contain spaces but no quotes, /// backslashes or control characters. An unquoted argument must contain none of /// whitespace, quotes, backslashes or control characters. fn unquote_argument(rest: &str) -> Result<&str, ParseError> { if let Some(quoted) = rest.strip_prefix('\'') { let inner = quoted.strip_suffix('\'').ok_or(ParseError::MalformedPath)?; if inner .chars() .any(|c| matches!(c, '\'' | '\\') || c.is_control()) { return Err(ParseError::MalformedPath); } return Ok(inner); } if rest.is_empty() || rest .chars() .any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '\\') || c.is_control()) { return Err(ParseError::MalformedPath); } Ok(rest) } /// Strips one leading `/` or `~/`, then requires exactly `owner/repo` or /// `owner/repo.git`, returning `owner/repo`. fn normalize_path(raw: &str) -> Result { let path = raw.strip_prefix('/').unwrap_or(raw); let path = path.strip_prefix("~/").unwrap_or(path); let segments: Vec<&str> = path.split('/').collect(); let [owner, repo] = segments.as_slice() else { return Err(ParseError::MalformedPath); }; for segment in [owner, repo] { if segment.is_empty() || *segment == ".." || segment.starts_with('-') || segment.starts_with('.') || !segment .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) { return Err(ParseError::MalformedPath); } } let repo = repo.strip_suffix(".git").unwrap_or(repo); if repo.is_empty() { return Err(ParseError::MalformedPath); } Ok(format!("{owner}/{repo}")) } #[cfg(test)] mod tests { use super::*; fn ok(cmd: &str) -> (GitService, String) { match parse_exec(cmd) { Ok(v) => v, Err(e) => panic!("expected {cmd:?} to parse, got {e:?}"), } } fn err(cmd: &str) -> ParseError { match parse_exec(cmd) { Ok(v) => panic!("expected {cmd:?} to be rejected, got {v:?}"), Err(e) => e, } } #[test] fn accepts_upload_and_receive_pack() { assert_eq!( ok("git-upload-pack '/alice/proj.git'"), (GitService::UploadPack, "alice/proj".into()) ); assert_eq!( ok("git-receive-pack '/alice/proj.git'"), (GitService::ReceivePack, "alice/proj".into()) ); } #[test] fn wiki_paths_keep_the_wiki_suffix() { assert_eq!( ok("git-receive-pack '/alice/proj.wiki.git'"), (GitService::ReceivePack, "alice/proj.wiki".to_owned()) ); assert_eq!( ok("git-upload-pack 'alice/proj.wiki'"), (GitService::UploadPack, "alice/proj.wiki".to_owned()) ); } #[test] fn accepts_spaced_form() { assert_eq!( ok("git upload-pack '/alice/proj'"), (GitService::UploadPack, "alice/proj".into()) ); assert_eq!( ok("git receive-pack 'alice/proj.git'"), (GitService::ReceivePack, "alice/proj".into()) ); } #[test] fn strips_leading_slash_and_tilde() { assert_eq!(ok("git-upload-pack '~/alice/proj'").1, "alice/proj"); assert_eq!(ok("git-upload-pack '/~/alice/proj'").1, "alice/proj"); assert_eq!(ok("git-upload-pack 'alice/proj'").1, "alice/proj"); // Only one leading slash is stripped. assert_eq!( err("git-upload-pack '//alice/proj'"), ParseError::MalformedPath ); } #[test] fn unquoted_path_without_spaces_is_accepted() { assert_eq!( ok("git-upload-pack /alice/proj.git"), (GitService::UploadPack, "alice/proj".into()) ); assert_eq!( ok("git-receive-pack alice/proj"), (GitService::ReceivePack, "alice/proj".into()) ); } #[test] fn unquoted_path_with_quote_or_space_is_rejected() { assert_eq!(err("git-upload-pack a/b c"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack a/b'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack a\"b/c"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack a\\b/c"), ParseError::MalformedPath); } #[test] fn rejects_option_injection() { assert_eq!(err("git-upload-pack '--help'"), ParseError::MalformedPath); assert_eq!( err("git-upload-pack '--upload-pack=touch /x'"), ParseError::MalformedPath ); assert_eq!(err("git-upload-pack '-x/y'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/-b'"), ParseError::MalformedPath); } #[test] fn rejects_shell_injection() { assert_eq!( err("git-upload-pack 'a/b'; rm -rf /"), ParseError::MalformedPath ); assert_eq!( err("git-upload-pack 'a/b' 'c/d'"), ParseError::MalformedPath ); assert_eq!(err("git-upload-pack 'a/b$(id)'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b;c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b|c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b&c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b%20c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/é'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b`id`'"), ParseError::MalformedPath); assert_eq!( err("git-upload-pack 'a/b' && id"), ParseError::MalformedPath ); } #[test] fn rejects_quote_backslash_newline_nul_and_control_chars_inside_quotes() { assert_eq!(err("git-upload-pack 'a/b\\c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b\nc'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b\rc'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b\0c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b\x07c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b\x7fc'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/b'c'"), ParseError::MalformedPath); } #[test] fn rejects_traversal() { assert_eq!(err("git-upload-pack '../x/y'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/..'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '/a/../b'"), ParseError::MalformedPath); assert_eq!( err("git-upload-pack '.hidden/repo'"), ParseError::MalformedPath ); assert_eq!( err("git-upload-pack 'owner/.git'"), ParseError::MalformedPath ); } #[test] fn rejects_wrong_segment_count() { assert_eq!(err("git-upload-pack 'a/b/c'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack 'a/'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '/a/b/'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '//'"), ParseError::MalformedPath); } #[test] fn rejects_empty_values() { assert_eq!(err("git-upload-pack ''"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '/'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '~/'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '.git'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack "), ParseError::MalformedPath); } #[test] fn rejects_unknown_commands() { assert_eq!( err("git-upload-archive '/a/b'"), ParseError::UnsupportedCommand ); assert_eq!( err("git upload-archive '/a/b'"), ParseError::UnsupportedCommand ); assert_eq!(err("ls -la"), ParseError::UnsupportedCommand); assert_eq!(err(""), ParseError::UnsupportedCommand); assert_eq!( err("git-upload-packx '/a/b'"), ParseError::UnsupportedCommand ); assert_eq!(err("git-upload-pack'/a/b'"), ParseError::UnsupportedCommand); assert_eq!(err("git-upload-pack"), ParseError::UnsupportedCommand); assert_eq!( err("GIT-UPLOAD-PACK '/a/b'"), ParseError::UnsupportedCommand ); assert_eq!( err(" git-upload-pack '/a/b'"), ParseError::UnsupportedCommand ); } #[test] fn rejects_extra_spaces_and_trailing_garbage() { assert_eq!(err("git-upload-pack '/a/b'"), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '/a/b' "), ParseError::MalformedPath); assert_eq!(err("git-upload-pack '/a/b"), ParseError::MalformedPath); } fn lfs_ok(cmd: &str) -> (String, LfsOperation) { match parse_lfs_authenticate(cmd) { Ok(v) => v, Err(e) => panic!("expected {cmd:?} to parse, got {e:?}"), } } fn lfs_err(cmd: &str) -> ParseError { match parse_lfs_authenticate(cmd) { Ok(v) => panic!("expected {cmd:?} to be rejected, got {v:?}"), Err(e) => e, } } #[test] fn lfs_accepts_quoted_and_bare_paths() { assert_eq!( lfs_ok("git-lfs-authenticate '/alice/proj.git' download"), ("alice/proj".into(), LfsOperation::Download) ); assert_eq!( lfs_ok("git-lfs-authenticate 'alice/proj' upload"), ("alice/proj".into(), LfsOperation::Upload) ); assert_eq!( lfs_ok("git-lfs-authenticate /alice/proj.git upload"), ("alice/proj".into(), LfsOperation::Upload) ); } #[test] fn lfs_rejects_bad_operation() { assert_eq!( lfs_err("git-lfs-authenticate 'a/b' push"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b' "), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b' download extra"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b'download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b' Download"), ParseError::MalformedPath ); } #[test] fn lfs_rejects_bad_paths() { assert_eq!( lfs_err("git-lfs-authenticate '--help' download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate '../x/y' upload"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b'; id download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b' 'c' download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate a/b c download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate 'a/b/c' download"), ParseError::MalformedPath ); assert_eq!( lfs_err("git-lfs-authenticate ''"), ParseError::MalformedPath ); } #[test] fn lfs_rejects_other_commands() { assert_eq!( lfs_err("git-upload-pack 'a/b' download"), ParseError::UnsupportedCommand ); assert_eq!( lfs_err("git-lfs-authenticate-x 'a/b' download"), ParseError::UnsupportedCommand ); } }