crates/gunnar-wire/tests/hostile.rs
//! **What a client that is not stock `git` can say.**
//!
//! `real_git_interop.rs` and `real_git_surface.rs` prove gunnar works when the
//! peer is `/usr/bin/git`. They cannot prove anything about a peer that is not,
//! because stock git will not send a malformed ref name, a truncated pack, or a
//! header claiming a four-gigabyte object — it refuses those on its own side,
//! and a server that trusted git to keep doing so is a server with no input
//! validation at all.
//!
//! So these are driven over `Vec<u8>`, where the bytes can be anything.
//!
//! # Every refusal is paired with an acceptance
//!
//! A server that refuses every push passes any test that only asserts a
//! refusal, which is why [`hostile_ref_names_are_refused`] pushes the
//! `ACCEPTED` table through the **same code path** in the same test, and why
//! the corrupt-pack cases each build their fixture, prove the *intact* pack is
//! taken, and only then damage it. The assertion is never "it failed"; it is
//! "it failed on this input and succeeded on that one".
//!
//! # Refusal means the reference did not move
//!
//! An error return is not the property that matters. A server can refuse a
//! push and still have written the ref, and that is the bug worth catching, so
//! every refusal here also asserts the store afterwards.
mod common;
use gix_hash::Kind;
use gunnar_pack::{IndexOptions, DEFAULT_ALLOC_LIMIT_BYTES};
use gunnar_store::git_storage_trait::GitServe;
use gunnar_wire::receive_pack::AllowAll;
use gunnar_wire::{ProtocolVersion, ReceivePack, UploadPack};
use common::{empty_pack, limited_ingest, push_bytes, validating_ingest, Fixture, Response};
/// Run one push and report whether the server took it, plus what it said.
fn push(fixture: &Fixture, commands: &[(&str, &str, &str)], pack: &[u8]) -> (bool, String) {
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let input = push_bytes(
commands,
"report-status object-format=sha1 agent=test/1",
pack,
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
match receive.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
) {
Ok(_) => {
let response = Response::parse(&out);
let lines = response.lines().join(" | ");
// **`serve` returning `Ok` is not the push being applied.** A pack
// the ingest refused comes back as an `unpack <error>` line and a
// per-ref refusal as `ng <ref> <reason>`, both inside a session
// that succeeded — that IS the report-status protocol. A test that
// read the return value alone would call every one of those a
// successful push.
let accepted = lines.contains("unpack ok") && !lines.contains("ng ");
(accepted, lines)
}
Err(err) => (false, format!("Err: {err}")),
}
}
/// Does a reference exist in the store, by raw bytes?
///
/// Raw bytes rather than a validated name type, because half the point of this
/// file is names the validator refuses to build, and a probe that could only
/// ask about legal names could not see an illegal one that got written anyway.
///
/// The contract's `RefRow::name` is a `String`, so what comes back is UTF-8 by
/// construction; the byte comparison is kept anyway because the *question* is
/// about bytes and the day that type widens (see
/// `receive_pack_wire_bytes.rs`'s non-UTF-8 case) this keeps asking the same
/// thing.
fn stored_names(fixture: &Fixture) -> Vec<Vec<u8>> {
fixture
.ref_names()
.into_iter()
.map(|name| name.into_bytes())
.collect()
}
fn holds(fixture: &Fixture, name: &str) -> bool {
stored_names(fixture)
.iter()
.any(|stored| stored == name.as_bytes())
}
// ── reference names ─────────────────────────────────────────────────────────
/// Names `git check-ref-format` rejects, and the reason gunnar's validator
/// gives. `refs/heads/../../etc/passwd` is the one that matters most: a `..`
/// component is how a path sandbox gets walked out of, and the `fs` backend
/// turns a ref name into a file path.
const REFUSED: &[(&str, &str)] = &[
("", "an empty name"),
("heads/main", "no refs/ prefix"),
("refs/heads/a..b", "a `..` component"),
("refs/heads/../../etc/passwd", "a sandbox escape"),
("refs/heads/x@{0}", "an `@{` reflog selector"),
("refs/heads/x.lock", "a `.lock` suffix"),
("refs/heads/.hidden", "a leading dot"),
("refs/heads/trailing.", "a trailing dot"),
("refs/heads/trailing/", "a trailing slash"),
("refs/heads/a//b", "an empty path component"),
("refs/heads/with space", "a space"),
("refs/heads/with\ttab", "a control character"),
("refs/heads/with\nnewline", "a newline"),
("refs/heads/star*", "a `*`"),
("refs/heads/colon:x", "a `:`"),
("refs/heads/caret^x", "a `^`"),
("refs/heads/tilde~x", "a `~`"),
("refs/heads/question?x", "a `?`"),
("refs/heads/bracket[x", "a `[`"),
("refs/heads/back\\slash", "a backslash"),
];
/// Names that are **legal** and unusual. `-dash` is the interesting one: git
/// permits it, and a server that ever passed a ref name to a subprocess would
/// have it read as an option. gunnar runs no subprocess, and this asserts the
/// name survives as itself rather than being rejected out of superstition.
const ACCEPTED: &[&str] = &[
"refs/heads/legit",
"refs/heads/-dash-leading",
"refs/heads/ünïcøde-namn",
"refs/heads/deeply/nested/branch/name",
"refs/heads/dots.in.the.middle",
"refs/pull/42/head",
"refs/heads/UPPER_and_lower-123",
];
#[test]
fn hostile_ref_names_are_refused_and_legal_ones_are_not() {
let zero = Kind::Sha1.null().to_string();
// The acceptance half FIRST, so a server that refuses everything fails
// here rather than passing the refusal half in silence.
for legal in ACCEPTED {
let fixture = Fixture::build(Kind::Sha1);
let target = fixture.merge.to_string();
let (ok, said) = push(
&fixture,
&[(&zero, &target, legal)],
&empty_pack(Kind::Sha1),
);
assert!(ok, "the legal name {legal:?} was refused: {said}");
assert!(
holds(&fixture, legal),
"the legal name {legal:?} was accepted but not stored; the store holds {:?}",
stored_names(&fixture)
.iter()
.map(|n| String::from_utf8_lossy(n).into_owned())
.collect::<Vec<_>>()
);
}
for (hostile, why) in REFUSED {
let fixture = Fixture::build(Kind::Sha1);
let before = stored_names(&fixture);
let target = fixture.merge.to_string();
let (ok, said) = push(
&fixture,
&[(&zero, &target, hostile)],
&empty_pack(Kind::Sha1),
);
assert!(!ok, "the push of {hostile:?} ({why}) was ACCEPTED: {said}");
// The property that matters is not the error, it is that nothing moved.
assert_eq!(
stored_names(&fixture),
before,
"the push of {hostile:?} ({why}) was refused but changed the store anyway"
);
}
}
/// A very long reference name.
///
/// # This test was blind for as long as it existed, and this is what it missed
///
/// Its original assertion was `ok == holds(..)` — *report and store agree* —
/// and its doc named the reason: *"a filesystem backend does: a path component
/// over 255 bytes cannot be created on ext4"*. **[`Fixture`] is
/// [`MemGitServe`](gunnar_store::mem::MemGitServe).** No filesystem is anywhere near it, `mem`
/// took a 400-byte name and stored it, so report and store agreed and the check
/// passed — on the one backend for which the property it names does not exist.
/// It could not have gone red for its own stated reason.
///
/// MEASURED 2026-08-06, load1 36.6: `mem`, the Arrow log and the in-memory
/// Arrow log all took a **70 011-byte** name; the `fs` backend refused anything
/// over **250** bytes per component, which is exactly where `git update-ref`
/// 2.53.0 draws its own line (250 accepted, 251 *"File name too long"*),
/// `NAME_MAX` minus the `.lock` the lock file needs.
///
/// So the rule now lives in the validator as
/// [`gunnar_store::MAX_REF_COMPONENT_BYTES`], where it binds every
/// backend, and the backend-by-backend proof is
/// `gunnar_store::conformance`'s `ref_name_size`, which runs against all four.
/// What is left for this file is the part only the wire can answer: **the
/// refusal reaches the client as a per-ref `ng` line and not as a dropped
/// session or a silent success**, with the acceptance of a name one byte under
/// the ceiling pushed through the same code path so a server that refuses
/// everything cannot pass.
#[test]
fn a_very_long_reference_name_is_refused_at_the_ceiling_and_taken_below_it() {
let zero = Kind::Sha1.null().to_string();
let ceiling = gunnar_store::MAX_REF_COMPONENT_BYTES;
// At the ceiling: taken, stored, and reported as taken.
let at = format!("refs/heads/{}", "x".repeat(ceiling));
let fixture = Fixture::build(Kind::Sha1);
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), &at)],
&empty_pack(Kind::Sha1),
);
assert!(
ok && holds(&fixture, &at),
"a {ceiling}-byte component is what git itself accepts and it was refused \
(reported ok={ok}, stored={}): {said}",
holds(&fixture, &at)
);
// One byte over, and as far over as a client can physically get: a command
// line is one pkt-line, so a 70 000-byte name cannot be *sent* at all
// (`gunnar_pktline::MAX_PAYLOAD` is 65 516, and the encoder refuses with
// `TooLong` rather than emitting a wrong header). The store-side answer at
// that size is `gunnar_store::conformance`'s `ref_name_size`; what the wire
// can be asked is the largest thing that fits in a packet.
for over in [ceiling + 1, 60_000] {
let long = format!("refs/heads/{}", "x".repeat(over));
let fixture = Fixture::build(Kind::Sha1);
let before = stored_names(&fixture);
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), &long)],
&empty_pack(Kind::Sha1),
);
assert!(
!ok && !holds(&fixture, &long),
"a {over}-byte path component was ACCEPTED. No loose-ref backend and no stock \
git can hold it, so this is a repository nobody can clone: {said}"
);
assert_eq!(
stored_names(&fixture),
before,
"the over-long push was refused but changed the store anyway"
);
assert!(
said.contains("ng "),
"the refusal did not reach the client as a per-ref `ng` line, so a pushing \
client cannot tell which reference was refused or why: {said}"
);
}
}
/// **One bad name must not take the whole push down with it.**
///
/// # What the twenty-name table above cannot see
///
/// [`hostile_ref_names_are_refused_and_legal_ones_are_not`] runs twenty hostile
/// names through [`push`] and asserts `!ok` for each. `ok` is false both when
/// the server answers `ng <ref> <reason>` over a live session **and** when
/// `serve` returns `Err` and the conversation ends — the helper folds them into
/// the same boolean. So every one of those twenty was, until 2026-08-06, taking
/// the second path and the table could not tell.
///
/// It matters because of what the second path costs a real client. `serve`
/// returned `Err` out of `read_commands`, *before* the pack was read: the client
/// is still writing 8 GB into a pipe nobody is draining, gets `EPIPE`, prints
/// `the remote end hung up unexpectedly`, and is told nothing about which
/// reference was refused or why. Every other reference in the same push is lost
/// with it.
///
/// **git does not do that**, and it was measured rather than assumed —
/// `git receive-pack` 2.53.0, 2026-08-06, raw two-command push:
/// `unpack ok` / `ok refs/heads/goodone` / `ng refs/heads/bad~name funny
/// refname`, exit 0, and `refs/heads/goodone` created on the server.
///
/// This asserts all three halves of that on gunnar: the good reference lands,
/// the bad one comes back as a named `ng` line, and the session completes.
#[test]
fn a_refused_ref_name_does_not_take_the_rest_of_the_push_with_it() {
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let target = fixture.merge.to_string();
let (ok, said) = push(
&fixture,
&[
(&zero, &target, "refs/heads/goodone"),
(&zero, &target, "refs/heads/bad~name"),
(&zero, &target, "refs/heads/goodtwo"),
],
&empty_pack(Kind::Sha1),
);
// The session completed and produced a report at all. `Err:` is what the
// helper prefixes a dropped session with, and that is the whole defect.
assert!(
!said.starts_with("Err:"),
"one unacceptable ref name ended the session instead of being reported. \
A client streaming a pack into this gets EPIPE and `the remote end hung up \
unexpectedly`, and learns nothing about which reference was at fault: {said}"
);
assert!(!ok, "the push as a whole must not report success: {said}");
// The bad one is named, with a reason, in a per-ref line.
assert!(
said.contains("ng refs/heads/bad~name"),
"the refused reference was not named in an `ng` line: {said}"
);
// And the two good ones landed, exactly as git leaves them.
for good in ["refs/heads/goodone", "refs/heads/goodtwo"] {
assert!(
said.contains(&format!("ok {good}")),
"{good} shares a push with one bad name and was not reported ok: {said}"
);
assert!(
holds(&fixture, good),
"{good} was reported ok and is not in the store: {said}"
);
}
assert!(
!holds(&fixture, "refs/heads/bad~name"),
"the refused name was written anyway"
);
}
/// **`--atomic` still means all or nothing, including for a name.**
///
/// The companion to the test above, and the case that makes the fix a decision
/// rather than a relaxation: the good references must *not* land when the client
/// asked for `atomic`, and the collapse has to happen **before** the reference
/// transaction runs, or the report says refused about something already on disk.
#[test]
fn an_atomic_push_with_one_bad_name_lands_nothing() {
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let target = fixture.merge.to_string();
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let input = push_bytes(
&[
(&zero, &target, "refs/heads/goodone"),
(&zero, &target, "refs/heads/bad~name"),
],
"report-status atomic object-format=sha1 agent=test/1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("an atomic push with one bad name is still a completed session");
let said = Response::parse(&out).lines().join(" | ");
assert!(
said.contains("ng refs/heads/bad~name"),
"the bad name was not reported: {said}"
);
assert!(
said.contains("ng refs/heads/goodone"),
"`atomic` was asked for and the good reference was not refused with it: {said}"
);
assert!(
!holds(&fixture, "refs/heads/goodone"),
"the report said `ng refs/heads/goodone` and the store holds it anyway — the \
batch was collapsed AFTER the transaction, so the client is being told a lie \
about what is on the server"
);
}
/// **A reference name that is not UTF-8.**
///
/// git reference names are *bytes*; `refs/heads/\xff` is a name git will create
/// and serve. `ReceivePack::parse_command` reads the command line through
/// `String::from_utf8_lossy`, which replaces every invalid byte with U+FFFD
/// before the name is validated — so the name that gets stored is not the name
/// that was asked for, and two distinct hostile names collapse onto one.
///
/// This test states the property that matters and lets the implementation
/// answer: whatever gunnar stores must be either **exactly the requested
/// bytes** or **nothing at all**. Silently storing a third name is the failure.
#[test]
fn a_non_utf8_reference_name_is_stored_verbatim_or_refused() {
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
// Built as bytes, and handed to the wire as bytes: `\xff` is not
// expressible in a Rust `&str`, which is the whole point.
let mut wanted = b"refs/heads/".to_vec();
wanted.push(0xff);
let mut line = format!("{zero} {} ", fixture.merge).into_bytes();
line.extend_from_slice(&wanted);
line.push(0);
line.extend_from_slice(b"report-status object-format=sha1");
let mut input = Vec::new();
gunnar_pktline::write_text(&mut input, &line).unwrap();
gunnar_pktline::write_flush(&mut input).unwrap();
input.extend_from_slice(&empty_pack(Kind::Sha1));
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let mut cursor = std::io::Cursor::new(input);
let mut out = Vec::new();
let result = receive.serve(
&mut cursor,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
);
let created: Vec<Vec<u8>> = stored_names(&fixture)
.into_iter()
.filter(|stored| stored.starts_with(b"refs/heads/") && !stored.ends_with(b"d"))
.filter(|stored| stored != b"refs/heads/main" && stored != b"refs/heads/side")
.collect();
match created.as_slice() {
// Refused outright: acceptable, and the store is untouched.
[] => {}
[stored] => assert_eq!(
stored.as_slice(),
wanted.as_slice(),
"the push asked for {:?} and the store now holds {:?} — the name was MANGLED, \
not stored and not refused. Two different non-UTF-8 names collapse onto the \
same stored reference this way, so a push to one silently writes the other. \
serve() returned {:?}",
String::from_utf8_lossy(&wanted),
String::from_utf8_lossy(stored),
result.as_ref().map(|_| "Ok").map_err(|e| e.to_string()),
),
many => panic!("one command created {} references: {many:?}", many.len()),
}
}
// ── packs ───────────────────────────────────────────────────────────────────
/// A pack carrying one real object, so there is something to damage.
///
/// `empty_pack` cannot serve here: it is a header and a trailer with no entry
/// between them, so "truncate the middle" has no middle, and a corruption test
/// against it would be testing the trailer twice.
fn one_object_pack(fixture: &Fixture) -> Vec<u8> {
let (obj_type, owned) = fixture
.git
.read(fixture.merge.as_bytes())
.expect("read the commit")
.expect("the commit is here");
let kind = gix_object::Kind::from_bytes(obj_type.as_str().as_bytes())
.expect("the contract's object type names git's");
let mut out = Vec::new();
let mut writer =
gunnar_pack::PackWriter::new(&mut out, 1, &gunnar_pack::WriteOptions::new(Kind::Sha1))
.expect("start a pack");
writer.add_object(kind, &owned).expect("add the commit");
writer.finish().expect("finish the pack");
out
}
#[test]
fn a_truncated_pack_is_refused_and_the_reference_does_not_move() {
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let intact = one_object_pack(&fixture);
assert!(
intact.len() > 40,
"the fixture pack is {} bytes, too small to truncate meaningfully",
intact.len()
);
// The intact pack is taken. Without this, "the truncated pack was refused"
// is equally true of a server that refuses every pack.
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), "refs/heads/intact")],
&intact,
);
assert!(
ok,
"the INTACT pack was refused, so this proves nothing: {said}"
);
assert!(holds(&fixture, "refs/heads/intact"));
// Now the same bytes, minus the trailer and a little of the body.
let fixture = Fixture::build(Kind::Sha1);
let truncated = &intact[..intact.len() - 25];
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), "refs/heads/truncated")],
truncated,
);
assert!(!ok, "a truncated pack was ACCEPTED: {said}");
assert!(
!holds(&fixture, "refs/heads/truncated"),
"a truncated pack was refused but the reference moved anyway"
);
}
#[test]
fn a_corrupt_pack_body_is_refused_and_the_reference_does_not_move() {
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let intact = one_object_pack(&fixture);
// Flip a bit in the compressed body, past the 12-byte header and well
// before the 20-byte trailer, so this is a damaged *object* and not a
// damaged checksum.
let mut corrupt = intact.clone();
let at = 12 + (corrupt.len() - 32) / 2;
corrupt[at] ^= 0xff;
assert_ne!(corrupt, intact, "the corruption did not change any byte");
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), "refs/heads/corrupt")],
&corrupt,
);
assert!(!ok, "a pack with a corrupted body was ACCEPTED: {said}");
assert!(
!holds(&fixture, "refs/heads/corrupt"),
"a corrupt pack was refused but the reference moved anyway"
);
}
/// **The per-entry allocation ceiling is on by default, and governs a push.**
///
/// What this does *not* prove, stated plainly because the first version of it
/// claimed otherwise: it does not prove the 1 GiB default stops a hostile size
/// *claim*. A pack entry's header states its uncompressed size before any of it
/// arrives, so a 60-byte pack can ask for four gigabytes — but gix rejects such
/// a pack on its short zlib body first. Measured 2026-08-04: the crafted pack
/// below is refused identically with `no_alloc_limit()`, with an 8 GiB limit,
/// and with the default, so its refusal says nothing about the guard. Reaching
/// the guard through a claim alone needs a body that really does inflate past
/// 1 GiB, which is not a test that belongs in this suite.
///
/// What it does prove, and what nothing else did:
///
/// 1. `IndexOptions::new` **wires the default in**. `gunnar-pack`'s own
/// `an_allocation_limit_stops_an_oversized_object` sets a limit explicitly
/// and calls the unset case "without a limit"; if the constructor ever
/// stopped defaulting to `Some(..)`, every production receive path would
/// lose the ceiling and that test would still pass.
/// 2. The ceiling governs a **real push**, not just a direct `index_pack`
/// call: the same pack through `ReceivePack::serve` is refused under a
/// ceiling below its object and accepted under the default, and the
/// reference does not move when it is refused.
#[test]
fn the_allocation_ceiling_is_on_by_default_and_governs_a_push() {
// 1. the constructor wires it in.
assert_eq!(
IndexOptions::new(Kind::Sha1).alloc_limit_bytes,
Some(DEFAULT_ALLOC_LIMIT_BYTES),
"IndexOptions::new no longer defaults to a ceiling — every receive path that \
builds its options this way is now unguarded"
);
assert_eq!(
gunnar_wire::DEFAULT_OBJECT_LIMIT_BYTES,
DEFAULT_ALLOC_LIMIT_BYTES,
"the serve-side and receive-side ceilings have drifted apart"
);
// 2. it governs a push. One object, comfortably over the ceiling used here
// and comfortably under the default.
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let pack = one_object_pack(&fixture);
let object_bytes = fixture
.git
.read(fixture.merge.as_bytes())
.expect("read the commit")
.expect("the commit is here")
.1
.len();
let ceiling = object_bytes / 2;
assert!(
ceiling < object_bytes && object_bytes < DEFAULT_ALLOC_LIMIT_BYTES,
"the fixture object is {object_bytes} bytes; it must sit between the {ceiling}-byte \
ceiling and the {DEFAULT_ALLOC_LIMIT_BYTES}-byte default for this to discriminate"
);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/over-ceiling")],
"report-status object-format=sha1",
&pack,
);
let mut out = Vec::new();
receive
.serve(
&mut std::io::Cursor::new(input),
&mut out,
&limited_ingest::<gunnar_wire::Error>(Kind::Sha1, ceiling),
&AllowAll,
)
.expect("the session itself is well formed");
let report = Response::parse(&out).lines().join(" | ");
assert!(
!report.contains("unpack ok"),
"a push whose object is {object_bytes} bytes was accepted under a {ceiling}-byte \
ceiling: {report}"
);
assert!(
!holds(&fixture, "refs/heads/over-ceiling"),
"the push was refused but the reference moved anyway"
);
// The acceptance half: the identical pack, under the default ceiling.
let fixture = Fixture::build(Kind::Sha1);
let (ok, said) = push(
&fixture,
&[(
&zero,
&fixture.merge.to_string(),
"refs/heads/under-ceiling",
)],
&pack,
);
assert!(
ok,
"the same pack was refused under the {DEFAULT_ALLOC_LIMIT_BYTES}-byte default too, \
so the refusal above was not about the ceiling: {said}"
);
assert!(holds(&fixture, "refs/heads/under-ceiling"));
}
/// A tiny pack whose header claims four gigabytes must not be indexed.
///
/// This is a safety floor, not a proof of the guard (see the case above for
/// why): whatever refuses it, the server must not report success and must not
/// try to honour the claim.
#[test]
fn a_tiny_pack_claiming_four_gigabytes_is_never_indexed() {
let claimed: u64 = 4 * 1024 * 1024 * 1024;
assert!(claimed as usize > DEFAULT_ALLOC_LIMIT_BYTES);
let hostile = pack_claiming(claimed);
assert!(
hostile.len() < 4096,
"the hostile pack is {} bytes; the attack is that it is small",
hostile.len()
);
let indexed = gunnar_pack::index_pack(
&mut std::io::Cursor::new(hostile.clone()),
None,
&IndexOptions::new(Kind::Sha1),
None,
);
assert!(
indexed.is_err(),
"a {}-byte pack claiming {claimed} bytes was INDEXED",
hostile.len()
);
// Discriminating: the crafting routine is capable of producing a pack the
// indexer takes, so this is not a helper that only ever emits garbage.
let fixture = Fixture::build(Kind::Sha1);
let honest = one_object_pack(&fixture);
gunnar_pack::index_pack(
&mut std::io::Cursor::new(honest),
None,
&IndexOptions::new(Kind::Sha1),
None,
)
.expect("an honest pack must index at the default ceiling");
}
/// A pack of one entry whose header claims `size` uncompressed bytes, with a
/// body far too short to be it.
///
/// Hand-built because no writer will produce it: the entry header is a varint
/// carrying the type in bits 4..6 of the first byte and the size in the low
/// nibble plus seven bits per continuation byte.
fn pack_claiming(size: u64) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"PACK");
out.extend_from_slice(&2u32.to_be_bytes());
out.extend_from_slice(&1u32.to_be_bytes());
// type 3 (blob) in bits 4..6, low four bits of the size, MSB = continues.
let mut byte = 0b0011_0000u8 | (size & 0x0f) as u8;
let mut rest = size >> 4;
while rest > 0 {
out.push(byte | 0x80);
byte = (rest & 0x7f) as u8;
rest >>= 7;
}
out.push(byte);
// A short, valid zlib stream. It does not decompress to anything like the
// claimed size, which is the point: a decoder that sized its buffer from
// the header has already lost before it reads this.
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, b"not four gigabytes").unwrap();
out.extend_from_slice(&encoder.finish().unwrap());
let mut hasher = gix_hash::hasher(Kind::Sha1);
hasher.update(&out);
let digest = hasher.try_finalize().expect("hash the pack");
out.extend_from_slice(digest.as_bytes());
out
}
// ── degenerate but legal ────────────────────────────────────────────────────
/// A push with no commands at all is a client that changed its mind, not an
/// error, and must not be reported as a push.
#[test]
fn a_push_with_no_commands_is_not_a_push() {
let fixture = Fixture::build(Kind::Sha1);
let before = stored_names(&fixture);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let mut input = Vec::new();
gunnar_pktline::write_flush(&mut input).unwrap();
let mut cursor = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut cursor,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("an empty command list is legal");
assert!(
outcome.is_none(),
"a flush-only session was reported as a push: {outcome:?}"
);
assert_eq!(
stored_names(&fixture),
before,
"a no-op push moved a reference"
);
}
/// A command whose object id is the wrong hash length for the repository.
///
/// A sha256 id against a sha1 store is what a client pointed at the wrong
/// remote sends, and it must be refused rather than truncated into something
/// that happens to parse.
#[test]
fn a_command_carrying_the_wrong_hash_kind_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
let before = stored_names(&fixture);
let zero = Kind::Sha1.null().to_string();
let sha256_id = "a".repeat(64);
let (ok, said) = push(
&fixture,
&[(&zero, &sha256_id, "refs/heads/wrong-kind")],
&empty_pack(Kind::Sha1),
);
assert!(
!ok,
"a sha256 object id was accepted by a sha1 repository: {said}"
);
assert_eq!(
stored_names(&fixture),
before,
"the refused push moved a reference anyway"
);
// Discriminating: the same shape with a correct-width id is taken.
let fixture = Fixture::build(Kind::Sha1);
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), "refs/heads/right-kind")],
&empty_pack(Kind::Sha1),
);
assert!(ok, "a well-formed sha1 push was refused too: {said}");
}
// ── the upload side: malformed requests ─────────────────────────────────────
/// Serve one protocol-v2 session over the bytes given, on a connected session.
fn serve_v2(fixture: &Fixture, request: &[u8]) -> gunnar_wire::Result<Vec<u8>> {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload.serve(
ProtocolVersion::V2,
&mut std::io::Cursor::new(request.to_vec()),
&mut out,
)?;
Ok(out)
}
/// A v2 command section: `command=<name>`, capabilities, delim, arguments,
/// flush. [`common::v2_request`] builds the same shape for the byte-level
/// suite; this one exists so a case can put something illegal in it.
fn v2_command(name: &str, capabilities: &[&str], arguments: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
gunnar_pktline::write_text(&mut out, format!("command={name}").as_bytes()).unwrap();
for capability in capabilities {
gunnar_pktline::write_text(&mut out, capability.as_bytes()).unwrap();
}
gunnar_pktline::write_delim(&mut out).unwrap();
for argument in arguments {
gunnar_pktline::write_text(&mut out, argument.as_bytes()).unwrap();
}
gunnar_pktline::write_flush(&mut out).unwrap();
out
}
#[test]
fn a_v2_command_the_server_does_not_implement_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
// The acceptance half first: `ls-refs` over the identical framing.
let good = serve_v2(
&fixture,
&v2_command("ls-refs", &["object-format=sha1"], &[]),
)
.expect("ls-refs is implemented");
assert!(
Response::parse(&good)
.lines()
.iter()
.any(|line| line.contains("refs/heads/main")),
"the control command returned no refs, so the refusal below proves nothing"
);
for unknown in ["git-archive", "object-info", "fetch-v3", ""] {
let result = serve_v2(&fixture, &v2_command(unknown, &["object-format=sha1"], &[]));
assert!(
result.is_err(),
"the unimplemented v2 command {unknown:?} was served rather than refused"
);
}
}
/// A client that stops talking in the middle of a command.
///
/// Over SSH this is a dropped connection, and it must come back as an error
/// rather than a hang or a panic. The three cuts are at different points of the
/// framing — mid-header, after a complete line with no flush, and after the
/// delim with no arguments — because a reader that only checked one of them
/// would be caught by only one of them.
#[test]
// **Un-ignored 2026-08-10.** The reason given was *"MemStore is not and cannot
// become an emitting backend"*, and half of that stopped being true: it became
// one. `MemGitServe::emit_pack` copies stored pack entries byte for byte and
// reports `recompressed = 0` as a measurement, and `gunnar-store`'s own
// conformance suite drives `gunnar_pack::index_pack` — an outside oracle — over
// what it emits. So a pack really is served here and these two can interrupt
// one mid-flight, which is what they were written to do.
//
// It is still not an *engine*: it deltifies nothing and ignores `caps.thin`, so
// nothing here may assert a byte count or a compression ratio. Neither does.
fn a_request_that_stops_early_is_an_error_and_not_a_hang() {
let fixture = Fixture::build(Kind::Sha1);
let whole = v2_command(
"fetch",
&["object-format=sha1"],
&[&format!("want {}", fixture.merge), "done"],
);
// A truncated pkt-line header: three of the four length bytes.
let cuts = [3usize, 12, whole.len() - 4];
for cut in cuts {
assert!(cut < whole.len(), "the cut must actually truncate");
let result = serve_v2(&fixture, &whole[..cut]);
assert!(
result.is_err(),
"a request cut off at byte {cut} of {} was served as if complete",
whole.len()
);
}
// Discriminating: the whole thing is served.
serve_v2(&fixture, &whole).expect("the complete request must be served");
}
/// A pkt-line length header that is not a length.
///
/// `0001` is the delim-pkt and `0002` the response-end; `0003` is shorter than
/// the four bytes of the header itself and cannot mean anything.
#[test]
fn an_impossible_pkt_line_length_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
for bad in [&b"0003"[..], &b"zzzz"[..], &b"000"[..]] {
let result = serve_v2(&fixture, bad);
assert!(
result.is_err(),
"the impossible pkt-line length {:?} was accepted",
String::from_utf8_lossy(bad)
);
}
}
/// `object-format` is the one capability whose disagreement is unrecoverable:
/// the two ends would hash differently and every id on the wire would be
/// meaningless. It must be refused, and the matching value accepted.
#[test]
fn a_command_claiming_the_wrong_object_format_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
serve_v2(
&fixture,
&v2_command("ls-refs", &["object-format=sha1"], &[]),
)
.expect("the matching object-format must be served");
let result = serve_v2(
&fixture,
&v2_command("ls-refs", &["object-format=sha256"], &[]),
);
assert!(
result.is_err(),
"a sha256 client was served by a sha1 repository"
);
}
/// Capability lines a real client would never send must not be able to steer
/// the server: unknown ones are ignored, and the command still answers.
#[test]
fn unknown_capability_lines_do_not_change_the_answer() {
let fixture = Fixture::build(Kind::Sha1);
let plain = serve_v2(
&fixture,
&v2_command("ls-refs", &["object-format=sha1"], &[]),
)
.expect("the plain command");
let noisy = serve_v2(
&fixture,
&v2_command(
"ls-refs",
&[
"object-format=sha1",
"agent=not-git/0.0",
"definitely-not-a-capability",
"server-option=../../etc/passwd",
"object-format=sha1",
],
&[],
),
)
.expect("unknown capabilities must be ignored, not fatal");
assert_eq!(
Response::parse(&plain).lines(),
Response::parse(&noisy).lines(),
"unknown capability lines changed the advertisement"
);
assert!(
!Response::parse(&plain).lines().is_empty(),
"both answers are empty, so comparing them proves nothing"
);
}
/// **The client hangs up while the pack is being written.**
///
/// The serving side is an `impl Write`, and a dropped connection surfaces as a
/// write error partway through. It must propagate: a server that swallowed it
/// would report a clone it never delivered, and cost summaries would
/// count objects that never reached anyone.
#[test]
// **Un-ignored 2026-08-10.** The reason given was *"MemStore is not and cannot
// become an emitting backend"*, and half of that stopped being true: it became
// one. `MemGitServe::emit_pack` copies stored pack entries byte for byte and
// reports `recompressed = 0` as a measurement, and `gunnar-store`'s own
// conformance suite drives `gunnar_pack::index_pack` — an outside oracle — over
// what it emits. So a pack really is served here and these two can interrupt
// one mid-flight, which is what they were written to do.
//
// It is still not an *engine*: it deltifies nothing and ignores `caps.thin`, so
// nothing here may assert a byte count or a compression ratio. Neither does.
fn a_write_that_fails_mid_pack_is_reported_and_not_swallowed() {
let fixture = Fixture::build(Kind::Sha1);
let request = v2_command(
"fetch",
&["object-format=sha1"],
&[&format!("want {}", fixture.merge), "done"],
);
// How much a complete clone writes, so the cut is genuinely mid-stream.
let whole = serve_v2(&fixture, &request).expect("the intact clone");
assert!(
whole.len() > 200,
"the fixture clone is {} bytes, too small to cut in half",
whole.len()
);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut sink = FailAfter {
written: 0,
fail_at: whole.len() / 2,
};
let result = upload.serve(
ProtocolVersion::V2,
&mut std::io::Cursor::new(request.clone()),
&mut sink,
);
assert!(
result.is_err(),
"the peer hung up after {} of {} bytes and the server reported success",
sink.fail_at,
whole.len()
);
assert!(
sink.written >= sink.fail_at,
"the sink failed at {} bytes but only {} were ever offered, so the failure \
happened before the pack and this is not the mid-pack case",
sink.fail_at,
sink.written
);
}
/// A `Write` that accepts `fail_at` bytes and then behaves like a closed pipe.
struct FailAfter {
written: usize,
fail_at: usize,
}
impl std::io::Write for FailAfter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
if self.written >= self.fail_at {
return Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"the peer hung up",
));
}
self.written += data.len();
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// **The pushing client's connection resets partway through the packfile.**
///
/// The other direction from `a_write_that_fails_mid_pack…`, and a different
/// failure from `a_truncated_pack…`: a truncated stream ends cleanly at EOF,
/// which a reader can mistake for "the client sent a short pack". A reset
/// yields an `io::Error` instead, and the two travel different paths out of the
/// decoder. Neither may leave the reference moved.
#[test]
fn a_connection_reset_mid_pack_is_refused_and_the_reference_does_not_move() {
let fixture = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let pack = one_object_pack(&fixture);
let whole = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/reset")],
"report-status object-format=sha1",
&pack,
);
// Reset once the command list and a little of the pack have gone by, so
// this is genuinely mid-pack and not a client that never said anything.
let commands_end = whole.len() - pack.len();
let reset_at = commands_end + pack.len() / 2;
assert!(
reset_at > commands_end && reset_at < whole.len(),
"the reset point must land inside the pack"
);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let mut input = std::io::BufReader::new(ResetAfter {
data: whole.clone(),
position: 0,
reset_at,
});
let mut out = Vec::new();
let result = receive.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
);
let report = Response::parse(&out).lines().join(" | ");
assert!(
result.is_err() || !report.contains("unpack ok"),
"a push whose connection reset mid-pack was reported as unpacked: {report}"
);
assert!(
!holds(&fixture, "refs/heads/reset"),
"the connection reset mid-pack but the reference moved anyway"
);
// The acceptance half: the identical bytes, delivered whole.
let fixture = Fixture::build(Kind::Sha1);
let (ok, said) = push(
&fixture,
&[(&zero, &fixture.merge.to_string(), "refs/heads/reset")],
&pack,
);
assert!(ok, "the same push delivered whole was refused too: {said}");
assert!(holds(&fixture, "refs/heads/reset"));
}
/// A `Read` that hands over `reset_at` bytes and then fails like a reset
/// connection, rather than ending at EOF.
struct ResetAfter {
data: Vec<u8>,
position: usize,
reset_at: usize,
}
impl std::io::Read for ResetAfter {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.position >= self.reset_at {
return Err(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"the client went away",
));
}
// Clamped to the data as well as to the reset point. Without the
// first clamp this reader panics instead of ending at EOF whenever
// reset_at is past the buffer — which is exactly the configuration a
// red probe of this case uses, so the probe would die inside itself
// and prove nothing.
let end = (self.position + buf.len())
.min(self.reset_at)
.min(self.data.len());
let n = end - self.position;
buf[..n].copy_from_slice(&self.data[self.position..end]);
self.position = end;
Ok(n)
}
}