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)?; } // Build base path: storage_path if given, else relative_path alone 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); // Canonicalize base (parent dir likely exists) for a reliable traversal check. let base_canon = base.canonicalize().unwrap_or_else(|_| base.clone()); // Unified path validation to avoid TOCTOU race condition let canonical = match candidate.canonicalize() { Ok(canon) => { // Path exists and was canonicalized successfully canon } Err(_) => { // Path doesn't exist yet — validate via parent directory // This avoids TOCTOU by not having separate code paths let parent = candidate.parent().unwrap_or(&base); let filename = candidate.file_name().ok_or_else(|| { GitError::InvalidArgument("invalid path: missing filename".into()) })?; // Canonicalize parent (which should exist) let parent_canon = parent .canonicalize() .unwrap_or_else(|_| parent.to_path_buf()); // Construct the full path and verify it's under base let constructed = parent_canon.join(filename); // String-level check as fallback for non-existent paths 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 } }; // Final verification: canonical path must be under base 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" ))); } 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, } } }