//! 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}" ))); } // Ref names must not exceed a reasonable length 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())); } // Prevent DoS via extremely long revision strings if rev.len() > 256 { return Err(GitError::InvalidArgument(format!( "revision too long (max 256 chars): {}", rev.len() ))); } // Pure hex OID — always safe if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 { return Ok(()); } // HEAD is always safe 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()); } // Validate ~N and ^N numeric suffixes to prevent DoS const MAX_ANCESTRY_DEPTH: u32 = 10000; // Check for ~N syntax 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 ))); } } } // Check for ^N syntax (not ^{tree}) if let Some(caret_pos) = rev.rfind('^') { let after_caret = &rev[caret_pos + 1..]; // Skip ^{tree} style operators 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 ))); } } } } // Strip trailing operators and validate the base ref // Only strip digits that are part of ~N or ^N patterns, not arbitrary trailing digits let mut base = rev; // Strip ^{tree}, ^{commit}, ^{object} suffixes base = base .trim_end_matches("^{tree}") .trim_end_matches("^{commit}") .trim_end_matches("^{object}"); // Strip ~N or ^N suffix if present 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() { // Pure operator like "^" — unlikely but not dangerous 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}" ))); } // Prevent modification of .git directory 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", ]; // Check each path component for component in path.split('/') { // Get filename without extension 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(), )); } // Check for wildcard patterns like "remote.*.url" 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" ))); } } // Config keys must be valid format: section.subsection.key if !key .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { return Err(GitError::InvalidArgument(format!( "config key contains invalid characters: {key}" ))); } 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(()) }