//! Input sanitization for git subprocess arguments. //! //! Prevents command injection by validating user-supplied strings before //! passing them to git commands. use crate::error::GitError; use crate::error::GitResult; /// Characters that are never allowed in git ref names / revision strings. const FORBIDDEN_REF_CHARS: &[char] = &[ '~', '^', ':', '?', '*', '[', '\\', ' ', '\n', '\r', '\t', '\0', ]; /// Validate a git reference name (branch, tag, etc.). /// /// Git ref rules (from `git check-ref-format`): /// - Cannot contain forbidden chars /// - Cannot start or end with '.' /// - Cannot end with '/' /// - Cannot contain '..' /// - Cannot contain '@{' /// - Cannot be empty pub fn validate_ref_name(name: &str) -> GitResult<()> { if name.is_empty() { return Err(GitError::InvalidArgument("ref name cannot be empty".into())); } if name.starts_with('.') || name.ends_with('.') { return Err(GitError::InvalidArgument(format!( "ref name cannot start or end with '.': {name}" ))); } if name.ends_with('/') { return Err(GitError::InvalidArgument(format!( "ref name cannot end with '/': {name}" ))); } if name.contains("..") { return Err(GitError::InvalidArgument(format!( "ref name cannot contain '..': {name}" ))); } if name.contains("@{") { return Err(GitError::InvalidArgument(format!( "ref name cannot contain '@{{': {name}" ))); } if name.contains(|c: char| FORBIDDEN_REF_CHARS.contains(&c)) { return Err(GitError::InvalidArgument(format!( "ref name contains forbidden character: {name}" ))); } if name.len() > 255 { return Err(GitError::InvalidArgument(format!( "ref name too long (max 255 chars): {name}" ))); } Ok(()) } /// Validate a revision string (branch name, tag name, or short expression). /// /// Allows OID hex strings, ref names, and a small set of revision operators /// (HEAD, ^{tree}, ~N, ^N) that are safe when passed as a single argument. pub fn validate_revision(rev: &str) -> GitResult<()> { if rev.is_empty() { return Err(GitError::InvalidArgument("revision cannot be empty".into())); } if rev.len() > 256 { return Err(GitError::InvalidArgument(format!( "revision too long (max 256 chars): {}", rev.len() ))); } if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 { return Ok(()); } if rev == "HEAD" { return Ok(()); } // Allow ref:refs/heads/... (git internal format) if let Some(rest) = rev.strip_prefix("ref:") { return validate_ref_name(rest.trim()); } const MAX_ANCESTRY_DEPTH: u32 = 10000; if let Some(tilde_pos) = rev.rfind('~') { let num_part = &rev[tilde_pos + 1..]; if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { let depth: u32 = num_part .parse() .map_err(|_| GitError::InvalidArgument("invalid ~N syntax".into()))?; if depth > MAX_ANCESTRY_DEPTH { return Err(GitError::InvalidArgument(format!( "~N depth too large: {} (max {})", depth, MAX_ANCESTRY_DEPTH ))); } } } if let Some(caret_pos) = rev.rfind('^') { let after_caret = &rev[caret_pos + 1..]; if !after_caret.starts_with('{') && !after_caret.is_empty() && let Some(first_char) = after_caret.chars().next() && first_char.is_ascii_digit() { let num_part: String = after_caret .chars() .take_while(|c| c.is_ascii_digit()) .collect(); if !num_part.is_empty() { let depth: u32 = num_part .parse() .map_err(|_| GitError::InvalidArgument("invalid ^N syntax".into()))?; if depth > MAX_ANCESTRY_DEPTH { return Err(GitError::InvalidArgument(format!( "^N depth too large: {} (max {})", depth, MAX_ANCESTRY_DEPTH ))); } } } } let mut base = rev; base = base .trim_end_matches("^{tree}") .trim_end_matches("^{commit}") .trim_end_matches("^{object}"); if let Some(tilde_pos) = base.rfind('~') { let after_tilde = &base[tilde_pos + 1..]; if !after_tilde.is_empty() && after_tilde.chars().all(|c| c.is_ascii_digit()) { base = &base[..tilde_pos]; } } else if let Some(caret_pos) = base.rfind('^') { let after_caret = &base[caret_pos + 1..]; if !after_caret.starts_with('{') && !after_caret.is_empty() && after_caret.chars().all(|c| c.is_ascii_digit()) { base = &base[..caret_pos]; } } if base.is_empty() { return Ok(()); } validate_ref_name(base)?; Ok(()) } /// Validate a file path within a commit action. /// /// Must be a relative path (no leading '/'), no '..' traversal, /// no null bytes, no .git directory access, and reasonable length. pub fn validate_file_path(path: &str) -> GitResult<()> { if path.is_empty() { return Err(GitError::InvalidArgument( "file path cannot be empty".into(), )); } if path.starts_with('/') { return Err(GitError::InvalidArgument(format!( "file path must be relative, not absolute: {path}" ))); } if path.contains("..") { return Err(GitError::InvalidArgument(format!( "file path cannot contain '..': {path}" ))); } if path.contains('\0') { return Err(GitError::InvalidArgument(format!( "file path cannot contain null byte: {path}" ))); } if path.len() > 4096 { return Err(GitError::InvalidArgument(format!( "file path too long (max 4096 chars): {path}" ))); } if path == ".git" || path.starts_with(".git/") || path.contains("/.git/") || path.ends_with("/.git") { return Err(GitError::InvalidArgument(format!( "cannot modify .git directory: {path}" ))); } // Windows reserved names check #[cfg(target_os = "windows")] { const RESERVED_NAMES: &[&str] = &[ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; for component in path.split('/') { let name_part = component.split('.').next().unwrap_or(component); let name_upper = name_part.to_uppercase(); if RESERVED_NAMES.contains(&name_upper.as_str()) { return Err(GitError::InvalidArgument(format!( "Windows reserved device name: {component}" ))); } } } Ok(()) } /// Git config keys that are dangerous to set remotely. /// Setting these could allow arbitrary command execution or bypass security. const DANGEROUS_CONFIG_KEYS: &[&str] = &[ "core.sshCommand", "core.gitProxy", "http.proxy", "https.proxy", "remote.*.url", "credential.*", "safe.directory", "core.hooksPath", "receive.fsckObjects", "receive.denyCurrentBranch", "receive.denyDeleteCurrent", ]; /// Check if a git config key is safe to set remotely. pub fn validate_config_key(key: &str) -> GitResult<()> { if key.is_empty() { return Err(GitError::InvalidArgument( "config key cannot be empty".into(), )); } for pattern in DANGEROUS_CONFIG_KEYS { if pattern.contains('*') { // e.g. "remote.*.url" — match any "remote..url" let (prefix, suffix) = pattern.split_once('*').unwrap(); if key.starts_with(prefix) && key.ends_with(suffix) { return Err(GitError::InvalidArgument(format!( "config key '{key}' matches dangerous pattern '{pattern}'" ))); } } else if key == *pattern { return Err(GitError::InvalidArgument(format!( "config key '{key}' is not allowed to be set remotely" ))); } } if !key .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { return Err(GitError::InvalidArgument(format!( "config key contains invalid characters: {key}" ))); } Ok(()) } /// Allowed URL schemes for git remotes. const ALLOWED_REMOTE_SCHEMES: &[&str] = &[ "http://", "https://", "ssh://", "git://", "git+ssh://", ]; /// Validate a remote URL for git operations. /// /// Only allows standard transport protocols. Rejects `file://`, `ext::`, /// and other schemes that could access local resources or execute commands. pub fn validate_remote_url(url: &str) -> GitResult<()> { if url.is_empty() { return Err(GitError::InvalidArgument( "remote URL cannot be empty".into(), )); } if url.len() > 4096 { return Err(GitError::InvalidArgument( "remote URL too long (max 4096 chars)".into(), )); } if url.contains('\0') || url.contains('\n') || url.contains('\r') { return Err(GitError::InvalidArgument( "remote URL contains invalid characters".into(), )); } if !ALLOWED_REMOTE_SCHEMES.iter().any(|s| url.starts_with(s)) { return Err(GitError::InvalidArgument(format!( "remote URL must start with one of: {}. Got: {url}", ALLOWED_REMOTE_SCHEMES.join(", ") ))); } Ok(()) } /// Validate a git refspec string (e.g. `+refs/heads/*:refs/heads/*`). /// /// Refspecs must not contain null bytes, newlines, or shell metacharacters. pub fn validate_refspec(refspec: &str) -> GitResult<()> { if refspec.is_empty() { return Err(GitError::InvalidArgument( "refspec cannot be empty".into(), )); } if refspec.contains('\0') || refspec.contains('\n') || refspec.contains('\r') { return Err(GitError::InvalidArgument( "refspec contains invalid characters".into(), )); } if refspec.contains(|c: char| matches!(c, '$' | '`' | '(' | ')' | '{' | '}' | '|' | ';' | '&' | '<' | '>')) { return Err(GitError::InvalidArgument(format!( "refspec contains shell metacharacter: {refspec}" ))); } if refspec.len() > 1024 { return Err(GitError::InvalidArgument( "refspec too long (max 1024 chars)".into(), )); } Ok(()) } /// Validate a storage-relative path (used in resolve_for_init and from_repository_header). /// /// Must not contain path traversal, must be a simple relative path. pub fn validate_relative_path(path: &str) -> GitResult<()> { if path.is_empty() { return Err(GitError::InvalidArgument( "relative_path cannot be empty".into(), )); } if path.starts_with('/') { return Err(GitError::InvalidArgument( "relative_path must be relative, not absolute".into(), )); } if path.contains("..") { return Err(GitError::InvalidArgument(format!( "path traversal detected: relative_path contains '..': {path}" ))); } Ok(()) }