use std::{ ffi::{OsStr, OsString}, path::{Component, Path, PathBuf}, }; use serde::{Deserialize, Serialize}; use crate::{ bare::GitBare, errors::{GitError, GitResult}, }; const DANGEROUS_SUBCOMMANDS: &[&str] = &[ "add", "apply", "archive", "bisect", "bundle", "checkout", "clean", "clone", "commit", "config", "fetch", "gc", "hook", "init", "maintenance", "merge", "mv", "notes", "pull", "push", "rebase", "remote", "repack", "replace", "reset", "restore", "rm", "send-email", "sparse-checkout", "stash", "submodule", "switch", "update-index", "update-ref", "worktree", ]; const DANGEROUS_OPTIONS: &[&str] = &[ "-C", "-c", "--exec-path", "--git-dir", "--html-path", "--man-path", "--namespace", "--paginate", "--super-prefix", "--upload-pack", "--work-tree", ]; const DANGEROUS_OPTION_PREFIXES: &[&str] = &[ "--exec-path=", "--git-dir=", "--namespace=", "--super-prefix=", "--upload-pack=", "--work-tree=", ]; const DENIED_ENV_NAMES: &[&str] = &[ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_COUNT", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_SYSTEM", "GIT_DIR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_WORK_TREE", "LD_LIBRARY_PATH", "PATH", ]; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitCommandParams { pub args: Vec, pub env: Vec<(String, String)>, pub stdin: Option>, pub check_status: bool, pub bypass_subcommand_validation: bool, } impl GitCommandParams { pub fn new(args: Vec) -> Self { Self { args, env: Vec::new(), stdin: None, check_status: true, bypass_subcommand_validation: false, } } pub fn unchecked(mut self) -> Self { self.check_status = false; self } pub fn trusted(mut self) -> Self { self.bypass_subcommand_validation = true; self } pub fn with_stdin(mut self, stdin: Vec) -> Self { self.stdin = Some(stdin); self } pub fn with_env(mut self, name: String, value: String) -> Self { self.env.push((name, value)); self } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitCommandOutput { pub status_code: Option, pub success: bool, pub stdout: Vec, pub stderr: Vec, } impl GitCommandOutput { pub fn stdout_lossy(&self) -> String { String::from_utf8_lossy(&self.stdout).into_owned() } pub fn stderr_lossy(&self) -> String { String::from_utf8_lossy(&self.stderr).into_owned() } } impl GitBare { pub fn git_command( &self, args: Vec, ) -> GitResult { self.git_command_with(GitCommandParams::new(args)) } pub fn git_command_with( &self, params: GitCommandParams, ) -> GitResult { let bare_dir = self.safe_bare_dir()?; validate_git_command(¶ms)?; let mut args = Vec::with_capacity(params.args.len() + 2); args.push(OsString::from("--git-dir")); args.push(bare_dir.as_os_str().to_os_string()); args.extend(params.args.iter().map(OsString::from)); let mut expression = duct::cmd("git", args) .dir(&bare_dir) .stdout_capture() .stderr_capture() .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_TERMINAL_PROMPT", "0") .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE") .env_remove("GIT_OBJECT_DIRECTORY") .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") .env_remove("GIT_SSH") .env_remove("GIT_SSH_COMMAND") .unchecked(); for (name, value) in ¶ms.env { expression = expression.env(name, value); } if let Some(stdin) = params.stdin { expression = expression.stdin_bytes(stdin); } let output = expression.run()?; let result = GitCommandOutput { status_code: output.status.code(), success: output.status.success(), stdout: output.stdout, stderr: output.stderr, }; if params.check_status && !result.success { return Err(GitError::CommandFailed { status_code: result.status_code, stderr: result.stderr_lossy(), }); } Ok(result) } pub fn git_command_stdout(&self, args: Vec) -> GitResult { let output = self.git_command(args)?; Ok(output.stdout_lossy()) } pub fn git_command_success(&self, args: Vec) -> GitResult { let output = self.git_command_with(GitCommandParams::new(args).unchecked())?; Ok(output.success) } pub fn git_command_trusted( &self, args: Vec, ) -> GitResult { self.git_command_with(GitCommandParams::new(args).trusted()) } pub fn git_command_trusted_unchecked( &self, args: Vec, ) -> GitResult { self.git_command_with(GitCommandParams::new(args).trusted().unchecked()) } pub fn git_command_trusted_stdout( &self, args: Vec, ) -> GitResult { let output = self.git_command_with(GitCommandParams::new(args).trusted())?; Ok(output.stdout_lossy()) } pub fn git_command_trusted_success( &self, args: Vec, ) -> GitResult { let output = self.git_command_with( GitCommandParams::new(args).trusted().unchecked(), )?; Ok(output.success) } fn safe_bare_dir(&self) -> GitResult { let bare_dir = self.bare_dir.canonicalize()?; if !bare_dir.is_dir() { return Err(GitError::NotBareRepository); } Ok(bare_dir) } } fn validate_git_command(params: &GitCommandParams) -> GitResult<()> { if params.bypass_subcommand_validation { return Ok(()); } if params.args.is_empty() { return Err(GitError::UnsafeCommand( "missing git subcommand".to_owned(), )); } let subcommand = first_subcommand(¶ms.args).ok_or_else(|| { GitError::UnsafeCommand("missing git subcommand".to_owned()) })?; if DANGEROUS_SUBCOMMANDS.contains(&subcommand.as_str()) { return Err(GitError::UnsafeCommand(format!( "subcommand `{subcommand}` is not allowed" ))); } for arg in ¶ms.args { validate_git_arg(arg)?; } for (name, value) in ¶ms.env { validate_git_env(name, value)?; } Ok(()) } fn first_subcommand(args: &[String]) -> Option { let mut iter = args.iter().peekable(); while let Some(arg) = iter.next() { if arg == "--" { return None; } if arg == "-c" || arg == "-C" || arg == "--git-dir" || arg == "--work-tree" { iter.next(); continue; } if arg.starts_with('-') { continue; } return Some(arg.to_owned()); } None } fn validate_git_arg(arg: &str) -> GitResult<()> { if DANGEROUS_OPTIONS.contains(&arg) { return Err(GitError::UnsafeCommand(format!( "option `{arg}` is not allowed" ))); } if DANGEROUS_OPTION_PREFIXES .iter() .any(|prefix| arg.starts_with(prefix)) { return Err(GitError::UnsafeCommand(format!( "option `{arg}` is not allowed" ))); } if let Some((name, value)) = arg.split_once('=') { if looks_like_path(value) { validate_relative_path(value).map_err(|_| { GitError::UnsafeCommand(format!( "path value for `{name}` escapes bare_dir" )) })?; } } else if looks_like_path(arg) { validate_relative_path(arg).map_err(|_| { GitError::UnsafeCommand(format!( "path argument `{arg}` escapes bare_dir" )) })?; } if contains_shell_metachar(arg) { return Err(GitError::UnsafeCommand(format!( "argument `{arg}` contains shell metacharacters" ))); } Ok(()) } fn validate_git_env(name: &str, value: &str) -> GitResult<()> { let upper_name = name.to_ascii_uppercase(); if DENIED_ENV_NAMES.contains(&upper_name.as_str()) || upper_name.starts_with("GIT_CONFIG_") { return Err(GitError::UnsafeCommand(format!( "environment variable `{name}` is not allowed" ))); } if looks_like_path(value) { validate_relative_path(value).map_err(|_| { GitError::UnsafeCommand(format!( "environment variable `{name}` escapes bare_dir" )) })?; } Ok(()) } fn looks_like_path(value: &str) -> bool { value.contains('/') || value.contains('\\') || value == "." || value == ".." } fn validate_relative_path(value: &str) -> Result<(), ()> { let path = Path::new(value); if path.is_absolute() { return Err(()); } for component in path.components() { match component { Component::Normal(part) if is_safe_path_part(part) => {} Component::CurDir => {} _ => return Err(()), } } Ok(()) } fn is_safe_path_part(part: &OsStr) -> bool { let Some(part) = part.to_str() else { return false; }; !part.is_empty() && part != "." && part != ".." } fn contains_shell_metachar(value: &str) -> bool { value.chars().any(|ch| { matches!(ch, ';' | '|' | '&' | '`' | '$' | '<' | '>' | '\n' | '\r') }) }