crates/gunnar-delta/src/limits.rs
//! Hard caps: what stands between a sliding window and an OOM kill.
//!
//! # Why this module exists at all
//!
//! Delta search is the classic shape where a parameter that reads as a tuning
//! knob is actually a memory multiplier. `window` alone is harmless; `window` ×
//! `object size` × `threads` is a product, and every one of its factors is
//! caller-supplied. A test in this tree took a 499 GB machine out on 2026-08-03
//! by crossing a degenerate parameter with a large input, and this is exactly
//! that shape.
//!
//! So the rule here is: **refuse, do not attempt.** Every bound is checked in
//! [`Limits::validate`] *before* the first allocation, and an out-of-bounds
//! request comes back as [`crate::Error::Refused`] naming the field, the value
//! and the cap. There is no "best effort" path and no silent clamping; a clamp
//! would let a caller ask for 100× the memory and never learn it did not get it.
use crate::error::{Error, Result};
/// Bounds on everything that can multiply into memory or time.
///
/// The defaults are sized so that the worst case fits comfortably inside a few
/// GiB with every core busy, which is the configuration a repack runs in. They
/// are *caps*, not settings: normal git parameters (window 10, depth 50) sit
/// two to three orders of magnitude below every one of them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Most objects one [`crate::deltify`] call may consider.
///
/// The per-object bookkeeping is small but not free; at the default this is
/// under a gigabyte of state before a single object byte is touched.
pub max_objects: usize,
/// Largest permitted `window`.
///
/// Time is `O(objects × window)` delta attempts and memory is
/// `O(threads × window × index_size)`. git's default is 10 and its
/// aggressive setting is 250; anything past this cap is a typo, not a
/// request.
pub max_window: usize,
/// Largest permitted `depth`.
///
/// Depth costs no memory here, chains being resolved in one serial pass,
/// but a decoder pays for it on every read, so a runaway value is a
/// present a caller gives to whoever clones the result. git's default is 50.
pub max_depth: usize,
/// Objects larger than this are never used as a delta **base**.
///
/// Indexing a base allocates roughly `len × 0.75`, and the index cache holds
/// `window + 1` of them per thread. This is the single most important number
/// in the struct: it is what bounds `threads × window × index_size`.
/// Exceeding it is not an error; such an object is skipped as a candidate.
/// Refusing a whole repack because one blob is big would be the wrong
/// behaviour.
pub max_base_size: usize,
/// Objects larger than this are never **deltified**; they go out whole.
///
/// Also not an error, for the same reason. The scan itself is `O(n)` in the
/// target with a bounded candidate chain, so this bound is about latency and
/// about not spending a minute on a blob that will not delta anyway.
pub max_target_size: usize,
/// Refuse to build a single base index larger than this.
///
/// Redundant with `max_base_size` under the defaults and deliberately kept:
/// it is the check that still fires if someone raises `max_base_size`
/// without doing the multiplication.
pub max_index_bytes: usize,
/// Byte budget for one thread's index cache.
///
/// When adding an index would exceed it the oldest cached index is dropped,
/// which costs a rebuild rather than an allocation failure. Multiply by the
/// thread count for the crate's peak resident set.
pub index_memory_per_thread: usize,
/// Refuse a request whose objects sum to more than this.
///
/// The crate never copies object data, the window holding borrowed slices,
/// so this does not bound *this* crate's allocations. It bounds the caller's,
/// and it is the one number that catches "someone handed us a planet".
pub max_total_input: u64,
}
const MIB: usize = 1024 * 1024;
impl Default for Limits {
fn default() -> Self {
Limits {
max_objects: 16_000_000,
max_window: 1024,
max_depth: 1000,
max_base_size: 64 * MIB,
max_target_size: 64 * MIB,
max_index_bytes: 96 * MIB,
index_memory_per_thread: 256 * MIB,
max_total_input: 64 * 1024 * MIB as u64,
}
}
}
impl Limits {
/// Check one value against one cap, naming the field in the error.
pub(crate) fn check(what: &'static str, requested: u64, limit: u64) -> Result<()> {
if requested > limit {
return Err(Error::Refused {
what,
requested,
limit,
});
}
Ok(())
}
/// Reject a request before anything is allocated for it.
///
/// `window` and `depth` come from the caller's options; `objects` and
/// `total_bytes` from the input.
pub fn validate(
&self,
window: usize,
depth: usize,
objects: usize,
total_bytes: u64,
) -> Result<()> {
Self::check("window", window as u64, self.max_window as u64)?;
Self::check("depth", depth as u64, self.max_depth as u64)?;
Self::check("objects", objects as u64, self.max_objects as u64)?;
Self::check("total input bytes", total_bytes, self.max_total_input)?;
Ok(())
}
}