diff --git a/gitmirror/crates/gitmirror-xrpc/src/error.rs b/gitmirror/crates/gitmirror-xrpc/src/error.rs index b2c0ec772..b7e918394 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/error.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/error.rs @@ -22,6 +22,7 @@ pub(crate) enum XrpcError { CompareError(String), FileNotFound { path: String }, BlameTooLarge, + BlobTooLarge { limit: u64 }, BlameTimeout, MergeConflict(Vec), PushRejected(String), @@ -87,6 +88,11 @@ impl IntoResponse for XrpcError { "FileNotFound", format!("file not found: {path}"), ), + Self::BlobTooLarge { limit } => ( + StatusCode::PAYLOAD_TOO_LARGE, + "BlobTooLarge", + format!("file is too large to serve, limit is {limit} bytes"), + ), Self::BlameTooLarge => ( StatusCode::PAYLOAD_TOO_LARGE, "BlameTooLarge", diff --git a/gitmirror/crates/gitmirror-xrpc/src/lib.rs b/gitmirror/crates/gitmirror-xrpc/src/lib.rs index 687597fef..7d7994c1b 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/lib.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/lib.rs @@ -32,6 +32,7 @@ pub struct AppState { pub(crate) clock: Arc, pub(crate) stats: Arc, pub(crate) redis: Option, + pub(crate) max_blob_bytes: u64, } impl AppState { @@ -43,6 +44,7 @@ impl AppState { knot_policy: KnotPolicy, clock: Arc, redis: Option, + max_blob_bytes: u64, ) -> Self { let layout = Arc::new(Layout::new((*repo_base).clone())); Self { @@ -53,6 +55,7 @@ impl AppState { clock, stats: Arc::default(), redis, + max_blob_bytes, } } } diff --git a/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs b/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs index cfdbd22ea..756e18422 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs @@ -284,7 +284,7 @@ fn caused_by( .is_some_and(|source| caused_by(source, predicate)) } -fn allocation_limited(error: &(dyn std::error::Error + 'static)) -> bool { +pub(crate) 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| { diff --git a/gitmirror/crates/gitmirror-xrpc/src/routes/reads.rs b/gitmirror/crates/gitmirror-xrpc/src/routes/reads.rs index a25314775..4a435a2fd 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/routes/reads.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/routes/reads.rs @@ -15,7 +15,7 @@ use lexicons::sh_tangled::git::temp; use sha2::{Digest as _, Sha256}; use tracing::warn; -use crate::routes::git::GixSignature; +use crate::routes::git::{GixSignature, allocation_limited}; use crate::{AppState, error::XrpcError, last_commit, metrics::BlockingKind, sniff}; const IMMUTABLE: &str = "public, max-age=31536000, immutable"; @@ -42,6 +42,17 @@ fn open(state: &AppState, repo: &Did) -> Result { }) } +fn open_capped(state: &AppState, repo: &Did, limit: u64) -> Result { + gix::open_opts( + state.layout.repo_path(repo), + gix::open::Options::default() + .config_overrides([format!("gitoxide.objects.allocLimit={limit}")]), + ) + .map_err(|e| XrpcError::RepoNotFound { + detail: e.to_string(), + }) +} + fn resolve_commit(repo: &gix::Repository, refspec: &str) -> Result { if refspec.is_empty() || refspec.contains('\0') || refspec.starts_with('-') { return Err(XrpcError::InvalidRequest(format!( @@ -461,7 +472,8 @@ pub(crate) async fn get_blob( ExtractXrpc(args): ExtractXrpc, ) -> Result { tracing::Span::current().record("repo", args.repo.as_str()); - let repo = open(&state, &args.repo)?; + let limit = state.max_blob_bytes; + let repo = open_capped(&state, &args.repo, limit)?; let refspec = args.r#ref.as_deref().unwrap_or("HEAD").to_owned(); let cache = cache_control(&[&refspec]); let path = args.path.to_string(); @@ -485,9 +497,20 @@ pub(crate) async fn get_blob( .ok_or_else(|| XrpcError::FileNotFound { path: lookup.clone(), })?; - repo.find_object(entry.object_id()) + + let oid = entry.object_id(); + if blob_size(&repo, oid)? > limit { + return Err(XrpcError::BlobTooLarge { limit }); + } + repo.find_object(oid) .map(|object| object.data.clone()) - .map_err(|e| XrpcError::Internal(e.to_string())) + .map_err(|error| { + if allocation_limited(&error) { + XrpcError::BlobTooLarge { limit } + } else { + XrpcError::Internal(error.to_string()) + } + }) }) .await .map_err(|e| XrpcError::Internal(e.to_string()))??; diff --git a/gitmirror/crates/gitmirror/src/config.rs b/gitmirror/crates/gitmirror/src/config.rs index e4583cb13..01c007cda 100644 --- a/gitmirror/crates/gitmirror/src/config.rs +++ b/gitmirror/crates/gitmirror/src/config.rs @@ -76,6 +76,11 @@ fn parse_binds(raw: &str) -> Result, BindParseError> { pub struct RepoConfig { #[config(env = "GITMIRROR_SCAN_PATH")] pub scan_path: PathBuf, + + /// Largest blob getBlob will serve. gix cannot stream objects, so this bounds how much + /// a single request reads into memory. + #[config(env = "GITMIRROR_MAX_BLOB_BYTES", default = 26_214_400)] + pub max_blob_bytes: u64, } #[derive(Debug, Config)] diff --git a/gitmirror/crates/gitmirror/src/main.rs b/gitmirror/crates/gitmirror/src/main.rs index e92c07757..bea4b3620 100644 --- a/gitmirror/crates/gitmirror/src/main.rs +++ b/gitmirror/crates/gitmirror/src/main.rs @@ -172,6 +172,7 @@ async fn serve(cfg: MirrorConfig) -> anyhow::Result<()> { }) .transpose()?; + let max_blob_bytes = cfg.repo.max_blob_bytes; let state = AppState::new( Arc::new(cfg.repo.scan_path), http, @@ -182,6 +183,7 @@ async fn serve(cfg: MirrorConfig) -> anyhow::Result<()> { }, Arc::new(SystemClock::new()), redis, + max_blob_bytes, ); let app = router(state);