b7b40ce
use std::path::PathBuf;
pub const MAX_REPO_PATH_LEN: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitService {
UploadPack,
ReceivePack,
UploadArchive,
}
impl GitService {
pub fn as_subcommand(&self) -> &'static str {
match self {
Self::UploadPack => "upload-pack",
Self::ReceivePack => "receive-pack",
Self::UploadArchive => "upload-archive",
}
}
pub fn is_write(&self) -> bool {
matches!(self, Self::ReceivePack)
}
}
impl std::str::FromStr for GitService {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"upload-pack" => Ok(Self::UploadPack),
"receive-pack" => Ok(Self::ReceivePack),
"upload-archive" => Ok(Self::UploadArchive),
_ => Err(()),
}
}
}
/// Parse a git-over-ssh exec command like `git-upload-pack 'acme/widget.git'`.
/// Returns the service and the raw (single-quoted) path.
pub fn parse_git_command(cmd: &str) -> Option<(GitService, &str)> {
let (bin, path) = cmd.split_once(' ')?;
let service = match bin {
"git-upload-pack" => GitService::UploadPack,
"git-receive-pack" => GitService::ReceivePack,
"git-upload-archive" => GitService::UploadArchive,
_ => return None,
};
Some((service, path))
}
fn strip_apostrophes(s: &str) -> &str {
s.trim().trim_matches('\'')
}
/// Parse a repository path into (owner, repo). Both segments must be non-empty.
/// Enforces a strict character gate to block path traversal and shell metacharacters.
pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
let path = strip_apostrophes(path).trim_matches('/');
if path.is_empty() || path.len() > MAX_REPO_PATH_LEN {
return None;
}
for b in path.bytes() {
let ok =
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_' | b'/');
if !ok {
return None;
}
}
let path = path.trim_end_matches(".git").trim_end_matches('/');
if path.is_empty() {
return None;
}
let (owner, repo) = path.split_once('/')?;
if owner.is_empty() || repo.is_empty() {
return None;
}
if owner == "." || owner == ".." || repo.starts_with("..") || repo.contains("/../") {
return None;
}
Some((owner, repo))
}
/// Sanitize untrusted input for safe logging.
pub fn sanitize_for_log(input: &str) -> String {
let mut out = String::with_capacity(input.len().min(256));
for &b in input.as_bytes().iter().take(256) {
match b {
0x00..=0x1f | 0x7f => out.push('?'),
0x80..=0x9f => out.push('?'),
_ => out.push(b as char),
}
}
out
}
/// Build the git subprocess command for a bare repository dir.
pub fn build_git_command(service: GitService, bare: &PathBuf) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("git");
cmd.current_dir(bare)
.arg(service.as_subcommand())
.arg(".")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1");
#[cfg(unix)]
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null");
cmd
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_commands() {
assert_eq!(
parse_git_command("git-upload-pack 'acme/widget.git'"),
Some((GitService::UploadPack, "'acme/widget.git'"))
);
assert_eq!(
parse_git_command("git-receive-pack 'acme/widget.git'"),
Some((GitService::ReceivePack, "'acme/widget.git'"))
);
assert_eq!(
parse_git_command("git-upload-archive 'acme/widget.git'"),
Some((GitService::UploadArchive, "'acme/widget.git'"))
);
}
#[test]
fn rejects_invalid_commands() {
assert_eq!(parse_git_command(""), None);
assert_eq!(parse_git_command("ls -la"), None);
assert_eq!(parse_git_command("git-upload-pack"), None);
assert_eq!(parse_git_command("git-upload-packx 'a/b.git'"), None);
assert_eq!(parse_git_command("rm -rf /"), None);
assert_eq!(parse_git_command("git-upload-pack; rm -rf /"), None);
assert_eq!(
parse_git_command("GIT_SSH=1 git-upload-pack 'a/b.git'"),
None
);
}
#[test]
fn parses_valid_paths() {
assert_eq!(parse_repo_path("acme/widget.git"), Some(("acme", "widget")));
assert_eq!(parse_repo_path("acme/widget"), Some(("acme", "widget")));
assert_eq!(
parse_repo_path("'acme/widget.git'"),
Some(("acme", "widget"))
);
assert_eq!(
parse_repo_path("/acme/widget.git/"),
Some(("acme", "widget"))
);
assert_eq!(parse_repo_path("a-b/c_d.git"), Some(("a-b", "c_d")));
}
#[test]
fn rejects_invalid_paths() {
assert_eq!(parse_repo_path(""), None);
assert_eq!(parse_repo_path("."), None);
assert_eq!(parse_repo_path(".."), None);
assert_eq!(parse_repo_path("../etc/passwd"), None);
assert_eq!(parse_repo_path("a/.."), None);
assert_eq!(parse_repo_path("a/b/c"), Some(("a", "b/c")));
assert_eq!(parse_repo_path("Acme/Widget"), None);
assert_eq!(parse_repo_path("a b/c"), None);
assert_eq!(parse_repo_path("a/b; rm -rf"), None);
assert_eq!(parse_repo_path("a$(id)/c"), None);
assert_eq!(parse_repo_path("a/"), None);
assert_eq!(parse_repo_path("/a"), None);
assert_eq!(parse_repo_path("a/../c"), None);
assert_eq!(parse_repo_path("a\u{0}/c"), None);
assert_eq!(
parse_repo_path(&format!("a/{}", "b".repeat(MAX_REPO_PATH_LEN))),
None
);
}
#[test]
fn sanitizes_log_input() {
assert_eq!(sanitize_for_log("hello"), "hello");
assert_eq!(sanitize_for_log("a\u{0}b"), "a?b");
assert_eq!(sanitize_for_log("a\nb"), "a?b");
assert_eq!(sanitize_for_log(&"x".repeat(1000)).len(), 256);
}
}