gitdataai/lib/git/cmd/command.rs
2026-05-30 01:38:40 +08:00

421 lines
10 KiB
Rust

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<String>,
pub env: Vec<(String, String)>,
pub stdin: Option<Vec<u8>>,
pub check_status: bool,
pub bypass_subcommand_validation: bool,
}
impl GitCommandParams {
pub fn new(args: Vec<String>) -> 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<u8>) -> 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<i32>,
pub success: bool,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
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<String>,
) -> GitResult<GitCommandOutput> {
self.git_command_with(GitCommandParams::new(args))
}
pub fn git_command_with(
&self,
params: GitCommandParams,
) -> GitResult<GitCommandOutput> {
let bare_dir = self.safe_bare_dir()?;
validate_git_command(&params)?;
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 &params.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<String>) -> GitResult<String> {
let output = self.git_command(args)?;
Ok(output.stdout_lossy())
}
pub fn git_command_success(&self, args: Vec<String>) -> GitResult<bool> {
let output =
self.git_command_with(GitCommandParams::new(args).unchecked())?;
Ok(output.success)
}
pub fn git_command_trusted(
&self,
args: Vec<String>,
) -> GitResult<GitCommandOutput> {
self.git_command_with(GitCommandParams::new(args).trusted())
}
pub fn git_command_trusted_unchecked(
&self,
args: Vec<String>,
) -> GitResult<GitCommandOutput> {
self.git_command_with(GitCommandParams::new(args).trusted().unchecked())
}
pub fn git_command_trusted_stdout(
&self,
args: Vec<String>,
) -> GitResult<String> {
let output =
self.git_command_with(GitCommandParams::new(args).trusted())?;
Ok(output.stdout_lossy())
}
pub fn git_command_trusted_success(
&self,
args: Vec<String>,
) -> GitResult<bool> {
let output = self.git_command_with(
GitCommandParams::new(args).trusted().unchecked(),
)?;
Ok(output.success)
}
fn safe_bare_dir(&self) -> GitResult<PathBuf> {
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(&params.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 &params.args {
validate_git_arg(arg)?;
}
for (name, value) in &params.env {
validate_git_env(name, value)?;
}
Ok(())
}
fn first_subcommand(args: &[String]) -> Option<String> {
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')
})
}