Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
    }
}