gunnar-lfs 1.6.0

Git LFS over pure SSH: the git-lfs 3.0 `git-lfs-transfer` protocol, server side, streaming and without HTTP.
Documentation
//! 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);
    }
}