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

nordisk/gunnar

crates/gunnar-lfs/tests/namespace_allowance.rs

//! **The tenant's remaining room, enforced on the LFS door.**
//!
//! `tests/quota.rs` next door proves [`Limits::max_object_bytes`]: *"this
//! server accepts at most 8 GiB in one object"*. That is an OPERATOR's limit
//! and it is the same number for every namespace on the box. It says nothing
//! about how much any one tenant may accumulate, and until this file existed
//! nothing did:
//!
//! > *"can they cheat and add a 10gb file when they only have 1gb left for
//! > example?"* — the question put on 2026-09-07
//!
//! On the git side the answer is no, and has been since
//! `gunnar-wire/tests/pack_ceiling.rs`: the receive loop counts pack bytes as
//! they arrive and cuts the transfer past the plan's ceiling. **On the LFS side
//! the answer was yes.** A pack byte and an LFS byte land on the same
//! filesystem, are reported by the same `Usage.Read`, and are billed at the same
//! `disk_gib_month` — but only one of them had a door. A tenant one byte under
//! their plan could open a `git-lfs-transfer` upload session and put 8 GiB in it,
//! then another, for as long as the operator's disk lasted.
//!
//! # Red first — MEASURED 2026-09-07
//!
//! Before [`Allowance`] existed, this file did not compile, and that is the
//! honest shape of the failure: there was no way to even SAY how much room a
//! namespace had left to a `gunnar-lfs` session.
//!
//! ```text
//! error[E0432]: unresolved import `gunnar_lfs::Allowance`
//!  --> crates/gunnar-lfs/tests/namespace_allowance.rs:38:19
//!   |
//! 38 | use gunnar_lfs::{Allowance, DiscardingLfsStore, ...
//!   |                  ^^^^^^^^^ no `Allowance` in the root
//! ```
//!
//! # Why 507 and not 413
//!
//! [`Error::TooLarge`]'s 413 is *"this object is bigger than I accept"* — a
//! policy of the operator's, identical tomorrow and identical for every tenant.
//! This refusal is *"YOU have no room"*, which is a fact about one namespace's
//! plan that a purchase changes in a minute. `docs/api/batch.md` gives 507 to
//! *"the server has insufficient storage capacity to complete the request"*,
//! which from the client's side of the wire is exactly what a spent plan is, and
//! `Error::OutOfSpace` already carries it, already drains, and already leaves the
//! session alive to answer the next command. A second code for the same
//! client-visible fact would be a second thing for git-lfs to get wrong.
//!
//! # Why there is no slack here, though the pack ceiling has 6.25 %
//!
//! `gunnar_server::entitlement::EntitlementSlot::pack_ceiling` adds slack
//! because a pack's WIRE bytes and its STORED bytes are different numbers and
//! neither is knowable before the push — a thin pack is completed with bases the
//! client never sent, and the engine stores it compressed. **An LFS object has
//! no such gap.** `gunnar_lfs::store` stores objects RAW, byte-identical to what
//! the client uploaded (measured: level-6 DEFLATE over LFS content came out at
//! ratio 1.000), and the size is declared up front and then verified byte for
//! byte by `VerifyingSink`. Declared bytes ARE stored bytes, so the ceiling can
//! be exact, and an exact ceiling is the one a tenant can reason about.

use std::io::BufRead;

use gunnar_lfs::{Allowance, DiscardingLfsStore, Limits, Oid, Operation, Session, CHUNK_BYTES};

const GIB: u64 = 1024 * 1024 * 1024;

/// The line a tenant out of room reads. Composed by the gate that knows the
/// plan (`gunnar-server`); this crate only carries it, which is the same
/// division of labour `gunnar_wire::PackCeiling::refusal` already uses.
const REFUSAL: &str = "plan 10gb has 1073741824 bytes of lfs room left (cap 10737418240, \
                       9663676416 already held): buy more at https://gunnar.rs/billing/team";

