use std::collections::HashSet; use std::path::{Path, PathBuf}; use uuid::Uuid; use crate::config::SharedRepoConfig; use crate::error::{RepoError, RepoResult}; pub const DEFAULT_OBJECT_FORMAT_SHA1: &str = "sha1"; pub const DEFAULT_OBJECT_FORMAT_SHA256: &str = "sha256"; pub fn validate_repo_name(name: &str, max_len: usize) -> RepoResult { let trimmed = name.trim(); if trimmed.is_empty() { return Err(RepoError::BadRequest("repository name is empty".into())); } if trimmed.len() > max_len { return Err(RepoError::BadRequest(format!( "repository name too long (max {max_len})" ))); } if !trimmed .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { return Err(RepoError::BadRequest( "repository name must match [A-Za-z0-9._-]".into(), )); } if trimmed.starts_with('.') || trimmed.starts_with('-') { return Err(RepoError::BadRequest( "repository name may not start with '.' or '-'".into(), )); } Ok(trimmed.to_lowercase()) } pub fn validate_branch_name(name: &str, max_len: usize) -> RepoResult { let trimmed = name.trim(); if trimmed.is_empty() || trimmed.len() > max_len { return Err(RepoError::BadRequest("invalid branch name".into())); } if trimmed.starts_with('-') || trimmed.starts_with('.') { return Err(RepoError::BadRequest("invalid branch name prefix".into())); } if trimmed.contains("..") || trimmed.contains("@{") || trimmed.contains('\\') { return Err(RepoError::BadRequest("invalid branch name".into())); } Ok(trimmed.to_string()) } pub fn validate_tag_name(name: &str, max_len: usize) -> RepoResult { let trimmed = name.trim(); if trimmed.is_empty() || trimmed.len() > max_len { return Err(RepoError::BadRequest("invalid tag name".into())); } if trimmed.contains("..") || trimmed.contains(' ') { return Err(RepoError::BadRequest("invalid tag name".into())); } Ok(trimmed.to_string()) } pub fn validate_object_format(fmt: &str) -> RepoResult<&'static str> { match fmt { "sha1" => Ok(DEFAULT_OBJECT_FORMAT_SHA1), "sha256" => Ok(DEFAULT_OBJECT_FORMAT_SHA256), _ => Err(RepoError::BadRequest(format!( "unsupported object format: {fmt}" ))), } } pub fn git_bare_path(config: &SharedRepoConfig, channel_id: Uuid, repo_id: Uuid) -> PathBuf { Path::new(&config.git_storage_root) .join(channel_id.to_string()) .join(format!("{repo_id}.git")) } pub fn lfs_path(config: &SharedRepoConfig, channel_id: Uuid, repo_id: Uuid) -> PathBuf { Path::new(&config.lfs_storage_root) .join(channel_id.to_string()) .join(repo_id.to_string()) } pub fn parse_owner_repo_slug(slug: &str) -> RepoResult<(&str, &str)> { let (space, repo) = slug .split_once('/') .ok_or_else(|| RepoError::BadRequest("expected '/'".into()))?; if space.is_empty() || repo.is_empty() { return Err(RepoError::BadRequest("empty space or repo segment".into())); } Ok((space, repo)) } pub fn normalize_topic(topic: &str) -> RepoResult { let t = topic.trim().to_lowercase(); if t.is_empty() { return Err(RepoError::BadRequest("empty topic".into())); } if t.len() > 35 { return Err(RepoError::BadRequest("topic too long".into())); } if !t .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) { return Err(RepoError::BadRequest("invalid topic characters".into())); } Ok(t) } pub fn sanitize_topics(topics: I, max: usize) -> RepoResult> where I: IntoIterator, S: AsRef, { let mut seen = HashSet::new(); let mut out = Vec::new(); for t in topics { let n = normalize_topic(t.as_ref())?; if seen.insert(n.clone()) { out.push(n); } } if out.len() > max { return Err(RepoError::BadRequest(format!( "too many topics (max {max})" ))); } Ok(out) }