use crate::share::errors::GitError; pub const MAX_REPO_NAME: usize = 255; pub const MAX_BRANCH_NAME: usize = 250; pub fn validate_repo_name(name: &str) -> Result { let n = name.trim(); if n.is_empty() || n.chars().count() > MAX_REPO_NAME { return Err(GitError::InvalidArgument( "repo name is empty or too long".to_string(), )); } if n == "." || n == ".." { return Err(GitError::InvalidArgument(format!( "invalid repo name `{n}`" ))); } if n.starts_with('.') || n.ends_with('.') || n.starts_with('-') || n.ends_with('-') { return Err(GitError::InvalidArgument(format!( "repo name `{n}` must not start or end with `.` or `-`" ))); } if !n .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) { return Err(GitError::InvalidArgument(format!( "repo name `{n}` contains invalid characters" ))); } Ok(n.to_string()) } pub fn validate_branch_name(name: &str) -> Result { let n = name.trim(); if n.is_empty() || n.chars().count() > MAX_BRANCH_NAME { return Err(GitError::InvalidArgument( "branch name is empty or too long".to_string(), )); } if n.starts_with('-') || n.ends_with('/') || n.ends_with(".lock") || n.contains("..") || n.contains("@{") || n.contains("//") || n.contains('\\') { return Err(GitError::InvalidArgument(format!( "invalid branch name `{n}`" ))); } if n.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(GitError::InvalidArgument(format!( "invalid branch name `{n}`" ))); } Ok(n.to_string()) } pub fn validate_revision(rev: &str) -> Result<(), GitError> { if rev.trim().is_empty() { return Err(GitError::InvalidArgument("empty revision".to_string())); } if rev.starts_with('-') { return Err(GitError::InvalidArgument(format!( "revision must not start with `-`: `{rev}`" ))); } if rev.contains(['\0', '\n', '\r']) { return Err(GitError::InvalidArgument(format!( "revision contains control characters: `{rev}`" ))); } Ok(()) } pub fn validate_qualified_ref(name: &str) -> Result<(), GitError> { let n = name.trim(); if !n.starts_with("refs/") { return Err(GitError::InvalidArgument(format!( "qualified ref must start with `refs/`: `{n}`" ))); } if n.ends_with('/') || n.ends_with(".lock") || n.contains("..") || n.contains("@{") || n.contains("//") || n.contains('\\') || n.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(GitError::InvalidArgument(format!("invalid ref name `{n}`"))); } Ok(()) } const ALLOWED_SCHEMES: &[&str] = &["https", "http", "ssh", "git"]; pub fn validate_remote_url(url: &str) -> Result<(), GitError> { let u = url.trim(); if u.is_empty() || u.starts_with('-') { return Err(GitError::InvalidArgument("invalid remote url".to_string())); } if u.contains('\0') || u.contains('\n') || u.contains('\r') { return Err(GitError::InvalidArgument( "remote url contains control characters".to_string(), )); } if !u.contains("://") { return Err(GitError::InvalidArgument(format!( "remote url `{u}` is not supported: use a full URL with scheme (https://, ssh://, git://)" ))); } let (scheme, rest) = u .split_once("://") .ok_or_else(|| GitError::InvalidArgument("invalid remote url".to_string()))?; let scheme = scheme.to_ascii_lowercase(); if !ALLOWED_SCHEMES.contains(&scheme.as_str()) { return Err(GitError::InvalidArgument(format!( "remote url scheme `{scheme}` is not allowed" ))); } let hostport = rest.split('/').next().unwrap_or_default(); let userinfo = hostport.rsplit_once('@').map(|(u, _)| u); if let Some(info) = userinfo && info.contains(':') { return Err(GitError::InvalidArgument( "remote url userinfo with password is not allowed".to_string(), )); } let host = hostport .rsplit('@') .next() .unwrap_or_default() .trim() .to_string(); let host = strip_brackets_and_port(&host); let host = host.to_ascii_lowercase(); if host.is_empty() || is_forbidden_host(&host) { return Err(GitError::InvalidArgument(format!( "remote url host `{host}` is not allowed" ))); } Ok(()) } fn strip_brackets_and_port(host: &str) -> String { if host.starts_with('[') && let Some(end) = host.find(']') { let inner = &host[1..end]; let rest = &host[end + 1..]; if !inner.is_empty() && (rest.is_empty() || rest.starts_with(':')) { return inner.to_string(); } } if host.matches(':').count() == 1 && let Some(idx) = host.rfind(':') { let (h, p) = host.split_at(idx); let port = &p[1..]; if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) { return h.to_string(); } } host.to_string() } pub fn validate_remote_name(name: &str) -> Result<(), GitError> { let n = name.trim(); if n.is_empty() || n.len() > 255 || n.starts_with('-') { return Err(GitError::InvalidArgument(format!( "invalid remote name `{n}`" ))); } if n.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(GitError::InvalidArgument(format!( "invalid remote name `{n}`" ))); } Ok(()) } pub fn validate_refspec(refspec: &str) -> Result<(), GitError> { let r = refspec.trim(); if r.is_empty() || r.len() > 1024 || r.starts_with('-') { return Err(GitError::InvalidArgument(format!("invalid refspec `{r}`"))); } if r.chars().any(|c| c.is_control()) { return Err(GitError::InvalidArgument(format!("invalid refspec `{r}`"))); } Ok(()) } fn is_forbidden_host(host: &str) -> bool { if host == "localhost" || host.ends_with(".localhost") { return true; } if host.ends_with(".local") || host.ends_with(".internal") { return true; } if let Some(mapped) = host.strip_prefix("::ffff:") && parse_ipv4(mapped).is_some() { return true; } if let Some(segments) = parse_ipv4(host) { if segments[0] == 127 || segments[0] == 10 || segments[0] == 0 || segments[0] == 169 && segments[1] == 254 || segments[0] == 192 && segments[1] == 168 || segments[0] == 172 && (16..=31).contains(&segments[1]) { return true; } } else if host == "::1" || host == "::" || host.starts_with("fe80:") || host.starts_with("fc") || host.starts_with("fd") { return true; } false } fn parse_ipv4(host: &str) -> Option<[u8; 4]> { let parts: Vec<&str> = host.split('.').collect(); if parts.len() != 4 { return None; } let mut out = [0u8; 4]; for (i, p) in parts.iter().enumerate() { let n: u16 = p.parse().ok()?; if n > 255 { return None; } out[i] = n as u8; } Some(out) }