fn oid(seed: u8) -> Oid {
    let c = char::from_digit(seed as u32 % 16, 16).unwrap();
    Oid::parse(&String::from(c).repeat(64)).unwrap()
}

/// The real oid of `payload`, hashed by gix and not by anything in this crate:
/// a `put-object` under an oid the bytes do not hash to is refused by
/// `VerifyingSink` for a reason that has nothing to do with the allowance, and
/// that refusal would make every "it went through" assertion here vacuous.
fn real_oid(payload: &[u8]) -> Oid {
    let mut hasher = gix_hash::hasher(gix_hash::Kind::Sha256);
    hasher.update(payload);
    match hasher.try_finalize().unwrap() {
        gix_hash::ObjectId::Sha256(d) => Oid::from_digest(d),
        other => unreachable!("{other:?}"),
    }
}

fn statuses(wire: &[u8]) -> Vec<u16> {
    String::from_utf8_lossy(wire)
        .split("status ")
        .skip(1)
        .filter_map(|rest| rest.split('\n').next()?.trim().parse().ok())
        .collect()
}

/// Drive one conversation, with or without an allowance, and hand back the wire
/// plus the store.
fn serve(
    script: Vec<u8>,
    operation: Operation,
    allowance: Option<Allowance>,
) -> (Vec<u8>, DiscardingLfsStore) {
    serve_under(script, operation, allowance, roomy())
}

/// **An operator's limits big enough to be out of the way.**
///
/// The shipped `max_object_bytes` is 8 GiB, which is SMALLER than the 10 GiB
/// object that question names — so on a default box that object is refused
/// 413 before the tenant's plan is ever consulted, and a test that did not raise
/// the limit would be asserting the operator's wall while claiming to assert the
/// tenant's. An operator selling a 10 GiB plan raises this flag
/// (`--max-lfs-object-bytes`); these tests are that operator's box.
///
/// The ordering itself — operator first, tenant second — is a real property and
/// is pinned in [`the_operators_limit_is_answered_before_the_tenants`] rather
/// than assumed here.
fn roomy() -> Limits {
    Limits::default().max_object_bytes(16 * GIB)
}

fn serve_under(
    script: Vec<u8>,
    operation: Operation,
    allowance: Option<Allowance>,
    limits: Limits,
) -> (Vec<u8>, DiscardingLfsStore) {
    let store = DiscardingLfsStore::new();
    let mut input = std::io::BufReader::new(std::io::Cursor::new(script));
    let mut output = Vec::new();
    let reader: &mut dyn BufRead = &mut input;
    let mut session = Session::new(&store, operation).with_limits(limits);
    if let Some(allowance) = allowance {
        // **A ledger of this test's own**, so the numbers below stay the
        // numbers below: `AllowanceLedger::shared()` is the process's, and a
        // test binary that ran two of these against one namespace would have
        // the second one's room spent by the first — which is the property
        // `tests/concurrent_allowance.rs` exists to assert and would be noise
        // here. `detached()` is that seam, and the mark is taken before the
        // allowance is bound, exactly as `Dispatch::ask_plan` does it.
        let ledger = gunnar_lfs::AllowanceLedger::detached();
        session = session.with_allowance(gunnar_lfs::NamespaceAllowance::new(
            ledger.mark("team"),
            allowance,
        ));
    }
    session
        .serve(reader, &mut output)
        .expect("a well-framed conversation must not fail the session");
    (output, store)
}

fn version(s: &mut Vec<u8>) {
    gunnar_pktline::write_text(s, b"version 1").unwrap();
    gunnar_pktline::write_flush(s).unwrap();
}

fn quit(s: &mut Vec<u8>) {
    gunnar_pktline::write_text(s, b"quit").unwrap();
    gunnar_pktline::write_flush(s).unwrap();
}

