crates/gunnar-policy/src/gates/limits.rs
//! Gate 4, size and count ceilings.
//!
//! The pre-receive gate's design puts it as "reject a 4 GB blob **before it lands**, not after".
//!
//! # An honest note about "before it lands"
//!
//! By the time a policy gate runs, receive-pack has already read the pack off
//! the wire and indexed it (the receive-pack sequence, step 4 precedes step 5). So this gate
//! rejects before the *references* are applied and before the objects are folded
//! into the cold tier by `repack()`, not before the bytes arrive. The only place
//! a 4 GB blob can be refused before it is received is the pack reader itself,
//! streaming, and that ceiling belongs in `gunnar-pack`.
//!
//! [`LimitPolicy::max_pack_bytes`] is the seam for it: receive-pack passes the
//! measured pack size in
//! [`PushContext::pack_bytes`](crate::PushContext::pack_bytes), and if the limit
//! is configured but the size was not measured this gate **rejects** rather than
//! passing, so a wired-up limit cannot become a decoration.
//!
//! # Reads
//!
//! Sizes come from
//! [`GitServe::header`](gunnar_store::git_storage_trait::GitServe::header),
//! never from reading payloads. Refusing an oversized blob must not require
//! decompressing it. The size it reports is the **post-resolution** size — what
//! the object inflates to once its delta chain is applied — which is the only
//! number a ceiling can honestly be compared against, and is not
//! `Stored::bytes.len()`.
use gunnar_store::git_storage_trait::ObjType;
use crate::config::LimitPolicy;
use crate::context::PushContext;
use crate::error::{Error, Result};
use crate::{Gate, Verdict};
/// Enforces per-object, per-push and per-pack ceilings.
#[derive(Clone, Copy, Debug)]
pub struct LimitsGate {
policy: LimitPolicy,
}
impl LimitsGate {
/// A gate over `policy`.
pub fn new(policy: LimitPolicy) -> Self {
LimitsGate { policy }
}
/// The policy this gate enforces.
pub fn policy(&self) -> &LimitPolicy {
&self.policy
}
}
impl Gate for LimitsGate {
fn name(&self) -> &str {
"limits"
}
fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
if let Some(max) = self.policy.max_pack_bytes {
match ctx.pack_bytes {
Some(actual) if actual > max => {
return Ok(Verdict::reject(format!(
"packfile is {actual} bytes, over the {max}-byte limit"
)));
}
Some(_) => {}
None => {
return Ok(Verdict::reject(format!(
"a {max}-byte packfile limit is configured but the pack size was not \
measured, so the limit cannot be enforced"
)));
}
}
}
if let Some(max) = self.policy.max_objects {
let count = ctx.new_objects.len() as u64;
if count > max {
return Ok(Verdict::reject(format!(
"push introduces {count} objects, over the {max}-object limit"
)));
}
}
let needs_sizes = self.policy.max_object_bytes.is_some()
|| self.policy.max_blob_bytes.is_some()
|| self.policy.max_total_bytes.is_some();
if !needs_sizes {
return Ok(Verdict::Allow);
}
let mut total: u64 = 0;
for id in ctx.new_objects {
let Some((kind, size)) = ctx.objects.header(id.as_bytes()).map_err(Error::backend)?
else {
// The pack is indexed by now, so this cannot happen on a healthy
// push. If it does happen, the size is unknown, which is not the
// same as small.
return Err(Error::MissingObject {
oid: *id,
what: "its size",
});
};
total = total.saturating_add(size);
if let Some(max) = self.policy.max_object_bytes {
if size > max {
return Ok(Verdict::reject(format!(
"{} object {id} is {size} bytes, over the {max}-byte object limit",
kind.as_str()
)));
}
}
if let Some(max) = self.policy.max_blob_bytes {
if kind == ObjType::Blob && size > max {
return Ok(Verdict::reject(format!(
"blob {id} is {size} bytes, over the {max}-byte blob limit"
)));
}
}
}
if let Some(max) = self.policy.max_total_bytes {
if total > max {
return Ok(Verdict::reject(format!(
"push introduces {total} bytes of objects, over the {max}-byte limit"
)));
}
}
Ok(Verdict::Allow)
}
}