1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! **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);
}