/// A `put-object` that DECLARES `size` and sends `payload`. The declaration is
/// what a pre-arrival refusal reads; the payload is what a client that was
/// already writing when the refusal was decided leaves in the channel.
fn put(s: &mut Vec<u8>, oid: &Oid, size: u64, payload: &[u8]) {
    gunnar_pktline::write_text(s, format!("put-object {oid}").as_bytes()).unwrap();
    gunnar_pktline::write_text(s, format!("size={size}").as_bytes()).unwrap();
    gunnar_pktline::write_delim(s).unwrap();
    if !payload.is_empty() {
        gunnar_pktline::write_data(s, payload).unwrap();
    }
    gunnar_pktline::write_flush(s).unwrap();
}

/// **The question above, spelled in its own numbers.** A tenant with 1 GiB of
/// room declares a 10 GiB object.
///
/// Refused at the declaration, with 507 and the plan's own sentence, and the
/// store is never asked to begin a write — which is the difference between a
/// quota and an accounting entry. Nothing here moves 10 GiB and nothing needs
/// to: the whole property is that the refusal happens BEFORE the payload.
#[test]
fn ten_gibibytes_declared_against_one_gibibyte_left_is_refused_before_the_bytes() {
    let mut s = Vec::new();
    version(&mut s);
    // The client is already writing when the refusal is decided; two chunks is
    // what `Limits::max_drain_bytes` budgets to resynchronise on.
    put(&mut s, &oid(2), 10 * GIB, &vec![0u8; CHUNK_BYTES]);
    quit(&mut s);

    let (wire, store) = serve(
        s,
        Operation::Upload,
        Some(Allowance {
            bytes: GIB,
            refusal: REFUSAL.to_owned(),
        }),
    );
    let text = String::from_utf8_lossy(&wire);
    assert_eq!(
        statuses(&wire),
        vec![200, 507, 200],
        "a 10 GiB object on 1 GiB of room must be 507, and the session must \
         survive to answer `quit`.\nwire: {text}"
    );
    assert!(
        text.contains(REFUSAL),
        "the client must be told the plan's own sentence, naming the cap, what \
         is held and where to buy more — not `insufficient storage`, which \
         reads as the operator's disk being full.\nwire: {text}"
    );
    assert_eq!(
        store.accepted(),
        0,
        "the store saw {} bytes of an object that was refused at its \
         declaration",
        store.accepted()
    );
    assert_eq!(store.committed(), 0);
}

/// **The check a per-object limit could not make.** The allowance is the
/// SESSION's running total, so an upload split into pieces that are each
/// individually legal is still refused at the piece that crosses.
///
/// This is the assertion that separates an allowance from
/// [`Limits::max_object_bytes`]: with only the latter, a tenant with 1 GiB left
/// uploads twenty 8 GiB objects and every one of them passes, because every one
/// of them is under the per-object limit.
#[test]
fn the_allowance_is_the_sessions_running_total_and_not_a_per_object_limit() {
    // Three payloads of 16 bytes against 40 bytes of room: two fit, the third
    // crosses. Small on purpose — the arithmetic is the subject, and a fixture
    // sized in gibibytes would be a test of the machine's disk.
    let payloads: Vec<Vec<u8>> = (0u8..3).map(|n| vec![b'a' + n; 16]).collect();
    let mut s = Vec::new();
    version(&mut s);
    for payload in &payloads {
        put(&mut s, &real_oid(payload), payload.len() as u64, payload);
    }
    quit(&mut s);

    let (wire, store) = serve(
        s,
        Operation::Upload,
        Some(Allowance {
            bytes: 40,
            refusal: REFUSAL.to_owned(),
        }),
    );
    assert_eq!(
        statuses(&wire),
        vec![200, 200, 200, 507, 200],
        "16 + 16 fits in 40 and the third 16 does not; the third must be the \
         one refused.\nwire: {}",
        String::from_utf8_lossy(&wire)
    );
    assert_eq!(
        store.committed(),
        2,
        "exactly the two objects that fitted must have been committed"
    );
    assert_eq!(
        store.accepted(),
        32,
        "the store must have taken 32 bytes and not one byte of the third \
         object; it took {}",
        store.accepted()
    );
}

