crates/gunnar-lfs/src/limits.rs
//! What this server will accept, decided **before** the bytes arrive.
//!
//! # The two different things a "limit" means here
//!
//! They get confused, and confusing them is how a size cap gets treated as an
//! OOM defence it is not.
//!
//! * **[`Limits::max_object_bytes`] is a quota.** It protects disk and it
//! protects the client's time. It does *not* protect memory, because nothing
//! on this path allocates proportionally to the object.
//! * **[`CHUNK_BYTES`] is the memory bound**, and it is a constant. Every byte
//! of every LFS object passes through one buffer of this size, in both
//! directions. Peak resident memory for a transfer is that buffer plus the
//! hasher, whether the object is 4 KiB or 400 GiB. `tests/streaming.rs`
//! asserts it against the real process RSS, because a claim that something
//! "streams" is exactly the kind of claim a green unit test cannot make.
//!
//! # Enforced before arrival, following `gunnar-pack`
//!
//! `gunnar-pack`'s `alloc_limit_bytes` defaults to 1 GiB because a test in this
//! tree OOM'd a 499 GB machine by allocating what a header claimed. The same
//! discipline applies here and there are two places to apply it: the `batch`
//! request declares every object's size before any of them move, and
//! `put-object` declares one more time in its `size=` argument. **Both are
//! checked**, and the `put-object` check is the load-bearing one, because a
//! client is free to skip `batch` entirely.
/// The one buffer size every LFS byte passes through, in either direction.
///
/// It is `gunnar_pktline::MAX_PAYLOAD`, so an outbound chunk fills exactly one
/// pkt-line and no byte is copied twice to satisfy a smaller frame. The real
/// git-lfs client chunks its uploads at 32 KiB; both are legal and the reader on
/// each side is size-agnostic.
pub const CHUNK_BYTES: usize = gunnar_pktline::MAX_PAYLOAD;
/// The default object quota: **8 GiB**.
///
/// Chosen to be generous rather than tight: LFS exists for large files and a
/// low default would make gunnar refuse the workloads it is for. It is finite
/// because "no limit" means a single client decides how much disk the server
/// has.
pub const DEFAULT_MAX_OBJECT_BYTES: u64 = 8 * 1024 * 1024 * 1024;
/// The default cap on objects named in one `batch` request: **10 000**.
pub const DEFAULT_MAX_BATCH_OBJECTS: usize = 10_000;
/// The default cap on `key=value` arguments attached to one command: **32**.
pub const DEFAULT_MAX_ARGS: usize = 32;
/// What a session will accept.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Largest object this server stores, in bytes. A declared size over this
/// is refused with 413 **before** the payload is read.
pub max_object_bytes: u64,
/// Most objects one `batch` request may name.
pub max_batch_objects: usize,
/// Most `key=value` arguments one command may carry.
pub max_args: usize,
/// How many bytes of a refused upload will be read and discarded to keep
/// the connection in sync before the session is torn down.
///
/// **A refusal cannot simply not read.** The client has already begun
/// writing the object into the channel; the framing only ends at its
/// flush-pkt, so a server that answered 413 and then tried to read the next
/// *command* would read object bytes instead and desync. Draining costs
/// time proportional to what the client sends and memory proportional to
/// nothing, and past this budget the session is dropped rather than made to
/// serve an unbounded stream it has already refused.
pub max_drain_bytes: u64,
}
impl Default for Limits {
fn default() -> Self {
Limits {
max_object_bytes: DEFAULT_MAX_OBJECT_BYTES,
max_batch_objects: DEFAULT_MAX_BATCH_OBJECTS,
max_args: DEFAULT_MAX_ARGS,
// Two chunks past the refusal is enough to reach the flush-pkt of a
// client that respected the size it declared and was refused for
// some other reason; a client that lied gets dropped.
max_drain_bytes: 2 * CHUNK_BYTES as u64,
}
}
}
impl Limits {
/// The default limits.
pub fn new() -> Self {
Limits::default()
}
/// A different object quota.
pub fn max_object_bytes(mut self, bytes: u64) -> Self {
self.max_object_bytes = bytes;
self
}
/// A different batch cap.
pub fn max_batch_objects(mut self, objects: usize) -> Self {
self.max_batch_objects = objects;
self
}
/// A different drain budget.
pub fn max_drain_bytes(mut self, bytes: u64) -> Self {
self.max_drain_bytes = bytes;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_memory_bound_is_a_constant_and_not_a_function_of_the_object() {
// The whole point of the module. If `CHUNK_BYTES` ever became
// configurable per object, this is where it would be noticed.
assert_eq!(CHUNK_BYTES, gunnar_pktline::MAX_PAYLOAD);
// `const` rather than a runtime `assert!`: both operands are constants,
// so a runtime assertion here could never report anything the compiler
// did not already know, and clippy is right to call that out. As a const
// block it is a *compile* error instead, which is strictly stronger and
// cannot be skipped by a test filter.
const { assert!(CHUNK_BYTES < 128 * 1024) };
}
#[test]
fn the_defaults_are_finite() {
let limits = Limits::new();
assert!(limits.max_object_bytes > 0);
assert!(limits.max_batch_objects > 0);
assert!(limits.max_args > 0);
assert!(limits.max_drain_bytes > 0);
}
}