crates/gunnar-server/tests/pack_ceiling_store.rs
//! **The mid-upload cut against the REAL store, and what the repository looks
//! like afterwards.**
//!
//! `gunnar-wire/tests/pack_ceiling.rs` proves the ceiling stops the transfer
//! and proves it against a byte count, which is the row. It proves it against
//! `MemGitServe`, where "nothing was stored" costs nothing to be true. This
//! file asks the question that only a real engine can answer: **a push aborted
//! halfway through must leave the repository exactly as it was, and usable.**
//!
//! That is not free. The shipping ingest
//! (`storage::read_pushed_pack_spooling`) reads the pack off the wire into a
//! spool file beside the archive and only then calls `put_pack`, so an abort
//! mid-read has to leave no journal row, no archive growth and no spool file —
//! three separate ways to leave litter, and the last one is a **full-size**
//! file under a name nothing sweeps.
//!
//! # Red first — MEASURED 2026-09-05
//!
//! With `Counting`'s two guards removed from `gunnar-wire`, this test fails on
//! the byte count:
//!
//! ```text
//! assertion `left == right` failed: the engine was handed 1049215 of 1049215
//! offered bytes, not the 65726 the 65536-byte ceiling allows
//! left: 1049215
//! right: 65726
//! ```
//!
//! …and with that assertion stood down, so is everything under it. The push
//! reports `unpack = Ok(())` and the repository grows by **1 049 250 bytes**:
//! the whole megabyte was read, spooled and appended to the archive of a
//! tenant who was over their cap by the second byte of it.
use std::io::BufRead;
use std::sync::Arc;
use gunnar_core::{NewRepo, RepoRef};
use gunnar_server::control_plane::repo_bytes;
use gunnar_server::storage::ObjectSet;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_wire::receive_pack::{AllowAll, Config};
use gunnar_wire::{PackCeiling, ReceivePack, RefStatus};
const CEILING: u64 = 64 * 1024;
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";
/// A pack of incompressible blobs — see the note in
/// `gunnar-wire/tests/pack_ceiling.rs`: `PackBuilder` deflates, so a fixture of
/// repeated bytes would make this a test of zlib.
fn incompressible_pack(kind: gix_hash::Kind, blobs: usize, size: usize) -> Vec<u8> {
let mut pb = PackBuilder::new(kind);
let mut state: u64 = 0x2545_F491_4F6C_DD1D;
for _ in 0..blobs {
let mut body = vec![0u8; size];
for byte in body.iter_mut() {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*byte = (state >> 33) as u8;
}
pb.object(gix_object::Kind::Blob, body);
}
pb.build().expect("a pack of whole objects is legal").bytes
}
/// The v0 push preamble: one ref command, then a flush, then the pack.
///
/// Framed by hand rather than through `gunnar-pktline`, which `gunnar-server`
/// does not depend on and should not grow a dependency on for one test line.
/// The framing itself is `gunnar-wire`'s business and is pinned byte-for-byte
/// in `gunnar-wire/tests/receive_pack_wire_bytes.rs`; this is a fixture, not a
/// second implementation of anything (one implementation).
fn push_bytes(old: &str, new: &str, name: &str, caps: &str, pack: &[u8]) -> Vec<u8> {
let mut line = format!("{old} {new} {name}").into_bytes();
line.push(0);
line.extend_from_slice(caps.as_bytes());
line.push(b'\n');
let mut out = format!("{:04x}", line.len() + 4).into_bytes();
out.extend_from_slice(&line);
out.extend_from_slice(b"0000");
out.extend_from_slice(pack);
out
}
/// A reader that records what was taken out of it. The applied output.
struct Tracked {
inner: std::io::Cursor<Vec<u8>>,
taken: u64,
}
impl std::io::Read for Tracked {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let read = std::io::Read::read(&mut self.inner, buf)?;
self.taken += read as u64;
Ok(read)
}
}
impl BufRead for Tracked {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
BufRead::fill_buf(&mut self.inner)
}
fn consume(&mut self, amount: usize) {
BufRead::consume(&mut self.inner, amount);
self.taken += amount as u64;
}
}
/// Everything under `git_dir` that the spool would have left behind.
fn spool_leftovers(git_dir: &std::path::Path) -> Vec<String> {
let root = git_dir.join("gunnar-git.znippy");
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with("incoming-"))
.collect()
}
#[test]
fn a_cut_push_stores_nothing_and_leaves_the_repository_usable() {
let kind = gix_hash::Kind::Sha256;
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("team")
.expect("namespace");
let path = RepoRef::new("team", "one").expect("legal");
let repo = stores
.create_repo(NewRepo::new(path.clone()), false)
.expect("create");
let handle = stores.repo_store(&repo).expect("open");
assert_eq!(
handle.hash_kind(),
kind,
"the fixture pack and the repository must agree on the hash"
);
let git_dir = handle.git_dir().to_path_buf();
let before = repo_bytes(&git_dir);
// ── the push: a megabyte offered against a 64 KiB ceiling ─────────────
let pack = incompressible_pack(kind, 8, 128 * 1024);
assert!(
pack.len() as u64 > CEILING * 8,
"the fixture must dwarf the ceiling: {} bytes",
pack.len()
);
let zero = kind.null().to_string();
// The oid on the command line is one of the pack's own blobs. It does not
// matter whether a ref may point at a blob: the assertion below is that the
// reference did NOT move, and the accept twin lives in
// `gunnar-wire/tests/pack_ceiling.rs` where a real store is not in the way.
let input = push_bytes(
&zero,
&gix_hash::ObjectId::empty_blob(kind).to_string(),
"refs/heads/main",
"report-status object-format=sha256",
&pack,
);
let offered = input.len() as u64;
let mut reader = Tracked {
inner: std::io::Cursor::new(input),
taken: 0,
};
// **The shipping ingest**, not a test double: this is the spooling reader
// and `put_pack` the appliance runs, which is the whole point of doing this
// once outside `gunnar-wire`.
let engine = stores.engine();
let ingest = |pack: &mut dyn BufRead| -> gunnar_wire::Result<gunnar_wire::pack::IndexedPack> {
let received = engine
.ingest_push(&git_dir, kind, pack, ObjectSet::NotNeeded, None, None)
.map_err(|err| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!("{err:#}")))
})?;
Ok(gunnar_wire::pack::IndexedPack {
num_objects: received.num_objects,
pack_id: gix_hash::ObjectId::try_from(received.pack_id.as_slice())
.expect("a pack trailer is an object id"),
index_id: gix_hash::ObjectId::null(kind),
pack_version: gunnar_wire::pack::PackVersion::from_u32(received.version),
hash_kind: kind,
index_path: None,
data_path: None,
keep_path: None,
stored_bytes: Some(received.pack_bytes),
})
};
let rp = ReceivePack::new(handle.serve(), kind).with_config(Config {
pack_ceiling: Some(PackCeiling {
bytes: CEILING,
refusal: REFUSAL.to_owned(),
// One push against the real engine, so no shared standing to hold
// it to: what this file proves is that a CUT push stores nothing,
// and the cut is the same cut either way. The namespace's ledger is
// proved in `gunnar-wire/tests/concurrent_pack_ceiling.rs` (the
// budget) and `gunnar-ssh/tests/pack_ledger.rs` (that the front
// door produces the standing at all).
ledger: None,
}),
..Config::default()
});
let mut out = Vec::new();
let outcome = rp
.serve(&mut reader, &mut out, &ingest, &AllowAll)
.expect("serve")
.expect("a push happened");
// ── 1. the engine was cut mid-stream ──────────────────────────────────
let commands = offered - pack.len() as u64;
assert_eq!(
reader.taken,
commands + CEILING + 1,
"the engine was handed {} of {offered} offered bytes, not the {} the {CEILING}-byte \
ceiling allows",
reader.taken,
commands + CEILING + 1
);
assert_eq!(outcome.unpack.as_ref().unwrap_err(), REFUSAL);
assert!(matches!(outcome.statuses[0], RefStatus::Rejected(_)));
// ── 2. nothing was stored ─────────────────────────────────────────────
let after = repo_bytes(&git_dir);
assert_eq!(
after.pack,
before.pack,
"the cut push grew the repository by {} bytes; the spool or the archive kept it",
after.pack.saturating_sub(before.pack)
);
assert_eq!(
spool_leftovers(&git_dir),
Vec::<String>::new(),
"a full-size spool file survived the abort, under a name nothing sweeps"
);
assert!(
handle.serve().refs().expect("list refs").is_empty(),
"the cut push moved a reference"
);
// ── 3. the repository still works ─────────────────────────────────────
//
// The assertion that separates "nothing was stored" from "the store was
// wedged": a `put_pack` straight into the same open handle, and the object
// count that follows it. A store left mid-transaction by the abort would
// fail here, and every assertion above would still have been green.
let mut pb = PackBuilder::new(kind);
let good = pb.object(
gix_object::Kind::Blob,
b"the push after the refused one".to_vec(),
);
assert_eq!(good, 0);
let good_pack = pb.build().expect("pack");
handle
.serve()
.put_pack(&good_pack.bytes)
.expect("the repository takes a push after an aborted one");
let after_good = repo_bytes(&git_dir);
assert!(
after_good.pack > after.pack,
"the good push stored nothing: {} then {}",
after.pack,
after_good.pack
);
drop(handle);
stores.close_repo(&path);
}