/// The identity half. Every assertion above is about a refusal and all of them
/// would pass against a door that refused every upload — so: the same object,
/// with room for it, and with no allowance at all (the community appliance,
/// where nobody is capped).
#[test]
fn an_object_that_fits_goes_through_and_so_does_one_on_an_uncapped_session() {
    const PAYLOAD: &[u8] = b"there is room for this\n";
    let mut s = Vec::new();
    version(&mut s);
    put(&mut s, &real_oid(PAYLOAD), PAYLOAD.len() as u64, PAYLOAD);
    quit(&mut s);

    for (what, allowance) in [
        (
            "room under the plan",
            Some(Allowance {
                bytes: PAYLOAD.len() as u64,
                refusal: REFUSAL.to_owned(),
            }),
        ),
        ("no plan at all", None),
    ] {
        let (wire, store) = serve(s.clone(), Operation::Upload, allowance);
        assert_eq!(
            statuses(&wire),
            vec![200, 200, 200],
            "{what}: the object must go through.\nwire: {}",
            String::from_utf8_lossy(&wire)
        );
        assert_eq!(store.committed(), 1, "{what}: nothing was committed");
        assert_eq!(store.accepted(), PAYLOAD.len() as u64, "{what}");
    }
}

/// **A batch says so before any of them move**, which is the whole reason the
/// LFS protocol has a batch step. A client that asks first must be told the
/// upload will not fit while it can still choose not to send 10 GiB.
///
/// Refusing the whole batch — rather than marking one object — is what the pure
/// SSH grammar can carry (`batch.rs`, `Plan::ssh_action`: there is no
/// per-object error line and any invented key is specified into being ignored),
/// and it is what this wire already does for `Limits::max_object_bytes`.
#[test]
fn an_upload_batch_that_would_not_fit_is_refused_at_the_batch() {
    let mut s = Vec::new();
    version(&mut s);
    gunnar_pktline::write_text(&mut s, b"batch").unwrap();
    gunnar_pktline::write_text(&mut s, b"hash-algo=sha256").unwrap();
    gunnar_pktline::write_delim(&mut s).unwrap();
    gunnar_pktline::write_text(&mut s, format!("{} {}", oid(1), 10 * GIB).as_bytes()).unwrap();
    gunnar_pktline::write_flush(&mut s).unwrap();
    quit(&mut s);

    let (wire, _) = serve(
        s,
        Operation::Upload,
        Some(Allowance {
            bytes: GIB,
            refusal: REFUSAL.to_owned(),
        }),
    );
    let text = String::from_utf8_lossy(&wire);
    assert_eq!(
        statuses(&wire),
        vec![200, 507, 200],
        "the batch must be refused 507.\nwire: {text}"
    );
    assert!(text.contains(REFUSAL), "wire: {text}");
}

/// **When both walls apply, the operator's is the one the client is told
/// about**, and that ordering is deliberate rather than incidental.
///
/// 413 says *"no tenant on this box may store an object that big"* — a sentence
/// that is still true after the customer pays. 507 says *"YOU have no room"* — a
/// sentence a purchase makes false in a minute. Telling a customer to buy more
/// space for an object the server would refuse at any size is the support ticket
/// that ends with a refund, so the durable refusal goes first.
///
/// This is also why the two tests above raise `max_object_bytes` to 16 GiB: at
/// the shipped 8 GiB default a 10 GiB object never reaches the tenant's wall at
/// all, and a test that did not raise it would have been asserting this
/// function's answer while claiming to assert theirs.
#[test]
fn the_operators_limit_is_answered_before_the_tenants() {
    let mut s = Vec::new();
    version(&mut s);
    put(&mut s, &oid(2), 10 * GIB, &vec![0u8; CHUNK_BYTES]);
    quit(&mut s);

    // Both are crossed: 10 GiB is over the 8 GiB the box accepts AND over the
    // 1 GiB the plan has left.
    let (wire, store) = serve_under(
        s,
        Operation::Upload,
        Some(Allowance {
            bytes: GIB,
            refusal: REFUSAL.to_owned(),
        }),
        Limits::default(),
    );
    let text = String::from_utf8_lossy(&wire);
    assert_eq!(
        statuses(&wire),
        vec![200, 413, 200],
        "with both walls crossed the operator's 413 must be the answer.\nwire: {text}"
    );
    assert!(
        !text.contains(REFUSAL),
        "the tenant must not be told to buy space for an object this box would \
         refuse at any plan size.\nwire: {text}"
    );
    assert_eq!(store.accepted(), 0);
}

