crates/gunnar-wire/tests/pack_ceiling.rs
//! **A push that crosses its byte ceiling is cut DURING the transfer.**
//!
//! # The hole this covers
//!
//! `git-receive-pack`'s pack header carries an **object count, not a byte
//! count**, so the size of a push is unknown at the moment it is authorised.
//! Everything gunnar had was on one side or the other of the transfer:
//! `gunnar_ssh::PlanGate::pack_quota` refuses a tenant who is *already* at
//! `caps["pack_bytes"]`, before a byte moves, and
//! `gunnar_policy::gates::limits` refuses a pack that has *already been
//! received* — its own header says so plainly: *"this gate rejects before the
//! references are applied … not before the bytes arrive"*. Between them a
//! tenant one byte under a 10 GiB cap could send 400 GiB, and the appliance
//! would read, spool and index every byte of it before anything looked.
//!
//! [`gunnar_wire::PackCeiling`] is the thing that was missing, and this file is
//! its proof. **The distinction that matters is that the refusal happens in
//! flight**: a test that only asserted "the push was rejected" would pass
//! against a server that read all 400 GiB and then said no, which is a
//! different bug and not this one. So every assertion here is about applied
//! output — **how many bytes the client was actually asked for** — and not
//! about the verdict alone.
//!
//! # Every "is cut" has its "is not cut" twin
//!
//! A guard that refused every push would satisfy the first test in this file
//! and nothing else. So:
//!
//! * the same push under a ceiling ABOVE its size lands, and the whole pack is
//! read (`a_push_under_its_ceiling_lands_and_the_whole_pack_is_read`);
//! * a push of exactly the ceiling lands, because the ceiling is the last byte
//! that is allowed and not the first that is not
//! (`a_push_of_exactly_the_ceiling_lands`);
//! * with no ceiling configured — the community default, and every gunnar
//! before 2026-09-05 — the same oversized push lands untouched
//! (`with_no_ceiling_the_same_push_lands`).
//!
//! # What was measured before the fix
//!
//! With the two guards in `Counting::read`/`Counting::fill_buf` removed and
//! everything else in place, `a_push_over_its_ceiling_is_cut_mid_transfer`
//! fails on its first assertion, MEASURED 2026-09-05:
//!
//! ```text
//! assertion `left == right` failed: the server took 1049154 of 1049154
//! offered bytes; it must stop one byte past the 65536-byte ceiling (after
//! 140 bytes of command list), not read the push to its end
//! left: 1049154
//! right: 65677
//! ```
//!
//! Every other test in this file stayed green through that removal, which is
//! the point: they are the "is not cut" half and they cannot tell a working
//! ceiling from a missing one. Only the byte count can.
mod common;
use gix_hash::Kind;
use gunnar_store::git_storage_trait::GitOps;
use gunnar_wire::receive_pack::{AllowAll, Config};
use gunnar_wire::{PackCeiling, ReceivePack, RefStatus};
use common::{incompressible_pack, push_bytes, validating_ingest, Fixture, Tracked};
/// The ceiling every test here is held to, unless it says otherwise.
const CEILING: u64 = 64 * 1024;
/// What the tenant is told. Composed by the caller in production
/// (`gunnar_server::entitlement`, which names the plan, the cap and the billing
/// URL); a literal here, because the point of the field is that `gunnar-wire`
/// passes it through without knowing what a plan is.
const REFUSAL: &str = "plan 10gb would pass its pack quota with this push (cap 10737418240 \
bytes, 10737352704 already held, this push sent more than 65536 bytes): \
the transfer was stopped and nothing was stored; delete or buy more at \
https://gunnar.rs/billing/team";
struct Pushed {
outcome: gunnar_wire::PushOutcome,
/// Bytes the server took off the wire, command list included.
taken: u64,
/// Bytes the client offered in total.
offered: u64,
/// The bytes the client was sent back.
report: String,
}
/// Push `pack` at `refs/heads/fresh` under `ceiling`, and report what the
/// server actually consumed.
fn push(fixture: &Fixture, pack: &[u8], ceiling: Option<PackCeiling>) -> Pushed {
let zero = Kind::Sha1.null().to_string();
// `refs/heads/fresh` at an object the fixture ALREADY holds: the pack's
// contents are not the subject here and a connectivity failure would be a
// second reason for the push to be refused, which would make a refusal
// prove nothing.
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/fresh")],
"report-status object-format=sha1",
pack,
);
let offered = input.len() as u64;
let mut reader = Tracked::new(input);
let rp = ReceivePack::new(&fixture.git, fixture.kind).with_config(Config {
pack_ceiling: ceiling,
..Config::default()
});
let mut out = Vec::new();
let outcome = rp
.serve(
&mut reader,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("serve")
.expect("a push happened");
Pushed {
outcome,
taken: reader.taken,
offered,
report: String::from_utf8_lossy(&out).into_owned(),
}
}
fn ceiling(bytes: u64) -> Option<PackCeiling> {
Some(PackCeiling {
bytes,
refusal: REFUSAL.to_owned(),
// **No ledger, and that is this file's subject.** One push against its
// own number is what these tests are about, and it is what a build with
// no shared ledger still does. The byte counts asserted below are
// therefore the unmetered arithmetic — which is exactly why they are
// worth asserting after the ledger landed: `Counting::admit` returns
// what it was asked for when there is nothing to ask, so if any of
// these numbers had moved, the ledger would have changed a path it must
// not touch. `tests/concurrent_pack_ceiling.rs` is where the shared
// budget is proved.
ledger: None,
})
}
// ── the row: a push that crosses the ceiling is stopped in flight ──────────
#[test]
fn a_push_over_its_ceiling_is_cut_mid_transfer() {
let f = Fixture::build(Kind::Sha1);
let pack = incompressible_pack(Kind::Sha1, 8, 128 * 1024);
assert!(
pack.len() as u64 > CEILING * 8,
"the fixture pack must dwarf the ceiling or this test proves nothing: {} bytes",
pack.len()
);
let pushed = push(&f, &pack, ceiling(CEILING));
// ── 1. the transfer stopped DURING the pack, not after it ─────────────
//
// The number, not an inequality: the server takes the command list, then
// the ceiling, then the one byte that trips it, and then nothing. An
// inequality here would pass for a server that read a hundred megabytes
// more than it had to, and "how much of a hostile upload did we take"
// is the entire question this row was opened about.
let commands = pushed.offered - pack.len() as u64;
assert_eq!(
pushed.taken,
commands + CEILING + 1,
"the server took {} of {} offered bytes; it must stop one byte past the {CEILING}-byte \
ceiling (after {commands} bytes of command list), not read the push to its end",
pushed.taken,
pushed.offered
);
assert!(
pushed.taken < pushed.offered / 4,
"the client was still asked for {} of {} bytes — that is a refusal AFTER the transfer, \
which is a different bug",
pushed.taken,
pushed.offered
);
// ── 2. the tenant is told a sentence they can act on ──────────────────
assert_eq!(
pushed.outcome.unpack.as_ref().unwrap_err(),
REFUSAL,
"the client got the indexer's opinion of a truncated stream instead of the ceiling's \
own line"
);
assert!(
pushed.report.contains(REFUSAL),
"the refusal never reached the wire; the client read: {:?}",
pushed.report
);
assert!(
pushed.report.contains("https://gunnar.rs/billing/team"),
"a refusal that does not say where to pay: {:?}",
pushed.report
);
// ── 3. nothing was stored and nothing moved ───────────────────────────
assert!(
pushed.outcome.pack.is_none(),
"a cut push reported an indexed pack: {:?}",
pushed.outcome.pack
);
assert!(
pushed.outcome.new_objects.is_err(),
"a cut push must not claim to know which objects it introduced"
);
assert!(
matches!(pushed.outcome.statuses[0], RefStatus::Rejected(_)),
"the reference was accepted on a push that never arrived: {:?}",
pushed.outcome.statuses
);
assert!(
!pushed.outcome.all_ok(),
"a cut push reported success: {:?}",
pushed.outcome
);
let refs = f.git.refs().expect("list refs");
assert!(
!refs.iter().any(|row| row.name == "refs/heads/fresh"),
"the cut push moved a reference: {refs:?}"
);
// The repository is exactly what it was. The fixture's three references
// are named rather than counted, because a count of three would also hold
// for three references that had all been rewritten.
let mut names: Vec<&str> = refs.iter().map(|row| row.name.as_str()).collect();
names.sort_unstable();
assert_eq!(
names,
["refs/heads/main", "refs/heads/side", "refs/tags/v1"],
"the repository is not what it was before the cut push"
);
// ── 4. the count the operator's log and the bill read is the real one ──
assert_eq!(
pushed.outcome.pack_bytes,
Some(CEILING + 1),
"`pack_bytes` must be what came off the wire, which is what the ceiling stopped at"
);
}
// ── the twins: what is NOT cut ─────────────────────────────────────────────
#[test]
fn a_push_under_its_ceiling_lands_and_the_whole_pack_is_read() {
let f = Fixture::build(Kind::Sha1);
let pack = incompressible_pack(Kind::Sha1, 8, 128 * 1024);
let generous = pack.len() as u64 * 2;
let pushed = push(&f, &pack, ceiling(generous));
assert_eq!(
pushed.outcome.unpack,
Ok(()),
"a push well under its ceiling was refused"
);
assert_eq!(pushed.outcome.statuses[0], RefStatus::Ok);
assert_eq!(
pushed.taken, pushed.offered,
"the whole push must be read when nothing stops it"
);
assert_eq!(pushed.outcome.pack_bytes, Some(pack.len() as u64));
assert!(f
.git
.refs()
.expect("list refs")
.iter()
.any(|row| row.name == "refs/heads/fresh"));
}
#[test]
fn a_push_of_exactly_the_ceiling_lands() {
// The boundary is not decoration: a hard cap with zero slack refuses the
// push that would land exactly at the limit, and that is the refusal a
// tenant cannot understand. `Counting::over` is `>` and this is what says
// so — flip it to `>=` and this test goes red while every other test in
// the file stays green.
let f = Fixture::build(Kind::Sha1);
let pack = incompressible_pack(Kind::Sha1, 4, 64 * 1024);
let exact = pack.len() as u64;
let pushed = push(&f, &pack, ceiling(exact));
assert_eq!(
pushed.outcome.unpack,
Ok(()),
"a push of exactly {exact} bytes under a {exact}-byte ceiling was refused"
);
assert_eq!(pushed.taken, pushed.offered);
}
#[test]
fn with_no_ceiling_the_same_push_lands() {
// The community build, and every gunnar before this ceiling existed. A
// `None` here must mean what it always meant — read the pack to its end —
// and not "cut at zero".
let f = Fixture::build(Kind::Sha1);
let pack = incompressible_pack(Kind::Sha1, 8, 128 * 1024);
let pushed = push(&f, &pack, None);
assert_eq!(pushed.outcome.unpack, Ok(()));
assert_eq!(pushed.outcome.statuses[0], RefStatus::Ok);
assert_eq!(pushed.taken, pushed.offered);
}
#[test]
fn a_deletion_carries_no_pack_and_is_never_cut() {
// A ceiling of one byte, and a `git push --delete`, which sends no pack at
// all. The rule — a deletions-only push is a MEASURED zero, not an
// absent measurement — must survive the ceiling: a guard that refused this
// would make a full tenant unable to free space, which is the one thing
// they must always be able to do.
let f = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&f.three.to_string(), &zero, "refs/heads/side")],
"report-status object-format=sha1",
&[],
);
let mut reader = Tracked::new(input);
let rp = ReceivePack::new(&f.git, f.kind).with_config(Config {
pack_ceiling: ceiling(1),
..Config::default()
});
let mut out = Vec::new();
let outcome = rp
.serve(
&mut reader,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("serve")
.expect("a push happened");
assert_eq!(
outcome.unpack,
Ok(()),
"a deletion was cut by a byte ceiling it never sent a byte against"
);
assert_eq!(outcome.statuses[0], RefStatus::Ok);
assert_eq!(outcome.pack_bytes, Some(0));
assert!(!f
.git
.refs()
.expect("list refs")
.iter()
.any(|row| row.name == "refs/heads/side"));
}