crates/gunnar-sendpack/tests/grammar.rs
//! The four wire formats, each asserted from byte literals, and each asserted
//! **both ways**.
//!
//! Round-tripping an encoder through its own decoder proves only that the two
//! agree, which two wrong implementations also do. So every round-trip here
//! also pins the exact bytes against a literal transcribed from what
//! `git-receive-pack` 2.53.0 puts on the wire; `tests/against_real_git.rs`
//! then drives the real binary, which is the only judge that cannot be wrong in
//! the same way we are.
use gix_hash::{Kind, ObjectId};
use gunnar_sendpack::{
advertisement, capabilities, command, options, report, Advertisement, Capabilities,
PushCommand, RefStatus, RemoteRef, SendPackOptions,
};
fn pkt(payload: &str) -> Vec<u8> {
payload.as_bytes().to_vec()
}
fn oid(c: char) -> ObjectId {
ObjectId::from_hex(c.to_string().repeat(40).as_bytes()).unwrap()
}
// ── capabilities ────────────────────────────────────────────────────────────
#[test]
fn capabilities_parse_values_and_flags() {
let c = Capabilities::parse("report-status delete-refs object-format=sha256 agent=git/2.53");
assert!(c.has("report-status"));
assert!(c.has("object-format"));
assert!(!c.has("atomic"));
assert_eq!(c.value("object-format"), Some("sha256"));
assert_eq!(c.value("agent"), Some("git/2.53"));
assert_eq!(c.value("report-status"), None, "a flag has no value");
}
/// git's own `receive-pack` client emits `…\0 report-status-v2 …` with a
/// **leading** space. A naive `split(' ')` turns that into a capability whose
/// name is the empty string, which then matches nothing and hides the rest.
#[test]
fn a_leading_space_does_not_become_a_phantom_capability() {
let c = Capabilities::parse(" report-status-v2 atomic ");
assert_eq!(c.all(), ["report-status-v2", "atomic"]);
}
/// A prefix is not a capability. `has("report-status")` must not fire on
/// `report-status-v2`, or a client would believe v1 was on offer when only v2
/// was, negotiate v1, and be answered in v2.
#[test]
fn a_capability_is_not_matched_by_a_longer_one_that_starts_with_it() {
let c = Capabilities::parse("report-status-v2 object-formats-are-not-a-thing");
assert!(!c.has("report-status"), "v2 is not v1");
assert!(c.has("report-status-v2"));
assert_eq!(c.value("object-format"), None);
}
#[test]
fn splitting_the_first_line_distinguishes_an_empty_list_from_no_list() {
assert_eq!(capabilities::split(b"abc"), (&b"abc"[..], None));
assert_eq!(
capabilities::split(b"abc\0"),
(&b"abc"[..], Some(&b""[..])),
"a NUL with nothing after it is an EMPTY list, not an absent one"
);
assert_eq!(
capabilities::split(b"abc\0x y"),
(&b"abc"[..], Some(&b"x y"[..]))
);
}
// ── the advertisement, both ways ────────────────────────────────────────────
#[test]
fn advertisement_of_a_populated_sha1_repo() {
let (a, b) = (oid('1'), oid('2'));
let adv = advertisement::parse(&[
pkt(&format!(
"{a} refs/heads/main\0report-status delete-refs object-format=sha1\n"
)),
pkt(&format!("{b} refs/tags/v1\n")),
])
.unwrap();
assert_eq!(adv.hash_kind, Kind::Sha1);
assert_eq!(adv.refs.len(), 2);
assert_eq!(adv.refs[0].name, "refs/heads/main");
assert_eq!(adv.refs[0].oid, a);
assert_eq!(adv.refs[1].name, "refs/tags/v1");
assert!(adv.capabilities.has("delete-refs"));
assert_eq!(adv.oid_of("refs/heads/main"), a);
assert!(
adv.oid_of("refs/heads/nope").is_null(),
"an unknown ref reads as the null oid, which is what a create sends"
);
}
/// The empty-repository shape. `capabilities^{}` is NOT a ref, and a client
/// that recorded it would push against a ref name that cannot exist.
#[test]
fn advertisement_of_an_empty_repo_has_no_refs_but_has_capabilities() {
let zero = "0".repeat(40);
let adv = advertisement::parse(&[pkt(&format!(
"{zero} capabilities^{{}}\0report-status-v2 object-format=sha1 agent=git/2.53.0\n"
))])
.unwrap();
assert!(
adv.refs.is_empty(),
"capabilities^{{}} must not become a ref"
);
assert!(adv.capabilities.has("report-status-v2"));
assert_eq!(adv.hash_kind, Kind::Sha1);
}
#[test]
fn advertisement_of_a_sha256_repo() {
let a = "a".repeat(64);
let adv = advertisement::parse(&[pkt(&format!(
"{a} refs/heads/main\0report-status object-format=sha256\n"
))])
.unwrap();
assert_eq!(adv.hash_kind, Kind::Sha256);
assert_eq!(adv.refs[0].oid.kind(), Kind::Sha256);
let null = adv.oid_of("refs/heads/nope");
assert!(
null.is_null() && null.to_hex().to_string().len() == 64,
"the null oid of a sha256 remote is 64 hex chars, not 40"
);
}
/// A server too old to advertise `object-format` still tells the truth with the
/// width of its oids. Assuming sha1 there would make every oid parse fail.
#[test]
fn hash_kind_falls_back_to_oid_width() {
let a = "b".repeat(64);
let adv =
advertisement::parse(&[pkt(&format!("{a} refs/heads/main\0report-status\n"))]).unwrap();
assert_eq!(adv.hash_kind, Kind::Sha256);
}
#[test]
fn shallow_lines_are_not_refs() {
let (a, s) = (oid('1'), oid('2'));
let adv = advertisement::parse(&[
pkt(&format!("{a} refs/heads/main\0report-status\n")),
pkt(&format!("shallow {s}\n")),
])
.unwrap();
assert_eq!(adv.refs.len(), 1);
}
/// An advertisement whose `object-format` and oid width disagree is a server
/// contradicting itself, and the oids are what everything downstream uses.
#[test]
fn an_oid_of_the_wrong_width_for_the_stated_format_is_refused() {
let a = "a".repeat(64);
let err = advertisement::parse(&[pkt(&format!("{a} refs/heads/main\0object-format=sha1\n"))])
.unwrap_err();
assert!(format!("{err}").contains("Sha1"), "{err}");
}
/// The **server** half of the same format, byte-exact against what
/// `git-receive-pack` 2.53.0 emits.
#[test]
fn the_advertisement_encoder_and_decoder_are_one_format() {
let caps = vec!["report-status-v2".to_string(), "agent=x/1".to_string()];
let refs = vec![
RemoteRef::new("refs/heads/main", oid('1')),
RemoteRef::new("refs/tags/v1", oid('2')),
];
let lines = advertisement::lines(&refs, &caps, Kind::Sha1);
assert_eq!(
lines,
vec![
format!("{} refs/heads/main\0report-status-v2 agent=x/1", oid('1')).into_bytes(),
format!("{} refs/tags/v1", oid('2')).into_bytes(),
],
"capabilities ride on the FIRST line only, after a NUL"
);
let round = advertisement::parse(&lines).unwrap();
assert_eq!(round.refs, refs);
assert_eq!(round.capabilities.all(), caps);
assert_eq!(round.hash_kind, Kind::Sha1);
}
/// A repository with no refs still has to say who it is, or the client cannot
/// learn the hash algorithm and reports nothing useful.
#[test]
fn an_empty_repository_still_advertises_one_line() {
let caps = vec!["object-format=sha256".to_string()];
let lines = advertisement::lines(&[], &caps, Kind::Sha256);
assert_eq!(lines.len(), 1);
let round = advertisement::parse(&lines).unwrap();
assert!(round.refs.is_empty());
assert_eq!(round.hash_kind, Kind::Sha256);
}
// ── the command list, both ways ─────────────────────────────────────────────
fn adv_with(caps: &str) -> Advertisement {
advertisement::parse(&[pkt(&format!("{} refs/heads/main\0{caps}\n", oid('1')))]).unwrap()
}
fn cmd_update() -> PushCommand {
PushCommand::update("refs/heads/main", oid('1'), oid('2'))
}
#[test]
fn the_command_list_is_byte_exact() {
let cmds = vec![
PushCommand::update("refs/heads/main", oid('1'), oid('2')),
PushCommand::create("refs/heads/next", oid('3')),
];
let lines = command::lines(&cmds, &["report-status".into(), "agent=x/0".into()]).unwrap();
let zero = "0".repeat(40);
assert_eq!(
lines,
vec![
format!(
"{} {} refs/heads/main\0report-status agent=x/0",
oid('1'),
oid('2')
)
.into_bytes(),
format!("{zero} {} refs/heads/next", oid('3')).into_bytes(),
]
);
}
/// The half that did not exist before this crate: gunnar's server parsed the
/// command list with code that had never met the encoder that writes it.
#[test]
fn the_command_encoder_and_decoder_are_one_format() {
let cmds = vec![
PushCommand::update("refs/heads/main", oid('1'), oid('2')),
PushCommand::create("refs/heads/next", oid('3')),
PushCommand::delete("refs/tags/old", oid('4')),
];
let caps = vec!["report-status-v2".to_string()];
let lines = command::lines(&cmds, &caps).unwrap();
let mut back = Vec::new();
for (i, line) in lines.iter().enumerate() {
let (content, sent_caps) = capabilities::split(line);
assert_eq!(
sent_caps.is_some(),
i == 0,
"only the first line carries capabilities"
);
back.push(command::parse_line(content, Kind::Sha1).unwrap());
}
assert_eq!(back, cmds);
}
/// Reference names are bytes. A `String` field forces a lossy conversion, and a
/// lossy conversion collapses two distinct names onto one — so a push to one
/// silently overwrites the other.
#[test]
fn a_non_utf8_reference_name_survives_the_round_trip_intact() {
let a: &[u8] = b"refs/heads/\xff";
let b: &[u8] = b"refs/heads/\xfe";
assert_ne!(a, b);
assert_eq!(
String::from_utf8_lossy(a),
String::from_utf8_lossy(b),
"the two names are INDISTINGUISHABLE after a lossy conversion, which is \
the bug this test exists to keep fixed"
);
let cmds = vec![
PushCommand::create(a.to_vec(), oid('1')),
PushCommand::create(b.to_vec(), oid('2')),
];
let lines = command::lines(&cmds, &[]).unwrap();
let back: Vec<_> = lines
.iter()
.map(|l| command::parse_line(capabilities::split(l).0, Kind::Sha1).unwrap())
.collect();
assert_eq!(back, cmds);
assert_ne!(back[0].name, back[1].name);
}
#[test]
fn a_duplicated_refname_in_the_command_list_is_refused() {
let cmds = vec![
PushCommand::update("refs/heads/main", oid('1'), oid('2')),
PushCommand::update("refs/heads/main", oid('2'), oid('1')),
];
assert!(command::lines(&cmds, &[]).is_err());
}
#[test]
fn an_empty_command_list_is_refused() {
assert!(command::lines(&[], &["report-status".into()]).is_err());
}
/// A newline in a ref name would be read as the end of the line and everything
/// after it as a second command.
#[test]
fn a_reference_name_carrying_a_newline_or_a_nul_is_refused() {
for bad in [&b"refs/heads/a\nb"[..], &b"refs/heads/a\0b"[..]] {
let cmds = vec![PushCommand::create(bad.to_vec(), oid('1'))];
assert!(
command::lines(&cmds, &[]).is_err(),
"{:?} must not reach the wire",
String::from_utf8_lossy(bad)
);
}
}
/// A 40-hex id in a sha256 repository names nothing. It is refused here rather
/// than downstream, and as a **structured** error rather than a message,
/// because a server re-reports it to the client in its own vocabulary and
/// digging two algorithm names back out of a formatted string is how a message
/// becomes an API by accident.
#[test]
fn a_command_whose_oid_is_the_wrong_hash_kind_is_refused() {
let line = format!("{} {} refs/heads/main", oid('1'), oid('2'));
assert!(command::parse_line(line.as_bytes(), Kind::Sha1).is_ok());
let err = command::parse_line(line.as_bytes(), Kind::Sha256).unwrap_err();
assert!(
matches!(
err,
gunnar_sendpack::Error::ObjectFormat {
ours: "sha256",
theirs: "sha1"
}
),
"{err:?}"
);
assert_eq!(
err.to_string(),
"an object id is sha1 but the repository is sha256"
);
}
#[test]
fn a_command_with_two_null_oids_is_neither_a_create_nor_a_delete() {
let zero = "0".repeat(40);
let err = command::parse_line(
format!("{zero} {zero} refs/heads/main").as_bytes(),
Kind::Sha1,
)
.unwrap_err();
assert!(format!("{err}").contains("null"), "{err}");
}
// ── negotiation ─────────────────────────────────────────────────────────────
#[test]
fn negotiation_prefers_v2_and_echoes_object_format() {
let adv =
adv_with("report-status report-status-v2 side-band-64k object-format=sha1 agent=git/2.53");
let caps = options::negotiate(
&adv,
&[cmd_update()],
Kind::Sha1,
&SendPackOptions::default(),
)
.unwrap();
assert!(caps.contains(&"report-status-v2".to_string()));
assert!(
!caps.contains(&"report-status".to_string()),
"v2 replaces v1"
);
assert!(caps.contains(&"side-band-64k".to_string()));
assert!(caps.contains(&"object-format=sha1".to_string()));
assert!(caps.iter().any(|c| c.starts_with("agent=")));
}
#[test]
fn negotiation_falls_back_to_report_status_v1() {
let adv = adv_with("report-status delete-refs");
let caps = options::negotiate(
&adv,
&[cmd_update()],
Kind::Sha1,
&SendPackOptions::default(),
)
.unwrap();
assert!(caps.contains(&"report-status".to_string()));
assert!(
!caps.iter().any(|c| c.starts_with("object-format")),
"object-format is echoed only when the remote raised it"
);
assert!(!caps.contains(&"side-band-64k".to_string()));
}
/// The guard that matters: a requested guarantee that is not on offer must
/// FAIL, never be dropped. Asserted on the error, not on the absence of the
/// capability, because the absence is also what a silent downgrade looks like.
#[test]
fn atomic_is_refused_rather_than_downgraded() {
let opts = SendPackOptions {
atomic: true,
..Default::default()
};
let err = options::negotiate(
&adv_with("report-status"),
&[cmd_update()],
Kind::Sha1,
&opts,
)
.unwrap_err();
assert!(
format!("{err}").contains("atomic"),
"the error must name the guarantee that was refused: {err}"
);
let caps = options::negotiate(
&adv_with("report-status atomic"),
&[cmd_update()],
Kind::Sha1,
&opts,
)
.unwrap();
assert!(caps.contains(&"atomic".to_string()));
}
#[test]
fn push_options_are_refused_rather_than_dropped() {
let opts = SendPackOptions {
push_options: vec!["ci.skip".to_string()],
..Default::default()
};
assert!(options::negotiate(
&adv_with("report-status"),
&[cmd_update()],
Kind::Sha1,
&opts
)
.is_err());
let caps = options::negotiate(
&adv_with("report-status push-options"),
&[cmd_update()],
Kind::Sha1,
&opts,
)
.unwrap();
assert!(caps.contains(&"push-options".to_string()));
}
#[test]
fn deleting_without_delete_refs_is_refused() {
let del = PushCommand::delete("refs/heads/main", oid('1'));
let d = SendPackOptions::default();
assert!(options::negotiate(
&adv_with("report-status"),
std::slice::from_ref(&del),
Kind::Sha1,
&d
)
.is_err());
assert!(options::negotiate(
&adv_with("report-status delete-refs"),
&[del],
Kind::Sha1,
&d
)
.is_ok());
}
#[test]
fn pushing_across_hash_kinds_is_refused() {
let err = options::negotiate(
&adv_with("report-status object-format=sha1"),
&[cmd_update()],
Kind::Sha256,
&SendPackOptions::default(),
)
.unwrap_err();
assert!(format!("{err}").contains("object format"), "{err}");
}
// ── the report, both ways ───────────────────────────────────────────────────
#[test]
fn report_status_v1_is_parsed() {
let r = report::parse(&[
pkt("unpack ok\n"),
pkt("ok refs/heads/main\n"),
pkt("ng refs/heads/next non-fast-forward\n"),
])
.unwrap();
assert_eq!(r.unpack, "ok");
assert!(r.refs[0].accepted);
assert!(!r.refs[1].accepted);
assert_eq!(r.refs[1].reason.as_deref(), Some("non-fast-forward"));
assert!(!r.is_ok(), "one ng makes the whole push not ok");
assert!(r.failure_summary().unwrap().contains("non-fast-forward"));
}
/// `unpack ok` with every ref rejected is a **failed** push that reports a
/// successful unpack. A client that keyed off `unpack` alone would call it
/// green: the identity-value trap, in protocol form.
#[test]
fn unpack_ok_with_all_refs_rejected_is_not_ok() {
let r = report::parse(&[
pkt("unpack ok\n"),
pkt("ng refs/heads/main hook declined\n"),
])
.unwrap();
assert_eq!(r.unpack, "ok");
assert!(!r.is_ok());
}
#[test]
fn report_status_v2_options_attach_to_their_ref() {
let r = report::parse(&[
pkt("unpack ok\n"),
pkt("ok refs/heads/main\n"),
pkt("option old-oid 1111111111111111111111111111111111111111\n"),
pkt("option forced-update\n"),
])
.unwrap();
assert_eq!(r.refs.len(), 1);
assert_eq!(
r.refs[0].options,
vec![
(
"old-oid".to_string(),
Some("1111111111111111111111111111111111111111".to_string())
),
("forced-update".to_string(), None),
]
);
}
#[test]
fn an_option_before_any_ref_status_is_a_protocol_error() {
assert!(report::parse(&[pkt("unpack ok\n"), pkt("option forced-update\n")]).is_err());
}
#[test]
fn a_failed_unpack_is_carried_verbatim() {
let r = report::parse(&[pkt("unpack index-pack abnormal exit\n")]).unwrap();
assert_eq!(r.unpack, "index-pack abnormal exit");
assert!(!r.is_ok());
}
/// The server half, which gunnar's `receive-pack` used to write separately.
#[test]
fn the_report_encoder_and_decoder_are_one_format() {
let statuses = vec![
RefStatus::ok("refs/heads/main"),
RefStatus::rejected("refs/heads/next", "non-fast-forward"),
];
let lines = report::lines(Ok(()), &statuses);
assert_eq!(
lines,
vec![
b"unpack ok".to_vec(),
b"ok refs/heads/main".to_vec(),
b"ng refs/heads/next non-fast-forward".to_vec(),
]
);
let round = report::parse(&lines).unwrap();
assert_eq!(round.refs, statuses);
assert_eq!(round.unpack, "ok");
assert!(!round.is_ok());
}
#[test]
fn a_refused_pack_is_encoded_and_read_back() {
let lines = report::lines(Err("index-pack abnormal exit"), &[]);
assert_eq!(lines, vec![b"unpack index-pack abnormal exit".to_vec()]);
assert_eq!(
report::parse(&lines).unwrap().unpack,
"index-pack abnormal exit"
);
}
/// A status reason is **one line**, and a rejection reason is the one field on
/// this wire that carries text a pusher can influence — a hook's stderr, a
/// gate's message.
///
/// This asserts the flattening itself, on the bytes, and not the stronger claim
/// it is tempting to write. *"A newline cannot forge a second status"* is
/// **not** testable here and is not asserted: pkt-lines are length-delimited,
/// so an embedded newline stays inside its own packet and
/// [`report::parse`] still returns exactly one status either way. Writing that
/// test would produce a guard that passes with the flattening removed — which
/// is precisely what the first version of this test did.
///
/// What the flattening does buy is a peer that splits on newlines, and a log
/// line that is one log line. Both are real; neither is visible to a decoder,
/// so the assertion is on the encoder's output.
#[test]
fn a_rejection_reason_is_flattened_to_one_line_before_it_reaches_the_wire() {
let statuses = vec![RefStatus::rejected(
"refs/heads/main",
"denied\nok refs/heads/secret",
)];
let lines = report::lines(Ok(()), &statuses);
assert_eq!(lines.len(), 2, "unpack + exactly one status");
assert_eq!(
lines[1],
b"ng refs/heads/main denied ok refs/heads/secret".to_vec(),
"the newline must have become a space"
);
for line in &lines {
assert!(
!line.contains(&b'\n') && !line.contains(&b'\r'),
"no payload may carry a line break: {:?}",
String::from_utf8_lossy(line)
);
}
// The same for a refused pack, whose text comes from an indexer.
let refused = report::lines(Err("index-pack died\nfatal: boom"), &[]);
assert_eq!(refused[0], b"unpack index-pack died fatal: boom".to_vec());
}
#[test]
fn v2_options_round_trip_through_the_encoder() {
let statuses = vec![RefStatus {
name: "refs/heads/main".into(),
accepted: true,
reason: None,
options: vec![
("old-oid".to_string(), Some(oid('1').to_string())),
("forced-update".to_string(), None),
],
}];
let lines = report::lines(Ok(()), &statuses);
assert_eq!(
lines,
vec![
b"unpack ok".to_vec(),
b"ok refs/heads/main".to_vec(),
format!("option old-oid {}", oid('1')).into_bytes(),
b"option forced-update".to_vec(),
]
);
assert_eq!(report::parse(&lines).unwrap().refs, statuses);
}