/// A **download** must not see the allowance, for the same reason
/// `tests/quota.rs` asserts `max_object_bytes` does not apply to one: the
/// allowance bounds what a tenant may ACCUMULATE, and an object already stored
/// is already accumulated. A tenant at their ceiling who could not read their
/// own data back would be a tenant whose data is held hostage — which
/// `gunnar_ssh::plan` forbids by name for exactly the plan states that are
/// harsher than this one.
#[test]
fn a_download_is_never_refused_for_the_allowance() {
    let mut s = Vec::new();
    version(&mut s);
    gunnar_pktline::write_text(&mut s, b"batch").unwrap();
    gunnar_pktline::write_text(&mut s, b"hash-algo=sha256").unwrap();
    gunnar_pktline::write_delim(&mut s).unwrap();
    gunnar_pktline::write_text(&mut s, format!("{} {}", oid(1), 10 * GIB).as_bytes()).unwrap();
    gunnar_pktline::write_flush(&mut s).unwrap();
    quit(&mut s);

    let (wire, _) = serve(
        s,
        Operation::Download,
        Some(Allowance {
            bytes: 0,
            refusal: REFUSAL.to_owned(),
        }),
    );
    assert_eq!(
        statuses(&wire),
        vec![200, 200, 200],
        "a download batch must not be refused for the tenant's write \
         allowance.\nwire: {}",
        String::from_utf8_lossy(&wire)
    );
}

/// An allowance of **zero** — the tenant is exactly at their cap — refuses a
/// non-empty object and still admits an empty one.
///
/// The empty half is not a curiosity: git-lfs really does track zero-byte files,
/// and refusing one would cost a customer a push for zero bytes of storage. It
/// is also the boundary that tells `>` from `>=`, the same off-by-one
/// `tests/quota.rs` pins for `max_object_bytes`.
#[test]
fn zero_room_refuses_a_byte_and_still_takes_an_empty_object() {
    let empty = real_oid(b"");
    let mut s = Vec::new();
    version(&mut s);
    put(&mut s, &empty, 0, b"");
    put(&mut s, &oid(3), 1, b"x");
    quit(&mut s);

    let (wire, store) = serve(
        s,
        Operation::Upload,
        Some(Allowance {
            bytes: 0,
            refusal: REFUSAL.to_owned(),
        }),
    );
    assert_eq!(
        statuses(&wire),
        vec![200, 200, 507, 200],
        "an empty object fits in zero room and one byte does not.\nwire: {}",
        String::from_utf8_lossy(&wire)
    );
    assert_eq!(store.committed(), 1);
    assert_eq!(store.accepted(), 0);
}

