Files
gitks/server/mod.rs
T
zhenyi 934858bebf refactor(cache): redesign cache system with structured keys and improved performance
- Add repo_path parameter to cached_response and cached_vec_response functions
- Implement structured cache key format with namespace, repo_path, and request proto
- Replace global cache with Moka in-memory cache using weight-based eviction
- Set 256MB memory cap with 10-minute TTL and 2-minute TTI policy
- Add metrics collection for cache operations and evictions
- Implement efficient repo-scoped invalidation using key structure
- Add detailed documentation comments explaining cache architecture
- Remove outdated dependencies and update dependency versions
- Add error handling for encoding failures in cache operations
- Optimize Vec responses with length-delimited encoding and pre-allocation
2026-06-12 12:53:23 +08:00

463 lines
16 KiB
Rust

/// Single-machine mode: no cluster forwarding.
macro_rules! remote_client {
($fn_name:ident, $client:ty, $svc_label:literal) => {
async fn $fn_name(
_svc: &super::GitksService,
_header: Option<&crate::pb::RepositoryHeader>,
_is_write: bool,
) -> Result<Option<$client>, tonic::Status> {
Ok(None)
}
};
}
mod archive;
mod blame;
mod branch;
mod cache;
mod commit;
mod diff;
mod merge;
mod pack;
mod refs;
mod remote;
mod repository;
mod repository_maint;
mod tag;
mod tree;
use gix::discover::is_git;
use std::path::{Path, PathBuf};
use tokio_stream::wrappers::ReceiverStream;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{
archive_service_server, blame_service_server, branch_service_server, commit_service_server,
diff_service_server, merge_service_server, pack_service_server, ref_service_server,
remote_service_server, repository_service_server, tag_service_server, tree_service_server,
};
#[derive(Clone)]
pub struct GitksService {
pub repo_prefix: PathBuf,
pub grpc_addr: String,
pub disk_cache: Option<crate::disk_cache::DiskCache>,
pub pack_cache: Option<crate::pack_cache::PackCache>,
pub hook_manager: Option<crate::hooks::HookManager>,
}
impl GitksService {
pub fn new(repo_prefix: PathBuf) -> Self {
Self {
repo_prefix,
grpc_addr: String::new(),
disk_cache: None,
pack_cache: None,
hook_manager: None,
}
}
pub fn with_disk_cache(mut self, dc: crate::disk_cache::DiskCache) -> Self {
self.disk_cache = Some(dc);
self
}
pub fn with_pack_cache(mut self, pc: crate::pack_cache::PackCache) -> Self {
self.pack_cache = Some(pc);
self
}
pub fn with_hook_manager(mut self, hm: crate::hooks::HookManager) -> Self {
self.hook_manager = Some(hm);
self
}
pub fn with_grpc_addr(mut self, grpc_addr: String) -> Self {
self.grpc_addr = grpc_addr;
self
}
pub fn scan_all_repo(&self) -> GitResult<Vec<String>> {
let root = self.repo_prefix.as_ref();
let mut repos = Vec::new();
if is_bare_git_repo(root) {
repos.push(root.to_path_buf());
} else {
scan_bare_repos_recursively(root, &mut repos)?;
}
Ok(repos
.into_iter()
.filter_map(|path| path.to_str().map(str::to_owned))
.collect())
}
fn repo_label(&self, header: Option<&crate::pb::RepositoryHeader>) -> String {
header
.and_then(|h| {
if h.relative_path.is_empty() {
None
} else {
Some(h.relative_path.clone())
}
})
.unwrap_or_else(|| "unknown".into())
}
/// Get the relative path from a repository header, if any.
pub(crate) fn repo_relative_path<'a>(
&self,
header: Option<&'a crate::pb::RepositoryHeader>,
) -> Option<&'a str> {
header.and_then(|h| {
if h.relative_path.is_empty() {
None
} else {
Some(h.relative_path.as_str())
}
})
}
/// Acquire a rate-limit permit for the repository in this request.
/// Returns a guard that releases the permit on drop.
pub(crate) async fn acquire_rate_limit(
&self,
header: Option<&crate::pb::RepositoryHeader>,
) -> Result<Option<crate::rate_limit::RateLimitGuard>, tonic::Status> {
crate::rate_limit::acquire_or_reject(self.repo_relative_path(header)).await
}
pub(crate) fn resolve(
&self,
header: Option<&crate::pb::RepositoryHeader>,
) -> Result<GitBare, tonic::Status> {
let header =
header.ok_or_else(|| tonic::Status::invalid_argument("repository is required"))?;
let header = self.prefixed_header(header);
let gb = GitBare::from_repository_header(&header).map_err(into_status)?;
tracing::debug!(
repo = %gb.bare_dir.display(),
"resolved repository"
);
Ok(gb)
}
pub(crate) fn resolve_for_init(
&self,
header: Option<&crate::pb::RepositoryHeader>,
) -> Result<PathBuf, tonic::Status> {
let header =
header.ok_or_else(|| tonic::Status::invalid_argument("repository is required"))?;
let relative_path = header.relative_path.trim();
if relative_path.is_empty() {
return Err(tonic::Status::invalid_argument("relative_path is required"));
}
// Validate early to reject '..' and other traversal patterns
crate::sanitize::validate_relative_path(relative_path)
.map_err(|e| tonic::Status::invalid_argument(e.to_string()))?;
let candidate = self.repo_prefix.join(relative_path);
// Canonicalize repo_prefix (which should exist) for a reliable check
let prefix_canon = self
.repo_prefix
.canonicalize()
.unwrap_or_else(|_| self.repo_prefix.clone());
// Unified path validation to avoid TOCTOU
let canonical = match candidate.canonicalize() {
Ok(canon) => {
// Path exists and was canonicalized
canon
}
Err(_) => {
// Path doesn't exist yet — validate via parent
let parent = candidate.parent().unwrap_or(&self.repo_prefix);
let filename = candidate.file_name().ok_or_else(|| {
tonic::Status::invalid_argument("invalid path: missing filename")
})?;
let parent_canon = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
let constructed = parent_canon.join(filename);
// String-level verification for non-existent paths
let constructed_str = constructed.to_string_lossy();
let prefix_str = prefix_canon.to_string_lossy();
if !constructed_str.starts_with(&*prefix_str) {
return Err(tonic::Status::invalid_argument(
"path traversal detected: relative_path escapes repo prefix",
));
}
constructed
}
};
// Final check: canonical must be under prefix
if !canonical.starts_with(&prefix_canon) {
return Err(tonic::Status::invalid_argument(
"path traversal detected: relative_path escapes repo prefix",
));
}
Ok(canonical)
}
pub fn notify_ref_update(
&self,
relative_path: &str,
_ref_name: &str,
_old_oid: &str,
_new_oid: &str,
) {
// Invalidate moka caches
crate::server::cache::invalidate_repo(relative_path);
// Invalidate disk cache
if let Some(ref pc) = self.pack_cache {
pc.invalidate_repo(relative_path);
}
}
/// Inject repo_prefix as storage_path into the client-provided header
fn prefixed_header(&self, header: &crate::pb::RepositoryHeader) -> crate::pb::RepositoryHeader {
crate::pb::RepositoryHeader {
storage_path: self.repo_prefix.to_string_lossy().into_owned(),
relative_path: header.relative_path.clone(),
storage_name: header.storage_name.clone(),
}
}
}
pub(super) fn bridge_server_stream<T: Send + 'static>(
mut remote: tonic::Streaming<T>,
) -> tokio_stream::wrappers::ReceiverStream<Result<T, tonic::Status>> {
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(async move {
use tokio_stream::StreamExt;
while let Some(item) = remote.next().await {
if tx.send(item).await.is_err() {
break;
}
}
});
tokio_stream::wrappers::ReceiverStream::new(rx)
}
fn scan_bare_repos_recursively(dir: &Path, repos: &mut Vec<PathBuf>) -> GitResult<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if is_bare_git_repo(&path) {
repos.push(path);
continue;
}
if path.is_dir() {
scan_bare_repos_recursively(&path, repos)?;
}
}
Ok(())
}
fn is_bare_git_repo(path: &Path) -> bool {
match is_git(path) {
Ok(repo) => repo.is_bare(),
Err(_) => false,
}
}
pub(crate) fn into_status(e: GitError) -> tonic::Status {
match &e {
GitError::NotFound(_)
| GitError::ObjectNotFound(_)
| GitError::RefNotFound(_)
| GitError::RepoNotFound => tonic::Status::not_found(e.to_string()),
GitError::InvalidArgument(_) => tonic::Status::invalid_argument(e.to_string()),
GitError::PermissionDenied(_) => tonic::Status::permission_denied(e.to_string()),
GitError::Locked(_) => tonic::Status::failed_precondition(e.to_string()),
GitError::AuthFailed(_) => tonic::Status::unauthenticated(e.to_string()),
GitError::NotBareRepository => tonic::Status::failed_precondition(e.to_string()),
_ => tonic::Status::internal(e.to_string()),
}
}
impl From<GitError> for tonic::Status {
fn from(e: GitError) -> Self {
into_status(e)
}
}
pub(crate) fn into_stream<T: Send + 'static>(
items: Vec<T>,
) -> ReceiverStream<Result<T, tonic::Status>> {
let (tx, rx) = tokio::sync::mpsc::channel(items.len().max(1));
for item in items {
let _ = tx.try_send(Ok(item));
}
ReceiverStream::new(rx)
}
pub(crate) fn git_cmd(gb: &GitBare, args: &[&str]) -> GitResult<std::process::Output> {
let mut full_args: Vec<String> = vec![
"--git-dir".into(),
gb.bare_dir.to_string_lossy().into_owned(),
];
full_args.extend(args.iter().map(|s| s.to_string()));
let cmd_name = args.first().copied().unwrap_or("unknown");
tracing::debug!(
repo = %gb.bare_dir.display(),
args = %full_args.iter().skip(2).cloned().collect::<Vec<_>>().join(" "),
"spawning git subprocess"
);
let start = std::time::Instant::now();
let result = std::process::Command::new("git")
.args(&full_args)
.output()
.map_err(|e| {
tracing::error!(
repo = %gb.bare_dir.display(),
error = %e,
"failed to spawn git subprocess"
);
GitError::Internal(format!("failed to spawn git: {e}"))
})?;
let elapsed = start.elapsed();
let elapsed_ms = elapsed.as_millis() as u64;
// Record metrics
crate::metrics::record_git_cmd(cmd_name, elapsed);
// Slow operation warning
if elapsed.as_secs() >= 1 {
tracing::warn!(
repo = %gb.bare_dir.display(),
command = cmd_name,
elapsed_ms,
"slow git subprocess"
);
}
if !result.status.success() {
let stderr_str = String::from_utf8_lossy(&result.stderr);
tracing::warn!(
repo = %gb.bare_dir.display(),
command = cmd_name,
status = ?result.status.code(),
stderr = %stderr_str.trim(),
elapsed_ms,
"git subprocess exited with non-zero status"
);
return Err(structured_git_error(&stderr_str, result.status.code()));
}
tracing::debug!(
repo = %gb.bare_dir.display(),
command = cmd_name,
elapsed_ms,
"git subprocess completed"
);
Ok(result)
}
/// Map git subprocess stderr to a structured GitError variant.
fn structured_git_error(stderr: &str, code: Option<i32>) -> GitError {
let stderr_trimmed = stderr.trim();
if stderr_trimmed.contains("not a git repository") || stderr_trimmed.contains("does not exist")
{
GitError::RepoNotFound
} else if stderr_trimmed.contains("Permission denied") || stderr_trimmed.contains("denied") {
GitError::PermissionDenied(stderr_trimmed.to_string())
} else if stderr_trimmed.contains("is locked") || stderr_trimmed.contains("Could not acquire") {
GitError::Locked(stderr_trimmed.to_string())
} else if stderr_trimmed.contains("not found") || stderr_trimmed.contains("do not have") {
GitError::NotFound(stderr_trimmed.to_string())
} else {
GitError::CommandFailed {
status_code: code,
stderr: stderr_trimmed.to_string(),
}
}
}
pub async fn serve(
addr: std::net::SocketAddr,
svc: GitksService,
) -> Result<(), tonic::transport::Error> {
let span = tracing::info_span!("gitks.server", %addr);
let _enter = span.enter();
tracing::info!("registering gRPC services");
let (health_reporter, health_service) = tonic_health::server::health_reporter();
let repo_svc = repository_service_server::RepositoryServiceServer::new(svc.clone());
let archive_svc = archive_service_server::ArchiveServiceServer::new(svc.clone());
let blame_svc = blame_service_server::BlameServiceServer::new(svc.clone());
let branch_svc = branch_service_server::BranchServiceServer::new(svc.clone());
let commit_svc = commit_service_server::CommitServiceServer::new(svc.clone());
let diff_svc = diff_service_server::DiffServiceServer::new(svc.clone());
let merge_svc = merge_service_server::MergeServiceServer::new(svc.clone());
let pack_svc = pack_service_server::PackServiceServer::new(svc.clone());
let ref_svc = ref_service_server::RefServiceServer::new(svc.clone());
let remote_svc = remote_service_server::RemoteServiceServer::new(svc.clone());
let tag_svc = tag_service_server::TagServiceServer::new(svc.clone());
let tree_svc = tree_service_server::TreeServiceServer::new(svc);
health_reporter
.set_serving::<repository_service_server::RepositoryServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<archive_service_server::ArchiveServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<blame_service_server::BlameServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<branch_service_server::BranchServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<commit_service_server::CommitServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<diff_service_server::DiffServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<merge_service_server::MergeServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<pack_service_server::PackServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<ref_service_server::RefServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<remote_service_server::RemoteServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<tag_service_server::TagServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<tree_service_server::TreeServiceServer<GitksService>>()
.await;
let server = tonic::transport::Server::builder()
.add_service(health_service)
.add_service(repo_svc)
.add_service(archive_svc)
.add_service(blame_svc)
.add_service(branch_svc)
.add_service(commit_svc)
.add_service(diff_svc)
.add_service(merge_svc)
.add_service(pack_svc)
.add_service(ref_svc)
.add_service(remote_svc)
.add_service(tag_svc)
.add_service(tree_svc);
tracing::info!("server ready, starting to accept connections");
server.serve(addr).await
}