// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Parsing the SSH `exec` command line. //! //! Only the three git pack commands are accepted, each with exactly one quoted //! path argument. Anything else — including an interactive shell — is refused with //! a friendly banner by the caller. use git::pack::Service; /// A parsed, accepted git command: the service and its (raw, un-normalized) path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitCommand { /// The git service requested. pub service: Service, /// The repository path argument, as given (still needs normalizing). pub path: String, } /// Parse an exec command line into an accepted [`GitCommand`], or `None` if it is /// not one of the three git pack commands with a single path argument. #[must_use] pub fn parse(command: &str) -> Option { let words = shell_split(command)?; let (name, rest) = words.split_first()?; if rest.len() != 1 { return None; } let service = match name.as_str() { "git-upload-pack" => Service::UploadPack, "git-receive-pack" => Service::ReceivePack, "git-upload-archive" => Service::UploadArchive, _ => return None, }; Some(GitCommand { service, path: rest[0].clone(), }) } /// A parsed `git-lfs-authenticate ` command: the git-lfs client /// runs this over SSH to obtain an HTTP endpoint and bearer token for LFS transfer. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LfsAuth { /// The repository path argument (still needs normalizing). pub path: String, /// The operation: `upload` or `download`. pub operation: String, } /// Parse a `git-lfs-authenticate ` command line, or `None`. #[must_use] pub fn parse_lfs(command: &str) -> Option { let words = shell_split(command)?; let (name, rest) = words.split_first()?; if name != "git-lfs-authenticate" || rest.len() != 2 { return None; } if rest[1] != "upload" && rest[1] != "download" { return None; } Some(LfsAuth { path: rest[0].clone(), operation: rest[1].clone(), }) } /// Split a command line into words, honouring single and double quotes (git sends /// the path single-quoted). Returns `None` on an unterminated quote. fn shell_split(input: &str) -> Option> { let mut words = Vec::new(); let mut current = String::new(); let mut has_word = false; let mut chars = input.chars().peekable(); while let Some(c) = chars.next() { match c { ' ' | '\t' => { if has_word { words.push(std::mem::take(&mut current)); has_word = false; } } '\'' | '"' => { has_word = true; let quote = c; loop { match chars.next() { Some(q) if q == quote => break, Some(other) => current.push(other), None => return None, // unterminated quote } } } other => { has_word = true; current.push(other); } } } if has_word { words.push(current); } Some(words) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_the_three_git_commands() { assert_eq!( parse("git-upload-pack 'owner/repo.git'"), Some(GitCommand { service: Service::UploadPack, path: "owner/repo.git".to_string() }) ); assert_eq!( parse("git-receive-pack '/owner/repo'").map(|c| c.service), Some(Service::ReceivePack) ); assert_eq!( parse("git-upload-archive 'x'").map(|c| c.service), Some(Service::UploadArchive) ); } #[test] fn rejects_shells_and_extra_args() { assert!(parse("bash").is_none()); assert!(parse("").is_none()); assert!(parse("git-upload-pack").is_none()); assert!(parse("git-upload-pack a b").is_none()); assert!(parse("scp -t /tmp").is_none()); assert!(parse("git-upload-pack 'unterminated").is_none()); } }