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