crates/gunnar-lfs/tests/refusal_delivery.rs
//! **A refusal that never reaches the client is not a refusal.**
//!
//! `tests/quota.rs` proves the decision — the store never sees a byte of an
//! over-quota object. What it cannot prove, at the size it sends, is that the
//! client is ever *told*: it writes exactly one `CHUNK_BYTES` payload, and
//! `Limits::max_drain_bytes` is two chunks, so its refusal is the only one in
//! the tree that fits inside the drain budget. Its own comment says "two
//! chunks, which is what `max_drain_bytes` budgets for" beside a script that
//! writes one.
//!
//! Everything real is on the other side of that line. A quota exists to refuse
//! objects far larger than 128 KiB, and a client refusing to stop at 128 KiB is
//! the ordinary case, not a hostile one: git-lfs writes `put-object`, its
//! arguments, the delim and then the **whole object** before it reads a byte of
//! the response. So the case this file sends — a payload past the drain budget
//! — is the only one that ever happens in production, and it was the one that
//! answered nothing at all.
//!
//! Every case asserts the **status code on the wire**, not a returned `Result`:
//! the returned error was correct all along, and it went nowhere.
use std::io::BufRead;
use gunnar_lfs::{DiscardingLfsStore, Limits, Oid, Operation, Session, CHUNK_BYTES};
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()
}
/// Every `status <n>` line the server put on the wire, in order.
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 a conversation that is **expected to end the session**, and hand back
/// what was written before it did.
///
/// `quota.rs`'s helper `.expect()`s the session, which is right for the
/// conversations it drives and wrong for these: the session ending is part of
/// the contract here, and what is under test is what went out first.
fn serve_until_close(script: Vec<u8>, operation: Operation) -> (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 _ = Session::new(&store, operation).serve(reader, &mut output);
(output, store)
}
/// A `put-object` whose payload runs past the drain budget.
fn oversized_put(size: u64, chunks: usize) -> 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, format!("put-object {}", oid(2)).as_bytes()).unwrap();
gunnar_pktline::write_text(&mut s, format!("size={size}").as_bytes()).unwrap();
gunnar_pktline::write_delim(&mut s).unwrap();
for _ in 0..chunks {
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();
s
}
/// **The defect.** The client is refused for size and keeps writing, as every
/// real client does. The 413 must still go out.
#[test]
fn an_over_quota_put_is_told_413_even_when_the_client_will_not_stop() {
let drain_budget = Limits::default().max_drain_bytes;
let chunks = (drain_budget as usize / CHUNK_BYTES) + 2;
assert!(
(chunks * CHUNK_BYTES) as u64 > drain_budget,
"this case must exceed the drain budget or it is the case quota.rs \
already sends"
);
let (wire, store) = serve_until_close(oversized_put(EIGHT_GIB + 1, chunks), Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 413],
"the refusal never reached the client; it got a closed channel.\nwire: {}",
String::from_utf8_lossy(&wire).escape_debug()
);
assert!(
String::from_utf8_lossy(&wire).contains("accepts at most"),
"the 413 carries no reason:\n{}",
String::from_utf8_lossy(&wire).escape_debug()
);
// The decision itself is unchanged: still nothing reaches the store.
assert_eq!(store.accepted(), 0);
assert_eq!(store.committed(), 0);
}
/// The same for a refusal that is **not** about size, so the fix is not a
/// special case bolted onto the quota. A `download` grant cannot `put-object`,
/// and that 405 has the identical delivery problem.
#[test]
fn a_wrong_operation_put_is_told_405_even_when_the_client_will_not_stop() {
let drain_budget = Limits::default().max_drain_bytes;
let chunks = (drain_budget as usize / CHUNK_BYTES) + 2;
// A size well under the quota: the only reason to refuse is the grant.
let (wire, store) = serve_until_close(oversized_put(16, chunks), Operation::Download);
assert_eq!(
statuses(&wire),
vec![200, 405],
"an authorisation refusal was delivered as a dropped channel.\nwire: {}",
String::from_utf8_lossy(&wire).escape_debug()
);
assert_eq!(store.accepted(), 0);
}
/// **The identity-value half, in two directions.**
///
/// A client that respects the drain budget must still get its 413 *and* keep
/// the session — `quit` is answered, which is the third `200`. Without this,
/// every assertion above would hold against a server that closed on every
/// refusal, which is not the behaviour being asked for.
#[test]
fn a_refusal_the_server_can_resynchronise_after_does_not_end_the_session() {
let store = DiscardingLfsStore::new();
// One chunk, inside the two-chunk budget.
let script = oversized_put(EIGHT_GIB + 1, 1);
let mut input = 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::Upload)
.serve(reader, &mut output)
.expect("a drain that stayed inside its budget must not end the session");
assert_eq!(
statuses(&output),
vec![200, 413, 200],
"wire: {}",
String::from_utf8_lossy(&output).escape_debug()
);
}
/// And the other identity: an upload that is not refused at all still goes
/// through, so none of the above is passing against a server that refuses
/// everything.
#[test]
fn an_accepted_upload_is_unaffected() {
const PAYLOAD: &[u8] = b"delivered\n";
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 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;
Session::new(&store, Operation::Upload)
.serve(reader, &mut output)
.expect("an accepted upload must not fail the session");
assert_eq!(statuses(&output), vec![200, 200, 200]);
assert_eq!(store.committed(), 1);
}