b7cd2b1
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<String, GitError> {
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<String, GitError> {
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)
}