//! Copyright (c) 2022-2026 GitDataAi All rights reserved. use std::path::{Path, PathBuf}; use crate::error::{GitError, GitResult}; use crate::pb::RepositoryHeader; #[derive(Debug)] pub struct GitBare { pub bare_dir: PathBuf, } impl GitBare { pub fn new(bare_dir: PathBuf) -> Self { Self { bare_dir } } pub fn gix_repo(&self) -> GitResult { tracing::debug!(repo = %self.bare_dir.display(), "opening gix repository"); gix::open(&self.bare_dir).map_err(|e| { tracing::error!(repo = %self.bare_dir.display(), error = %e, "failed to open gix repository"); GitError::Internal(format!("failed to open gix repository: {e}")) }) } pub fn from_repository_header(header: &RepositoryHeader) -> GitResult { let storage_path = header.storage_path.trim(); let relative_path = header.relative_path.trim(); let storage_name = header.storage_name.trim(); let _ = storage_name; // reserved for future sharding logic // Validate relative_path early to prevent path traversal if !relative_path.is_empty() { crate::sanitize::validate_relative_path(relative_path)?; } let base = if !storage_path.is_empty() { let p = Path::new(storage_path); if !p.is_absolute() { return Err(GitError::InvalidArgument( "storage_path must be an absolute path".into(), )); } PathBuf::from(p) } else if !relative_path.is_empty() { return Err(GitError::InvalidArgument( "relative_path requires storage_path to be set".into(), )); } else { return Err(GitError::InvalidArgument("empty repository path".into())); }; let bare_dir = if !relative_path.is_empty() && !storage_path.is_empty() { let candidate = base.join(relative_path); let base_canon = base.canonicalize().unwrap_or_else(|_| base.clone()); // Validate that relative_path itself contains no traversal patterns // before any filesystem access (mitigates TOCTOU) if relative_path.contains("..") { return Err(GitError::InvalidArgument(format!( "path traversal detected: relative_path contains '..': {relative_path}" ))); } // Reject symlinks in relative_path components if relative_path.contains('\0') { return Err(GitError::InvalidArgument( "relative_path contains null byte".into(), )); } let canonical = match candidate.canonicalize() { Ok(canon) => canon, Err(_) => { // Path doesn't exist yet; validate via parent let parent = candidate.parent().unwrap_or(&base); let filename = candidate.file_name().ok_or_else(|| { GitError::InvalidArgument("invalid path: missing filename".into()) })?; let parent_canon = parent .canonicalize() .unwrap_or_else(|_| parent.to_path_buf()); let constructed = parent_canon.join(filename); let constructed_str = constructed.to_string_lossy(); let base_str = base_canon.to_string_lossy(); if !constructed_str.starts_with(&*base_str) { tracing::warn!( relative_path = %relative_path, base = %base_canon.display(), "path traversal attempt detected (parent check)" ); return Err(GitError::InvalidArgument(format!( "path traversal detected: {relative_path} escapes storage root" ))); } constructed } }; if !canonical.starts_with(&base_canon) { tracing::warn!( relative_path = %relative_path, canonical = %canonical.display(), base = %base_canon.display(), "path traversal attempt detected" ); return Err(GitError::InvalidArgument(format!( "path traversal detected: {relative_path} escapes storage root" ))); } // Verify the resolved path has no symlinks in its components // by checking that canonicalization is idempotent let double_canon = canonical.canonicalize().unwrap_or_else(|_| canonical.clone()); if canonical != double_canon { return Err(GitError::InvalidArgument( "path resolved to different target (possible symlink race)".into(), )); } canonical } else if !storage_path.is_empty() { base.canonicalize().unwrap_or(base) } else { return Err(GitError::InvalidArgument("empty repository path".into())); }; if !bare_dir.exists() { tracing::warn!(path = %bare_dir.display(), "repository not found"); return Err(GitError::RepoNotFound); } if !bare_dir.is_dir() { return Err(GitError::InvalidArgument(format!( "not a directory: {}", bare_dir.display() ))); } let head_path = bare_dir.join("HEAD"); if !head_path.exists() { let git_dir = bare_dir.join(".git"); if git_dir.is_dir() && git_dir.join("HEAD").exists() { tracing::debug!(path = %git_dir.display(), "resolved non-bare repo via .git subdir"); return Ok(Self { bare_dir: git_dir }); } return Err(GitError::NotBareRepository); } Ok(Self { bare_dir }) } pub fn object_format(&self) -> crate::pb::ObjectFormat { let repo = self.gix_repo().ok(); let kind = repo .as_ref() .map(|r| r.object_hash()) .unwrap_or(gix::hash::Kind::Sha1); match kind { gix::hash::Kind::Sha1 => crate::pb::ObjectFormat::Sha1, gix::hash::Kind::Sha256 => crate::pb::ObjectFormat::Sha256, _ => crate::pb::ObjectFormat::Unspecified, } } /// Convert a hex object id to a protobuf Oid. /// /// `Oid.value` is the binary hash bytes, while `Oid.hex` keeps the printable /// lowercase representation for clients that prefer string IDs. pub fn oid_to_pb(&self, hex: impl Into) -> crate::pb::Oid { let hex = hex.into().to_lowercase(); let format = self.object_format(); crate::pb::Oid { value: crate::oid::hex_to_bytes(&hex).unwrap_or_default(), hex, format: format as i32, } } }