crates/gunnar-wire/tests/receive_pack_wire_bytes.rs
//! The exact bytes `receive-pack` puts on the wire, pinned.
//!
//! # Why a byte pin and not another functional test
//!
//! The advertisement and the status report moved out of this crate and into
//! `gunnar-sendpack`, so that the encoder and the decoder of each are one
//! implementation instead of two that agree by inspection. Nothing
//! about that move is *supposed* to change what goes on the wire — and "supposed
//! to" is exactly the claim a functional suite cannot check.
//!
//! Every `real_git_interop` test would still pass if the advertisement grew a
//! trailing space, if the capability list reordered, or if a `ng` reason lost
//! its flattening: stock `git` tolerates all three, and the drift would surface
//! only against some other client, months later. So these are literals,
//! transcribed from what this crate emitted **before** the move (worktree
//! `008d74b`, 2026-08-05) and cross-read against `git receive-pack
//! --advertise-refs` on git 2.53.0.
mod common;
use gix_hash::Kind;
use gunnar_store::mem::MemGitServe;
use gunnar_wire::receive_pack::{AllowAll, PushFacts, PushGate, RefCommand};
use gunnar_wire::{ReceivePack, RefStatus};
use common::{empty_pack, push_bytes, validating_ingest, Fixture};
fn stable(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// The capability list, transcribed. **Order is part of the wire**: a client
/// that diffs two servers' advertisements reads a reorder as a change.
///
/// `agent=` is the one item taken from the crate rather than written out: it
/// carries the crate version, which changes on a release, and pinning it would
/// make these tests fail for the one reason that is not a wire change. It is
/// still inside the pinned **length prefix**, so an agent of the wrong length
/// still fails.
fn caps() -> String {
format!(
"report-status report-status-v2 delete-refs side-band-64k quiet atomic \
ofs-delta push-options object-format=sha1 agent={}",
gunnar_wire::caps::default_agent()
)
}
fn advertisement(fixture: &Fixture) -> String {
let rp = ReceivePack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
rp.write_ref_advertisement(&mut out).expect("advertise");
stable(&out)
}
/// Everything after the reference advertisement.
fn report_of(out: &[u8]) -> String {
let text = stable(out);
let at = text.find("unpack").expect("an `unpack` line");
text[at - 4..].to_owned()
}
#[test]
fn the_advertisement_of_a_populated_repository_is_byte_exact() {
let f = Fixture::build(Kind::Sha1);
let got = advertisement(&f);
// First line: `<len><oid> <ref>\0<caps>\n`. Every later line carries no
// capability list at all, which is the single most-repeated bug in this
// format — and with only one reference the two rules would be
// indistinguishable, which is why the fixture has three.
let first = format!("{} refs/heads/main\0{}\n", f.four, caps());
let mut expected = format!("{:04x}{first}", first.len() + 4);
for (oid, name) in [(f.three, "refs/heads/side"), (f.tag, "refs/tags/v1")] {
let line = format!("{oid} {name}\n");
expected.push_str(&format!("{:04x}{line}", line.len() + 4));
}
expected.push_str("0000");
assert_eq!(got, expected);
assert_eq!(got.matches('\0').count(), 1, "exactly one NUL, on line one");
}
/// A tag is advertised by `receive-pack` **unpeeled**. `upload-pack` peels it
/// and adds a `^{}` line; `receive-pack` does not, because a pusher compares
/// against the ref's own value and a peeled line would be a second, phantom
/// reference to compare against.
#[test]
fn receive_pack_does_not_peel_tags_the_way_upload_pack_does() {
let f = Fixture::build(Kind::Sha1);
let got = advertisement(&f);
assert!(got.contains(&format!("{} refs/tags/v1\n", f.tag)));
assert!(
!got.contains("^{}"),
"receive-pack advertises no peeled lines: {got:?}"
);
}
/// An empty repository advertises exactly one line, and it is a pseudo-ref. A
/// client that received nothing could not learn the hash algorithm; one that
/// recorded `capabilities^{}` as a reference would push against it.
#[test]
fn the_advertisement_of_an_empty_repository_is_byte_exact() {
// A store with nothing in it at all. It used to be the fixture's objects
// beside a second, empty `MemRefStore`; `GitServe` is one store, and the
// advertisement reads only the ref namespace, so an empty store says the
// same thing and says it without pretending two halves can disagree.
let empty = MemGitServe::new(Kind::Sha1);
let rp = ReceivePack::new(&empty, Kind::Sha1);
let mut out = Vec::new();
rp.write_ref_advertisement(&mut out).expect("advertise");
let zero = "0".repeat(40);
let line = format!("{zero} capabilities^{{}}\0{}\n", caps());
assert_eq!(
stable(&out),
format!("{:04x}{line}0000", line.len() + 4),
"an empty repository still has to say who it is"
);
}
// ── the status report ───────────────────────────────────────────────────────
/// A gate that refuses one reference, with a reason spanning two lines.
struct RefuseBlocked;
impl PushGate for RefuseBlocked {
fn check(
&self,
command: &RefCommand,
_facts: &PushFacts<'_>,
) -> std::result::Result<(), String> {
if command.name.as_bytes() == b"refs/heads/blocked" {
Err("hook declined\nand said so on two lines".to_owned())
} else {
Ok(())
}
}
}
/// One accepted and one refused reference in one report, with the refusal
/// reason flattened.
///
/// The flattening is now `gunnar_sendpack::report::one_line`'s rather than a
/// private copy here, and a status reason is the one field on this wire that
/// carries text a pusher can influence — a hook's stderr, a gate's message.
#[test]
fn the_status_report_is_byte_exact_including_a_flattened_reason() {
let f = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[
(&zero, &f.merge.to_string(), "refs/heads/allowed"),
(&zero, &f.merge.to_string(), "refs/heads/blocked"),
],
// No sideband: the report then rides the connection unwrapped, which is
// what makes it readable as literal bytes. The sidebanded form is
// covered by `real_git_interop`.
"report-status-v2 object-format=sha1",
&empty_pack(Kind::Sha1),
);
let rp = ReceivePack::new(&f.git, f.kind);
let mut out = Vec::new();
let outcome = rp
.serve(
&mut std::io::Cursor::new(input),
&mut out,
&validating_ingest(Kind::Sha1),
&RefuseBlocked,
)
.expect("serve")
.expect("a push happened");
assert_eq!(outcome.statuses[0], RefStatus::Ok);
assert!(matches!(outcome.statuses[1], RefStatus::Rejected(_)));
assert_eq!(
report_of(&out),
"000eunpack ok\n\
001aok refs/heads/allowed\n\
0041ng refs/heads/blocked hook declined and said so on two lines\n\
0000",
"the newline in the gate's reason must have become a space, and each \
status must be its own pkt-line"
);
// And the refused reference did not move.
assert!(f.read_ref("refs/heads/blocked").is_none());
assert_eq!(f.read_ref("refs/heads/allowed").unwrap(), f.merge);
}
#[test]
fn an_all_ok_report_is_byte_exact() {
let f = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &f.merge.to_string(), "refs/heads/fresh")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let rp = ReceivePack::new(&f.git, f.kind);
let mut out = Vec::new();
rp.serve(
&mut std::io::Cursor::new(input),
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("serve");
assert_eq!(
report_of(&out),
"000eunpack ok\n0018ok refs/heads/fresh\n0000"
);
}
/// A reference name that is not UTF-8 must round-trip through the command-list
/// parser byte-for-byte.
///
/// `refs/heads/\xff` and `refs/heads/\xfe` are **indistinguishable** after
/// `String::from_utf8_lossy`, so a parser that went through one would let a
/// push to either land on whichever the store wrote first. The grammar splits
/// on bytes; this asserts the property from the outside, on the server.
#[test]
fn two_reference_names_differing_only_in_invalid_bytes_do_not_collapse() {
let f = Fixture::build(Kind::Sha1);
let zero = Kind::Sha1.null().to_string();
let new = f.merge.to_string();
let mut input = Vec::new();
for (i, tail) in [b"\xff".as_slice(), b"\xfe".as_slice()].iter().enumerate() {
let mut line = format!("{zero} {new} refs/heads/x").into_bytes();
line.extend_from_slice(tail);
if i == 0 {
line.push(0);
line.extend_from_slice(b"report-status object-format=sha1");
}
gunnar_pktline::write_text(&mut input, &line).unwrap();
}
gunnar_pktline::write_flush(&mut input).unwrap();
input.extend_from_slice(&empty_pack(Kind::Sha1));
let rp = ReceivePack::new(&f.git, f.kind);
let mut out = Vec::new();
let outcome = rp
.serve(
&mut std::io::Cursor::new(input),
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("serve")
.expect("a push happened");
// 🔴 THE ASSERTION MOVED, AND WHAT IT COVERS MOVED WITH IT — 2026-08-10.
//
// Both names used to LAND, as two references, and the test read them back
// out of the store. They cannot land any more and the reason is in the
// contract, not in this crate: `RefRow::name` and `RefUpdate::name` on
// `git-storage-trait` are `String`, so a reference name that git will
// happily create but that is not UTF-8 has nothing to be carried in.
// `ReceivePack::parse_command` refuses it by name — `UnnameableRef` — which
// refuses THAT reference with an `ng` line the client can read and leaves
// the rest of the push untouched. `locked_on_target.md` and this comment
// record it as a real interop narrowing, deliberately not hidden.
//
// **The property under test survives intact**: the two spellings must not
// collapse onto one. They are still told apart, byte for byte, and each is
// refused on its own line — which is the same claim about the same parser,
// asserted one step earlier on the same bytes. `assert_ne!` below is what
// a `from_utf8_lossy` anywhere in the path would break, and it is the whole
// point of the test.
//
// **What is no longer covered, said plainly**: that the *store* keeps two
// such names apart. Nothing in gunnar can ask it to any more. That is a
// question for the day `RefRow::name` widens to bytes, which is a
// two-implementation decision and not one this test can force.
assert!(
outcome.commands.is_empty(),
"a non-UTF-8 name was accepted as a RefCommand: {:?}",
outcome.commands
);
assert_eq!(outcome.unnameable.len(), 2);
assert_eq!(outcome.unnameable[0].name, b"refs/heads/x\xff");
assert_eq!(outcome.unnameable[1].name, b"refs/heads/x\xfe");
assert_ne!(
outcome.unnameable[0].name, outcome.unnameable[1].name,
"the two names collapsed onto one"
);
assert_eq!(outcome.unnameable[0].position, 0);
assert_eq!(outcome.unnameable[1].position, 1);
// And on the wire: two `ng` lines, each echoing its own bytes back, so a
// client zipping the report against the list it sent can tell them apart.
let report = stable(&out);
assert!(
report.contains("ng refs/heads/x\u{fffd}"),
"the refusals did not reach the wire: {report:?}"
);
assert_eq!(
out.windows(4).filter(|w| *w == b"ng r").count(),
2,
"two refusals must be two pkt-lines: {report:?}"
);
// Neither landed, and no third name was invented.
assert!(f.read_ref("refs/heads/x\u{fffd}").is_none());
assert!(
!f.ref_names()
.iter()
.any(|name| name.starts_with("refs/heads/x")),
"a refused name reached the store anyway"
);
}