crates/gunnar-lfs/tests/quota.rs
//! The object quota, **at its real default value**.
//!
//! `limits.rs` had one test, and it asserted the defaults were non-zero. That
//! cannot fail for any plausible value and it never touches the arithmetic. The
//! quota is 8 GiB, which is past `u32`, past `i32`, and the place where a
//! `as u32` or an `as i32` anywhere on the declaration path stops being
//! invisible — so it is the value the boundary has to be tested at, not a
//! convenient small one.
//!
//! Nothing here moves 8 GiB. It does not need to: **every refusal in this file
//! happens before a byte of payload is read**, which is the property being
//! asserted. A quota that only refused after taking the bytes would be a quota
//! that protects nothing, and it would pass a test that merely checked the
//! final answer.
use std::io::BufRead;
use gunnar_lfs::{DiscardingLfsStore, Limits, Oid, Operation, Session, CHUNK_BYTES};
/// The shipped default, spelled out. Compared against `Limits::default()` below
/// rather than read from it, so a change to the constant fails here loudly
/// instead of silently retargeting every assertion in this file.
const EIGHT_GIB: u64 = 8 * 1024 * 1024 * 1024;
fn oid(seed: u8) -> Oid {
let c = char::from_digit(seed as u32 % 16, 16).unwrap();
Oid::parse(&String::from(c).repeat(64)).unwrap()
}
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 and hand back the wire plus the store.
fn serve(script: Vec<u8>, operation: Operation) -> (Vec<u8>, DiscardingLfsStore) {
let store = DiscardingLfsStore::new();
let mut input: std::io::BufReader<std::io::Cursor<Vec<u8>>> =
std::io::BufReader::new(std::io::Cursor::new(script));
let mut output = Vec::new();
let reader: &mut dyn BufRead = &mut input;
Session::new(&store, operation)
.serve(reader, &mut output)
.expect("a well-framed conversation must not fail the session");
(output, store)
}
fn batch_script(size: u64) -> Vec<u8> {
let mut s = Vec::new();
gunnar_pktline::write_text(&mut s, b"version 1").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
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!("{} {size}", oid(1)).as_bytes()).unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, b"quit").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
s
}
#[test]
fn the_default_quota_is_eight_gibibytes_and_the_boundary_is_exact() {
assert_eq!(
Limits::default().max_object_bytes,
EIGHT_GIB,
"the shipped quota changed; every assertion in this file targets 8 GiB"
);
// Exactly at the quota: accepted. `>` and not `>=` — an object of exactly
// the permitted size is permitted, and an off-by-one here silently costs a
// customer their largest legal upload.
let (wire, _) = serve(batch_script(EIGHT_GIB), Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 200, 200],
"an object of exactly the quota must be accepted.\nwire: {}",
String::from_utf8_lossy(&wire)
);
// One byte over: 413, and the whole batch is refused.
let (wire, _) = serve(batch_script(EIGHT_GIB + 1), Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 413, 200],
"one byte over the quota must be 413.\nwire: {}",
String::from_utf8_lossy(&wire)
);
assert!(
String::from_utf8_lossy(&wire).contains("8589934592"),
"the refusal must state the limit in bytes so an operator can act on it"
);
}
/// The same boundary on a `download` session must **not** fire. The quota is a
/// limit on what this server will accept, not on what it will serve — an object
/// already stored is served whatever its size, and applying the check in both
/// directions would make a lowered quota retroactively hide existing objects.
#[test]
fn the_quota_does_not_apply_to_a_download_batch() {
let (wire, _) = serve(batch_script(EIGHT_GIB * 4), Operation::Download);
assert_eq!(
statuses(&wire),
vec![200, 200, 200],
"a download batch must not be refused for size.\nwire: {}",
String::from_utf8_lossy(&wire)
);
}
/// **`put-object` is the load-bearing check**, because a client may skip `batch`
/// entirely. Refused before the payload: the store is never asked to begin a
/// write, and not one byte reaches it.
#[test]
fn put_object_over_quota_is_refused_before_the_store_is_touched() {
let mut s = Vec::new();
gunnar_pktline::write_text(&mut s, b"version 1").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, format!("put-object {}", oid(2)).as_bytes()).unwrap();
gunnar_pktline::write_text(&mut s, format!("size={}", EIGHT_GIB + 1).as_bytes()).unwrap();
gunnar_pktline::write_delim(&mut s).unwrap();
// The client is already writing when the refusal is decided. Two chunks,
// which is what `max_drain_bytes` budgets for.
gunnar_pktline::write_data(&mut s, &vec![0u8; CHUNK_BYTES]).unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, b"quit").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
let (wire, store) = serve(s, Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 413, 200],
"wire: {}",
String::from_utf8_lossy(&wire)
);
assert_eq!(
store.accepted(),
0,
"the refusal must come before the store sees a byte; it saw {}",
store.accepted()
);
assert_eq!(store.committed(), 0);
// And the session resynchronised: `quit` after the drained payload was still
// read as `quit`, which is what the third 200 above is.
}
/// The identity-value half for the whole file. Every assertion above is about a
/// refusal, and all of them would pass against a server that refused every
/// upload. One byte under the quota, actually transferred, actually committed.
#[test]
fn an_object_under_the_quota_still_goes_through() {
const PAYLOAD: &[u8] = b"under the quota\n";
// sha256 of PAYLOAD, from coreutils, not from gunnar.
let mut hasher = gix_hash::hasher(gix_hash::Kind::Sha256);
hasher.update(PAYLOAD);
let real = match hasher.try_finalize().unwrap() {
gix_hash::ObjectId::Sha256(d) => Oid::from_digest(d),
other => unreachable!("{other:?}"),
};
let mut s = Vec::new();
gunnar_pktline::write_text(&mut s, b"version 1").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, format!("put-object {real}").as_bytes()).unwrap();
gunnar_pktline::write_text(&mut s, format!("size={}", PAYLOAD.len()).as_bytes()).unwrap();
gunnar_pktline::write_delim(&mut s).unwrap();
gunnar_pktline::write_data(&mut s, PAYLOAD).unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, b"quit").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
let (wire, store) = serve(s, Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 200, 200],
"wire: {}",
String::from_utf8_lossy(&wire)
);
assert_eq!(store.accepted(), PAYLOAD.len() as u64);
assert_eq!(store.committed(), 1, "the object must have been committed");
}
/// A declared size at the top of `u64` must be refused as too large, not wrap.
///
/// `VerifyingSink::accept` uses `saturating_add` precisely so a hostile
/// declaration cannot make the running total wrap past the claim; this asserts
/// the declaration itself never gets that far.
#[test]
fn an_absurd_declared_size_is_refused_and_does_not_wrap() {
for size in [u64::MAX, u64::MAX - 1, i64::MAX as u64, EIGHT_GIB * 1024] {
let (wire, store) = serve(batch_script(size), Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 413, 200],
"size {size} must be refused 413.\nwire: {}",
String::from_utf8_lossy(&wire)
);
assert_eq!(store.accepted(), 0);
}
}
/// A configured quota is honoured, not just the default — otherwise every test
/// above proves only that one hardcoded number works.
#[test]
fn a_configured_quota_replaces_the_default_in_both_checks() {
let store = DiscardingLfsStore::new();
let limits = Limits::new().max_object_bytes(1024);
let mut input = std::io::BufReader::new(std::io::Cursor::new(batch_script(1025)));
let mut output = Vec::new();
let reader: &mut dyn BufRead = &mut input;
Session::new(&store, Operation::Upload)
.with_limits(limits)
.serve(reader, &mut output)
.unwrap();
assert_eq!(statuses(&output), vec![200, 413, 200]);
assert!(String::from_utf8_lossy(&output).contains("1024"));
// And exactly at the configured limit, accepted — the boundary moved with
// the setting rather than staying at 8 GiB.
let mut input = std::io::BufReader::new(std::io::Cursor::new(batch_script(1024)));
let mut output = Vec::new();
let reader: &mut dyn BufRead = &mut input;
Session::new(&store, Operation::Upload)
.with_limits(limits)
.serve(reader, &mut output)
.unwrap();
assert_eq!(statuses(&output), vec![200, 200, 200]);
}