/// **A client that charges the allowance and does not deliver gets one go at
/// it, not one per round trip** (GUN-017).
///
/// `put-object` admits against the size the client DECLARED, because the
/// payload has not arrived yet, and `ledger.rs` is a monotone counter with
/// nothing to give back. Its module comment makes that case well and rests it
/// on one assumption: that the over-charge is "over-conservative, transient,
/// and self-healing".
///
/// A declaration the client never honours is none of those. The refusal for a
/// corrupt object is 422 and 422 is not fatal, so the same connection could
/// declare, send garbage, be told 422, and declare again — charging a whole
/// plan without a byte of it landing. The counter is per NAMESPACE, so what it
/// exhausts is every other session in that namespace whose mark was taken
/// first, not only the liar's own.
///
/// This does not make the charge correct. It bounds it: one lie costs one
/// declaration and then the connection, which is the size of over-charge
/// `ledger.rs` already reasons about and calls self-healing. The honest
/// alternative — charging what was received — means admitting before the bytes
/// are counted and settling after, which is a reservation, which is the hazard
/// that file refuses by construction.
///
/// Three assertions and all three are load-bearing:
///
/// * the client IS told, with the 422 it is owed, rather than getting a dropped
///   channel — the failure `RefusedThenClosed` exists for;
/// * the SECOND `put-object` is not answered at all, which is the property;
/// * and the `quit` after it is not answered either, which is what says the
///   session ended rather than that one command was skipped.
#[test]
fn an_undelivered_declaration_costs_the_connection_and_not_just_the_object() {
    let payload = b"nowhere near a gibibyte".to_vec();
    let mut s = Vec::new();
    version(&mut s);
    // Declares a gibibyte against a gibibyte of room — admissible — and then
    // sends twenty-three bytes under the oid those twenty-three bytes really
    // hash to, so nothing but the SIZE is wrong.
    put(&mut s, &real_oid(&payload), GIB, &payload);
    // The repeat. On a session that survived the 422 this is charged another
    // gibibyte, and so is every copy after it.
    put(&mut s, &real_oid(&payload), GIB, &payload);
    quit(&mut s);

    let store = DiscardingLfsStore::new();
    let mut input = std::io::BufReader::new(std::io::Cursor::new(s));
    let mut output = Vec::new();
    let reader: &mut dyn BufRead = &mut input;
    let ledger = gunnar_lfs::AllowanceLedger::detached();
    let session = Session::new(&store, Operation::Upload)
        .with_limits(roomy())
        .with_allowance(gunnar_lfs::NamespaceAllowance::new(
            ledger.mark("team"),
            Allowance {
                bytes: GIB,
                refusal: REFUSAL.to_owned(),
            },
        ));
    let err = session
        .serve(reader, &mut output)
        .expect_err("a connection that charged for bytes it did not send must end");
    let text = String::from_utf8_lossy(&output);

    assert!(
        format!("{err}").contains("does not get to declare another"),
        "the session ended, but not because of the unpaid-for charge: {err}"
    );
    assert_eq!(
        statuses(&output),
        vec![200, 422],
        "the client must be TOLD (422, the corrupt object it sent) and then get \
         nothing more — no answer to the second put-object, and none to the \
         quit after it.\nwire: {text}"
    );
    assert_eq!(
        store.committed(),
        0,
        "nothing was supposed to LAND; the whole case is a gibibyte charged for \
         and an object that never passed verification"
    );
}

/// The identity-value guard for the test above.
///
/// A session that ended on any corrupt object would satisfy it while being a
/// different and much blunter rule — and the finding is about the ALLOWANCE, so
/// a server with no plan to exhaust has nothing to close a connection over. The
/// same conversation, the same lie, no allowance: the 422 arrives and the
/// session answers the next command and the `quit`.
#[test]
fn without_an_allowance_the_same_lie_costs_only_the_object() {
    let payload = b"nowhere near a gibibyte".to_vec();
    let mut s = Vec::new();
    version(&mut s);
    put(&mut s, &real_oid(&payload), GIB, &payload);
    put(&mut s, &real_oid(&payload), GIB, &payload);
    quit(&mut s);

    let (wire, store) = serve(s, Operation::Upload, None);
    let text = String::from_utf8_lossy(&wire);
    assert_eq!(
        statuses(&wire),
        vec![200, 422, 422, 200],
        "with no allowance there is nothing to exhaust, so a corrupt object must \
         stay what it has always been: refused, and the session lives.\nwire: {text}"
    );
    assert_eq!(store.committed(), 0, "neither object passed verification");
}