From 80779e77f012bbb4d1b7b9a91b67cf4f086d5d79 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 3 Sep 2026 19:15:20 +0900 Subject: [PATCH] gitmirror/xrpc: implement repo blame Port the blame handler to the restructured gitmirror-xrpc crate. Adds the get_blame route with deadline-guarded gix::blame::file, per-object allocation limits, and the full error taxonomy (FileNotFound, BlameTooLarge, BlameTimeout, RefNotFound). Signed-off-by: dawn --- Cargo.lock | 1 + gitmirror/crates/gitmirror-xrpc/Cargo.toml | 5 +- gitmirror/crates/gitmirror-xrpc/src/error.rs | 18 ++ gitmirror/crates/gitmirror-xrpc/src/lib.rs | 1 + .../crates/gitmirror-xrpc/src/routes/git.rs | 277 +++++++++++++++++- 5 files changed, 299 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08e8eb18c..a20c546fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2804,6 +2804,7 @@ dependencies = [ "serde_json", "tangled-axum", "tempfile", + "thiserror 2.0.18", "tokio", "tracing", ] diff --git a/gitmirror/crates/gitmirror-xrpc/Cargo.toml b/gitmirror/crates/gitmirror-xrpc/Cargo.toml index a9a6aef57..7c3ab90f3 100644 --- a/gitmirror/crates/gitmirror-xrpc/Cargo.toml +++ b/gitmirror/crates/gitmirror-xrpc/Cargo.toml @@ -20,11 +20,12 @@ jacquard-axum = { workspace = true } jacquard-common = { workspace = true } jacquard-identity = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = ["net", "rt-multi-thread", "sync"] } +tokio = { workspace = true, features = ["net", "rt-multi-thread", "sync", "time"] } +thiserror = { workspace = true } tracing = "0.1" line-numbers = "0.4.0" rustc-hash = "2.1.2" -gix = { version = "0.84", features = ["parallel", "blob-diff", "merge", "sha1", "sha256", "revision", "tree-editor"] } +gix = { version = "0.84", features = ["parallel", "blob-diff", "blame", "merge", "sha1", "sha256", "revision", "tree-editor"] } gix-pack = { workspace = true } gix-transport = { workspace = true, features = ["async-client"] } futures-lite = { workspace = true } diff --git a/gitmirror/crates/gitmirror-xrpc/src/error.rs b/gitmirror/crates/gitmirror-xrpc/src/error.rs index 011d6dd24..5978910e4 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/error.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/error.rs @@ -10,6 +10,9 @@ pub(crate) enum XrpcError { RefNotFound { rev: String, detail: String }, RevisionNotFound { rev: String }, CompareError(String), + FileNotFound { path: String }, + BlameTooLarge, + BlameTimeout, MergeConflict(Vec), PushRejected(String), Unauthorized(String), @@ -49,6 +52,21 @@ impl IntoResponse for XrpcError { "failed to compare revisions".to_owned(), ) } + Self::FileNotFound { path } => ( + StatusCode::NOT_FOUND, + "FileNotFound", + format!("file not found: {path}"), + ), + Self::BlameTooLarge => ( + StatusCode::PAYLOAD_TOO_LARGE, + "BlameTooLarge", + "blame exceeds resource limits".to_owned(), + ), + Self::BlameTimeout => ( + StatusCode::GATEWAY_TIMEOUT, + "BlameTimeout", + "blame computation timed out".to_owned(), + ), Self::MergeConflict(paths) => ( StatusCode::CONFLICT, "MergeConflict", diff --git a/gitmirror/crates/gitmirror-xrpc/src/lib.rs b/gitmirror/crates/gitmirror-xrpc/src/lib.rs index 58485aa2a..131e0b556 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/lib.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/lib.rs @@ -67,6 +67,7 @@ impl AtprotoService for AppState { pub fn router(state: AppState) -> Router { use routes::*; Router::new() + .route("/xrpc/sh.tangled.git.temp2.getBlame", get(git::get_blame)) .route("/xrpc/sh.tangled.git.temp2.getDiff", get(git::get_diff)) .route( "/xrpc/sh.tangled.git.temp2.getInterdiff", diff --git a/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs b/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs index 509ca84e2..f844c2ce5 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs @@ -1,3 +1,7 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::time::{Duration, Instant}; + use axum::extract::State; use bobbin_knot_proxy::KnotHost; use gitmirror_git::repo_ext::{MergeError, RepositoryExt as _}; @@ -10,10 +14,11 @@ use jacquard_common::ToSmolStr; use jacquard_common::types::string::Datetime; use lexicons::sh_tangled; use lexicons::sh_tangled::git::{ - merge_commit, temp2::{get_diff, get_interdiff, list_commits, merge_check} + merge_commit, temp2::{get_blame, get_diff, get_interdiff, list_commits, merge_check} }; use jacquard_identity::resolver::IdentityResolver as _; use tangled_axum::atproto::{AtprotoService as _, ExtractAnyServiceAuth}; +use tokio::time::timeout; use tracing::info; use crate::did_ext::DidDocumentExt as _; @@ -28,6 +33,10 @@ const COMMITTER_NAME: &str = "Tangled"; const COMMITTER_EMAIL: &str = "noreply@tangled.sh"; const REPO_PUSH_NSID: &str = "sh.tangled.repo.push"; +const MAX_BLAME_OBJECT_BYTES: u64 = 1024 * 1024; +const MAX_BLAME_RUNS: usize = 50_000; +const BLAME_TIMEOUT: Duration = Duration::from_secs(5); + enum MergeStyle { Rebase, // TODO(post-1.0): support more merge strategies @@ -172,6 +181,272 @@ pub(crate) fn find_commit(repo: &gix::Repository, sha: &str) -> Result { + git: &'a gix::Repository, + deadline: Instant, +} + +impl DeadlineObjects<'_> { + fn check(&self) -> Result<(), gix::objs::find::Error> { + if Instant::now() >= self.deadline { + return Err(Box::new(BlameDeadline { + source: BlameDeadlineMarker, + })); + } + Ok(()) + } +} + +impl gix::objs::FindHeader for DeadlineObjects<'_> { + fn try_header( + &self, + id: &gix::hash::oid, + ) -> Result, gix::objs::find::Error> { + self.check()?; + gix::objs::FindHeader::try_header(self.git, id) + } +} + +impl gix::objs::Find for DeadlineObjects<'_> { + fn try_find<'a>( + &self, + id: &gix::hash::oid, + buffer: &'a mut Vec, + ) -> Result>, gix::objs::find::Error> { + self.check()?; + gix::objs::Find::try_find(self.git, id, buffer) + } +} + +fn caused_by( + error: &(dyn std::error::Error + 'static), + predicate: fn(&T) -> bool, +) -> bool { + error.downcast_ref::().is_some_and(predicate) + || error + .source() + .is_some_and(|source| caused_by(source, predicate)) +} + +fn allocation_limited(error: &(dyn std::error::Error + 'static)) -> bool { + caused_by::(error, |error| { + matches!(error, gix::odb::loose::find::Error::OutOfMemory { .. }) + }) || caused_by::(error, |error| { + matches!(error, gix::odb::pack::data::decode::Error::OutOfMemory) + }) +} + +fn map_blame_error(error: &(dyn std::error::Error + 'static)) -> XrpcError { + if caused_by::(error, |_| true) { + XrpcError::BlameTimeout + } else if allocation_limited(error) { + XrpcError::BlameTooLarge + } else { + XrpcError::Internal(error.to_string()) + } +} + +fn get_blame_inner( + repo_path: &Path, + refspec: &str, + path: &str, + deadline: Instant, +) -> Result { + if path.is_empty() { + return Err(XrpcError::InvalidRequest( + "missing path parameter".to_owned(), + )); + } + if refspec.is_empty() || refspec.contains('\0') || refspec.starts_with('-') { + return Err(XrpcError::InvalidRequest(format!( + "invalid ref spec {refspec:?}" + ))); + } + + let repo = gix::open_opts( + repo_path, + gix::open::Options::default().config_overrides([format!( + "gitoxide.objects.allocLimit={MAX_BLAME_OBJECT_BYTES}" + )]), + ) + .map_err(|e| XrpcError::RepoNotFound { + detail: e.to_string(), + })?; + + let id = match repo.rev_parse_single(refspec.as_bytes()) { + Ok(id) => id, + Err(gix::revision::spec::parse::single::Error::RangedRev { spec }) => { + return Err(XrpcError::InvalidRequest(format!( + "ref must resolve to a single revision, got a range: {spec}" + ))); + } + Err(error) => { + return Err(XrpcError::RefNotFound { + rev: refspec.to_owned(), + detail: error.to_string(), + }); + } + }; + let peeled = id + .object() + .and_then(|object| object.peel_tags_to_end()) + .map_err(|e| XrpcError::RefNotFound { + rev: refspec.to_owned(), + detail: e.to_string(), + })?; + let commit_oid = peeled.id; + let commit = peeled + .try_into_commit() + .map_err(|_| XrpcError::RefNotFound { + rev: refspec.to_owned(), + detail: "revision does not point at a commit".to_owned(), + })?; + + let tree = commit + .tree() + .map_err(|e| XrpcError::Internal(e.to_string()))?; + let entry = tree + .lookup_entry_by_path(path) + .map_err(|e| XrpcError::Internal(e.to_string()))? + .filter(|entry| { + matches!( + entry.mode().kind(), + gix::objs::tree::EntryKind::Blob + | gix::objs::tree::EntryKind::BlobExecutable + | gix::objs::tree::EntryKind::Link + ) + }) + .ok_or_else(|| XrpcError::FileNotFound { + path: path.to_owned(), + })?; + + let header = repo + .find_header(entry.object_id()) + .map_err(|e| XrpcError::Internal(e.to_string()))?; + if header.size() > MAX_BLAME_OBJECT_BYTES { + return Err(XrpcError::BlameTooLarge); + } + + let file_path = path.as_bytes().as_bstr(); + let cache = repo + .commit_graph_if_enabled() + .map_err(|e| XrpcError::Internal(e.to_string()))?; + let mut resource_cache = repo + .diff_resource_cache_for_tree_diff() + .map_err(|e| XrpcError::Internal(e.to_string()))?; + let options = gix::blame::Options { + diff_algorithm: repo + .diff_algorithm() + .map_err(|e| XrpcError::Internal(e.to_string()))?, + rewrites: Some(gix::diff::Rewrites::default()), + ..Default::default() + }; + let outcome = gix::blame::file( + DeadlineObjects { + git: &repo, + deadline, + }, + commit_oid, + cache, + &mut resource_cache, + file_path, + options, + ) + .map_err(|error| match error { + gix::blame::Error::FileMissing { file_path, .. } => XrpcError::FileNotFound { + path: file_path.to_string(), + }, + error => map_blame_error(&error), + })?; + if outcome.entries.len() > MAX_BLAME_RUNS { + return Err(XrpcError::BlameTooLarge); + } + + let commit_oids: BTreeSet = outcome + .entries + .iter() + .map(|entry| entry.commit_id) + .collect(); + let commits = commit_oids + .into_iter() + .map(|oid| -> Result { + if Instant::now() >= deadline { + return Err(XrpcError::BlameTimeout); + } + let commit = repo + .find_commit(oid) + .map_err(|e| XrpcError::Internal(e.to_string()))?; + let decoded = commit + .decode() + .map_err(|e| XrpcError::Internal(e.to_string()))?; + Ok(get_blame::Commit { + oid: oid.to_smolstr(), + message: decoded.message.to_smolstr(), + author: GixSignature( + decoded + .author() + .map_err(|e| XrpcError::Internal(e.to_string()))?, + ) + .try_into() + .map_err(|e: anyhow::Error| XrpcError::Internal(e.to_string()))?, + extra_data: Default::default(), + }) + }) + .collect::, _>>()?; + + let runs = outcome + .entries + .into_iter() + .map(|entry| get_blame::Run { + start: i64::from(entry.start_in_blamed_file) + 1, + count: i64::from(entry.len.get()), + commit: entry.commit_id.to_smolstr(), + extra_data: Default::default(), + }) + .collect(); + + Ok(get_blame::GetBlameOutput { + commit: commit_oid.to_smolstr(), + commits, + runs, + extra_data: Default::default(), + }) +} + +pub(crate) async fn get_blame( + State(state): State, + ExtractXrpc(args): ExtractXrpc, +) -> Result, XrpcError> { + let repo_path = state.layout.repo_path(&args.repo); + let deadline = Instant::now() + BLAME_TIMEOUT; + let task = tokio::task::spawn_blocking(move || { + get_blame_inner( + &repo_path, + args.r#ref.as_ref(), + args.path.as_ref(), + deadline, + ) + }); + timeout(BLAME_TIMEOUT, task) + .await + .map_err(|_| XrpcError::BlameTimeout)? + .map_err(|e| XrpcError::Internal(e.to_string()))? + .map(XrpcResponse) +} + fn get_diff_inner( repo: &gix::Repository, base: gix::ObjectId, -- 2.51.2