mod common;
use gix_hash::{Kind, ObjectId};
use gunnar_pktline::Packet;
use gunnar_store::git_storage_trait::{GitOps, GitServe, ObjType};
use gunnar_store::mem::MemGitServe;
use gunnar_wire::receive_pack::{AllowAll, PushGate, RefCommand};
use gunnar_wire::upload_pack::{Config, FetchRequest, LsRefsRequest, WantPolicy};
use gunnar_wire::{ProtocolVersion, ReceivePack, RefStatus, UploadPack};
use common::{
empty_pack, index_and_count, name, push_bytes, v2_request, validating_ingest, Fixture, Response,
};
const OBJECTS_PER_COMMIT: u32 = 3;
fn fetch(fixture: &Fixture, request: &FetchRequest) -> (Response, u32) {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
let outcome = upload.fetch(request, &mut out).expect("fetch");
(Response::parse(&out), outcome.objects_sent)
}
fn clone_request(wants: &[ObjectId]) -> FetchRequest {
FetchRequest {
wants: wants.to_vec(),
done: true,
thin_pack: true,
ofs_delta: true,
..Default::default()
}
}
#[test]
fn ls_refs_reports_head_first_with_its_symref_and_the_peeled_tag() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload
.ls_refs(
&LsRefsRequest {
symrefs: true,
peel: true,
unborn: true,
ref_prefixes: Vec::new(),
},
&mut out,
)
.expect("ls-refs");
let response = Response::parse(&out);
let lines = response.lines();
assert_eq!(
lines[0],
format!("{} HEAD symref-target:refs/heads/main", fixture.four),
"HEAD must come first, with its symref target: {lines:?}"
);
assert!(
lines.contains(&format!("{} refs/heads/main", fixture.four)),
"{lines:?}"
);
assert!(
lines.contains(&format!("{} refs/heads/side", fixture.three)),
"{lines:?}"
);
assert!(
lines.contains(&format!(
"{} refs/tags/v1 peeled:{}",
fixture.tag, fixture.merge
)),
"the annotated tag was not peeled: {lines:?}"
);
assert_eq!(response.packets.last(), Some(&Packet::Flush));
}
#[test]
fn ls_refs_without_peel_or_symrefs_reports_neither() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload
.ls_refs(&LsRefsRequest::default(), &mut out)
.expect("ls-refs");
let lines = Response::parse(&out).lines();
assert!(
lines.iter().all(|line| !line.contains("symref-target:")),
"symrefs were not asked for: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.contains("peeled:")),
"peeling was not asked for: {lines:?}"
);
}
#[test]
fn ref_prefix_narrows_the_answer() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload
.ls_refs(
&LsRefsRequest {
ref_prefixes: vec!["refs/tags/".to_owned()],
..Default::default()
},
&mut out,
)
.expect("ls-refs");
let lines = Response::parse(&out).lines();
assert_eq!(lines.len(), 1, "a tags-only prefix returned {lines:?}");
assert!(lines[0].ends_with("refs/tags/v1"));
}
#[test]
fn a_clone_sized_fetch_packs_the_whole_history_on_band_one() {
let fixture = Fixture::build(Kind::Sha1);
let (response, sent) = fetch(&fixture, &clone_request(&[fixture.four]));
assert!(
response.has_line("packfile"),
"no packfile section: {:?}",
response.lines()
);
let pack = response.band(1);
assert!(
!pack.is_empty(),
"the packfile section carried no band-1 data"
);
assert_eq!(&pack[..4], b"PACK");
let counted = index_and_count(&pack, Kind::Sha1);
assert_eq!(
counted,
5 * OBJECTS_PER_COMMIT,
"5 commits, 5 trees, 5 blobs"
);
assert_eq!(counted, sent, "the reported count disagrees with the pack");
assert_eq!(response.packets.last(), Some(&Packet::Flush));
}
#[test]
fn wanting_the_tag_packs_the_tag_object_too() {
let fixture = Fixture::build(Kind::Sha1);
let (response, _) = fetch(&fixture, &clone_request(&[fixture.four, fixture.tag]));
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
5 * OBJECTS_PER_COMMIT + 1
);
}
#[test]
fn both_parents_of_a_merge_are_walked() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
haves: vec![fixture.two],
..clone_request(&[fixture.merge])
};
let (response, _) = fetch(&fixture, &request);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"with `two` excluded the pack is `merge` and `three`; half that means \
the second parent was never followed"
);
}
#[test]
fn a_fetch_whose_haves_cover_the_wants_sends_a_legal_empty_pack() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
haves: vec![fixture.four],
..clone_request(&[fixture.four])
};
let (response, sent) = fetch(&fixture, &request);
assert_eq!(sent, 0);
let pack = response.band(1);
assert_eq!(
index_and_count(&pack, Kind::Sha1),
0,
"an empty pack must still be a pack gix will index"
);
}
#[test]
fn sha256_is_served_end_to_end_and_not_inferred_from_the_sha1_green() {
let fixture = Fixture::build(Kind::Sha256);
assert_eq!(fixture.four.to_hex().to_string().len(), 64);
let (response, sent) = fetch(&fixture, &clone_request(&[fixture.four]));
let pack = response.band(1);
assert_eq!(index_and_count(&pack, Kind::Sha256), 5 * OBJECTS_PER_COMMIT);
assert_eq!(sent, 5 * OBJECTS_PER_COMMIT);
}
#[test]
fn a_want_whose_hash_kind_is_wrong_is_refused() {
let fixture = Fixture::build(Kind::Sha256);
let sha1_want = format!("want {}", "a".repeat(40));
let err = FetchRequest::parse(&[sha1_want.into_bytes()], Kind::Sha256, Default::default())
.expect_err("a sha1 want on a sha256 repository must be refused");
assert!(
matches!(err, gunnar_wire::Error::ObjectFormatMismatch { .. }),
"unexpected error: {err}"
);
let _ = fixture;
}
#[test]
fn blob_none_really_omits_the_blobs() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
filter: Some(gunnar_wire::Filter::BlobNone),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &request);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
10,
"5 commits and 5 trees, no blobs"
);
let (plain, _) = fetch(&fixture, &clone_request(&[fixture.four]));
assert_eq!(index_and_count(&plain.band(1), Kind::Sha1), 15);
}
#[test]
fn tree_zero_sends_commits_only() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
filter: Some(gunnar_wire::Filter::TreeDepth(0)),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &request);
assert_eq!(index_and_count(&response.band(1), Kind::Sha1), 5);
}
#[test]
fn blob_limit_keeps_the_small_ones() {
let fixture = Fixture::build(Kind::Sha1);
let generous = FetchRequest {
filter: Some(gunnar_wire::Filter::BlobLimit(1024)),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &generous);
assert_eq!(index_and_count(&response.band(1), Kind::Sha1), 15);
let mean = FetchRequest {
filter: Some(gunnar_wire::Filter::BlobLimit(1)),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &mean);
assert_eq!(index_and_count(&response.band(1), Kind::Sha1), 10);
}
#[test]
fn an_unimplemented_filter_is_refused_by_name() {
let err = gunnar_wire::Filter::parse("sparse:oid=deadbeef")
.expect_err("gunnar does not implement sparse filters");
assert!(
matches!(err, gunnar_wire::Error::UnsupportedFilter { .. }),
"unexpected error: {err}"
);
assert_eq!(
gunnar_wire::Filter::parse("blob:limit=2k").unwrap(),
gunnar_wire::Filter::BlobLimit(2048)
);
}
#[test]
fn depth_one_reports_a_boundary_and_packs_one_commit() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.depth = Some(1);
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line("shallow-info"),
"no shallow-info section: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("shallow {}", fixture.four)),
"the boundary was not reported: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
OBJECTS_PER_COMMIT
);
}
#[test]
fn depth_two_reaches_both_parents_of_the_merge() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.depth = Some(3);
let (response, _) = fetch(&fixture, &request);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
4 * OBJECTS_PER_COMMIT
);
}
#[test]
fn a_root_commit_is_never_reported_as_shallow() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.depth = Some(99);
let (response, _) = fetch(&fixture, &request);
assert!(
!response.has_line(&format!("shallow {}", fixture.one)),
"the root commit was reported as grafted, which makes every later \
fetch ask for a parent that does not exist: {:?}",
response.lines()
);
}
#[test]
fn want_ref_resolves_the_name_and_echoes_a_wanted_refs_section() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
want_refs: vec!["refs/heads/side".to_owned()],
done: true,
..Default::default()
};
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line("wanted-refs"),
"no wanted-refs section: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("{} refs/heads/side", fixture.three)),
"the resolved name was not echoed: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"`side` is `one` then `three`"
);
}
#[test]
fn deepening_an_already_shallow_client_sends_the_commits_past_its_cut() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.haves = vec![fixture.four];
request.shallow.client_shallow = vec![fixture.four];
request.shallow.depth = Some(1);
request.shallow.relative = true;
let (response, sent) = fetch(&fixture, &request);
assert!(
response.has_line(&format!("unshallow {}", fixture.four)),
"the client's old boundary was not lifted: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("shallow {}", fixture.merge)),
"the new boundary was not reported: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
OBJECTS_PER_COMMIT,
"the pack must carry `merge`; the client cannot reach it and was just \
told its own tip is no longer grafted"
);
assert_eq!(sent, OBJECTS_PER_COMMIT);
}
#[test]
fn deepen_since_cuts_on_the_committer_date() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.since = Some(1_700_000_004);
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line(&format!("shallow {}", fixture.merge)),
"the date cut was not reported: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"only `four` and `merge` are new enough"
);
}
#[test]
fn deepen_not_cuts_at_a_named_reference() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.not = vec!["refs/heads/side".to_owned()];
let (response, _) = fetch(&fixture, &request);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
3 * OBJECTS_PER_COMMIT
);
assert!(
response.has_line(&format!("shallow {}", fixture.two)),
"{:?}",
response.lines()
);
}
#[test]
fn negotiation_terminates_on_a_divergent_history() {
let fixture = Fixture::build(Kind::Sha1);
let server_tip = fixture.extend(fixture.four, 40, "theirs");
fixture.force_ref("refs/heads/main", server_tip);
let mine = Fixture::build(Kind::Sha1);
let mut client_haves: Vec<ObjectId> = Vec::new();
let mut head = mine.four;
for index in 0..40 {
head = mine.extend(head, 1, &format!("mine{index}"));
client_haves.push(head);
}
client_haves.push(fixture.four);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut offered = 0usize;
let mut rounds = 0usize;
let mut ready = false;
while offered < client_haves.len() && rounds < 20 {
rounds += 1;
offered = (offered + 32).min(client_haves.len());
let request = FetchRequest {
wants: vec![server_tip],
haves: client_haves[..offered].to_vec(),
done: false,
..Default::default()
};
let mut out = Vec::new();
let outcome = upload.fetch(&request, &mut out).expect("a fetch round");
let response = Response::parse(&out);
assert!(
response.has_line("acknowledgments"),
"every non-final round owes the client an acknowledgments \
section, or the client waits forever: {:?}",
response.lines()
);
if outcome.ready {
ready = true;
assert!(response.has_line("ready"));
assert!(outcome.sent_pack, "a ready round must carry the pack");
let sent = index_and_count(&response.band(1), Kind::Sha1);
assert_eq!(
sent,
40 * OBJECTS_PER_COMMIT,
"the cut point was found but the pack still carries history \
the client already has"
);
break;
}
assert!(
!outcome.sent_pack,
"a round with no cut point must not send a pack"
);
}
assert!(
ready,
"negotiation ran {rounds} rounds without finding the cut point that \
was in the client's very last batch"
);
assert!(rounds >= 2, "the divergence was not actually exercised");
}
#[test]
fn a_round_with_no_common_ground_answers_nak_rather_than_silence() {
let fixture = Fixture::build(Kind::Sha1);
let stranger = Fixture::build(Kind::Sha1).extend(Fixture::build(Kind::Sha1).one, 1, "alien");
let request = FetchRequest {
wants: vec![fixture.four],
haves: vec![stranger],
done: false,
..Default::default()
};
let (response, _) = fetch(&fixture, &request);
assert!(response.has_line("NAK"), "{:?}", response.lines());
assert!(!response.has_line("ready"));
assert!(!response.has_line("packfile"));
}
#[test]
fn wait_for_done_suppresses_the_early_cut_point() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
wants: vec![fixture.four],
haves: vec![fixture.merge],
done: false,
wait_for_done: true,
..Default::default()
};
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line(&format!("ACK {}", fixture.merge)),
"the common commit was not acknowledged: {:?}",
response.lines()
);
assert!(
!response.has_line("ready"),
"wait-for-done was accepted and then ignored"
);
}
#[test]
fn a_done_request_skips_the_acknowledgments_section_entirely() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
haves: vec![fixture.merge],
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &request);
assert!(
!response.has_line("acknowledgments"),
"{:?}",
response.lines()
);
assert_eq!(
response.lines().first().map(String::as_str),
Some("packfile")
);
}
#[test]
fn an_unreachable_want_is_refused_and_a_reachable_one_is_not() {
let fixture = Fixture::build(Kind::Sha1);
let orphan = fixture.extend(fixture.four, 1, "orphan");
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
let err = upload
.fetch(&clone_request(&[orphan]), &mut out)
.expect_err("an unreachable object must not be served");
assert!(
matches!(err, gunnar_wire::Error::WantNotAdvertised { .. }),
"unexpected error: {err}"
);
let mut out = Vec::new();
upload
.fetch(&clone_request(&[fixture.one]), &mut out)
.expect("a reachable non-tip want must be served");
assert!(Response::parse(&out).has_line("packfile"));
}
#[test]
fn an_object_that_is_not_there_is_refused_exactly_like_one_that_is() {
let fixture = Fixture::build(Kind::Sha1);
let orphan = fixture.extend(fixture.four, 1, "orphan");
let absent = gix_hash::ObjectId::from_hex(b"ffffffffffffffffffffffffffffffffffffffff")
.expect("a legal sha1 that nothing hashes to");
let upload = UploadPack::new(&fixture.git, fixture.kind);
let present_but_hidden = upload
.fetch(&clone_request(&[orphan]), &mut Vec::new())
.expect_err("an unreachable object must not be served");
let not_there = upload
.fetch(&clone_request(&[absent]), &mut Vec::new())
.expect_err("an object that does not exist must not be served either");
assert!(
matches!(
(&present_but_hidden, ¬_there),
(
gunnar_wire::Error::WantNotAdvertised { .. },
gunnar_wire::Error::WantNotAdvertised { .. }
)
),
"the two cases answer differently, which tells a caller which oids this repository holds:
hidden: {present_but_hidden}
absent: {not_there}"
);
let shape = |err: &gunnar_wire::Error, oid: &gix_hash::ObjectId| {
err.client_message().replace(&oid.to_string(), "<oid>")
};
assert_eq!(
shape(&present_but_hidden, &orphan),
shape(¬_there, &absent),
"the client-visible sentences differ, so the oracle is still open"
);
let mut out = Vec::new();
upload
.fetch(&clone_request(&[fixture.one]), &mut out)
.expect("a reachable non-tip want must still be served");
assert!(Response::parse(&out).has_line("packfile"));
}
#[test]
fn the_strict_policy_refuses_even_a_reachable_non_tip() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind).with_config(Config {
want_policy: WantPolicy::AdvertisedTips,
..Default::default()
});
let mut out = Vec::new();
assert!(upload
.fetch(&clone_request(&[fixture.one]), &mut out)
.is_err());
let mut out = Vec::new();
assert!(upload
.fetch(&clone_request(&[fixture.four]), &mut out)
.is_ok());
}
#[test]
fn the_v0_advertisement_carries_capabilities_after_a_nul_on_the_first_line() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload.write_ref_advertisement(&mut out).expect("advertise");
let response = Response::parse(&out);
let Packet::Data(first) = &response.packets[0] else {
panic!("the first packet is not a line");
};
let nul = first.iter().position(|&b| b == 0).expect("a NUL separator");
let (head, caps) = first.split_at(nul);
let caps = String::from_utf8_lossy(&caps[1..]);
assert_eq!(
String::from_utf8_lossy(head),
format!("{} HEAD", fixture.four)
);
assert!(
caps.contains("symref=HEAD:refs/heads/main"),
"no symref capability: {caps}"
);
assert!(caps.contains("side-band-64k"), "{caps}");
assert!(caps.contains("object-format=sha1"), "{caps}");
let lines = response.lines();
assert!(
lines.contains(&format!("{} refs/tags/v1^{{}}", fixture.merge)),
"the peeled tag line is missing: {lines:?}"
);
assert_eq!(response.packets.last(), Some(&Packet::Flush));
}
#[test]
fn a_v0_clone_negotiates_and_ends_its_sideband_with_a_flush() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!(
"want {} side-band-64k thin-pack ofs-delta agent=test/1",
fixture.four
)
.as_bytes(),
)
.unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
gunnar_pktline::write_text(&mut request, b"done").unwrap();
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
let summary = upload.serve_v0(&mut input, &mut out).expect("serve v0");
assert_eq!(summary.protocol, ProtocolVersion::V0);
let response = Response::parse(&out);
assert!(response.has_line("NAK"), "{:?}", response.lines());
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
5 * OBJECTS_PER_COMMIT
);
assert_eq!(
response.packets.last(),
Some(&Packet::Flush),
"a sidebanded v0 response must end with an unframed flush-pkt"
);
}
#[test]
fn a_v0_deepen_relative_capability_counts_past_the_clients_cut() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!(
"want {} side-band-64k thin-pack ofs-delta shallow deepen-relative agent=test/1",
fixture.four
)
.as_bytes(),
)
.unwrap();
gunnar_pktline::write_text(&mut request, format!("shallow {}", fixture.four).as_bytes())
.unwrap();
gunnar_pktline::write_text(&mut request, b"deepen 1").unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
gunnar_pktline::write_text(&mut request, format!("have {}", fixture.four).as_bytes()).unwrap();
gunnar_pktline::write_text(&mut request, b"done").unwrap();
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
let response = Response::parse(&out);
assert!(
response.has_line(&format!("unshallow {}", fixture.four)),
"a relative deepen must lift the client's old boundary: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("shallow {}", fixture.merge)),
"the new boundary is one commit past the cut: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
OBJECTS_PER_COMMIT,
"the pack must carry `merge`, the one commit past the client's cut"
);
}
fn v0_haves_then_done(fixture: &Fixture, caps: &str, have: ObjectId) -> Response {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!("want {} {caps} agent=test/1", fixture.four).as_bytes(),
)
.unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
gunnar_pktline::write_text(&mut request, format!("have {have}").as_bytes()).unwrap();
gunnar_pktline::write_text(&mut request, b"done").unwrap();
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
Response::parse(&out)
}
#[test]
fn a_v0_have_in_the_block_done_closes_is_acknowledged_in_both_ack_modes() {
let fixture = Fixture::build(Kind::Sha1);
for caps in [
"multi_ack_detailed side-band-64k ofs-delta",
"side-band-64k ofs-delta",
] {
let response = v0_haves_then_done(&fixture, caps, fixture.three);
assert!(
response.has_line(&format!("ACK {}", fixture.three)),
"[{caps}] the common `have` was not acknowledged: {:?}",
response.lines()
);
assert!(
!response.has_line("NAK"),
"[{caps}] a common `have` answered NAK: {:?}",
response.lines()
);
}
let unknown = ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap();
let response = v0_haves_then_done(&fixture, "multi_ack_detailed side-band-64k", unknown);
assert!(response.has_line("NAK"), "{:?}", response.lines());
}
#[test]
fn a_v0_client_that_only_wants_the_advertisement_gets_a_clean_end() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut input = std::io::Cursor::new(Vec::new());
let mut out = Vec::new();
upload
.serve_v0(&mut input, &mut out)
.expect("`git ls-remote` hanging up is not an error");
assert!(Response::parse(&out).lines().len() >= 3);
}
#[test]
fn a_push_moves_the_reference_and_reports_ok() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/from-a-push")],
"report-status object-format=sha1 agent=test/1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("serve the push")
.expect("a push was made");
assert_eq!(outcome.statuses, vec![RefStatus::Ok]);
let response = Response::parse(&out);
assert!(response.has_line("unpack ok"), "{:?}", response.lines());
assert!(
response.has_line("ok refs/heads/from-a-push"),
"{:?}",
response.lines()
);
assert_eq!(
fixture.read_ref("refs/heads/from-a-push").unwrap(),
fixture.merge
);
}
#[test]
fn a_push_whose_stated_old_value_is_wrong_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let input = push_bytes(
&[(
&fixture.one.to_string(),
&fixture.merge.to_string(),
"refs/heads/main",
)],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(matches!(outcome.statuses[0], RefStatus::Rejected(_)));
assert!(
Response::parse(&out)
.lines()
.iter()
.any(|line| line.starts_with("ng refs/heads/main")),
"the client was not told: {:?}",
Response::parse(&out).lines()
);
assert_eq!(
fixture.read_ref("refs/heads/main").unwrap(),
fixture.four,
"the reference moved despite the refusal"
);
}
#[test]
fn a_tip_whose_objects_are_missing_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let absent = "1".repeat(40);
let input = push_bytes(
&[(&zero, &absent, "refs/heads/incomplete")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(
outcome.unpack.is_ok(),
"the pack itself was fine; only its closure was not"
);
match &outcome.statuses[0] {
RefStatus::Rejected(reason) => assert!(
reason.contains("missing"),
"unhelpful rejection reason: {reason}"
),
RefStatus::Ok => panic!("a reference was pointed at an object that is not here"),
}
assert!(fixture.read_ref("refs/heads/incomplete").is_none());
}
#[test]
fn a_mixed_push_refuses_only_the_reference_that_does_not_close() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let absent = "1".repeat(40);
let input = push_bytes(
&[
(&zero, &fixture.merge.to_string(), "refs/heads/good"),
(&zero, &absent, "refs/heads/incomplete"),
],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(
outcome.statuses[0],
RefStatus::Ok,
"refs/heads/good closes on its own and was refused anyway, so the batched \
connectivity walk took a whole mirror push down over one bad tip"
);
match &outcome.statuses[1] {
RefStatus::Rejected(reason) => assert!(
reason.contains("missing"),
"unhelpful rejection reason: {reason}"
),
RefStatus::Ok => panic!("refs/heads/incomplete points at an object that is not here"),
}
assert_eq!(
fixture.read_ref("refs/heads/good"),
Some(fixture.merge),
"refs/heads/good was reported ok and did not move"
);
assert!(fixture.read_ref("refs/heads/incomplete").is_none());
let lines = Response::parse(&out).lines();
assert!(
lines.iter().any(|line| line == "ok refs/heads/good"),
"the client was not told refs/heads/good landed: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.starts_with("ng refs/heads/incomplete")),
"the client was not told refs/heads/incomplete was refused: {lines:?}"
);
}
#[test]
fn atomic_refuses_the_whole_batch_and_leaves_nothing_applied() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[
(&zero, &fixture.merge.to_string(), "refs/heads/good"),
(&zero, &fixture.merge.to_string(), "refs/heads/side"),
],
"report-status atomic object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(outcome.atomic);
assert!(
outcome.statuses.iter().all(|status| !status.is_ok()),
"an atomic batch reported a partial success: {:?}",
outcome.statuses
);
assert!(
fixture.read_ref("refs/heads/good").is_none(),
"the good half of an atomic push landed; it is not atomic"
);
assert_eq!(fixture.read_ref("refs/heads/side").unwrap(), fixture.three);
}
#[test]
fn without_atomic_the_good_half_of_a_batch_lands() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[
(&zero, &fixture.merge.to_string(), "refs/heads/good"),
(&zero, &fixture.merge.to_string(), "refs/heads/side"),
],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(outcome.statuses[0].is_ok(), "{:?}", outcome.statuses);
assert!(!outcome.statuses[1].is_ok());
assert_eq!(fixture.read_ref("refs/heads/good").unwrap(), fixture.merge);
}
#[test]
fn a_delete_only_push_reads_no_packfile() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&fixture.three.to_string(), &zero, "refs/heads/side")],
"report-status delete-refs object-format=sha1",
&[],
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&(|_: &mut dyn std::io::BufRead| panic!("no pack should have been read")),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(outcome.statuses, vec![RefStatus::Ok]);
assert!(outcome.pack.is_none());
assert!(fixture.read_ref("refs/heads/side").is_none());
}
#[test]
fn push_options_are_read_only_when_the_capability_was_negotiated() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let mut input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/opts")],
"report-status push-options object-format=sha1",
&[],
);
let mut options = Vec::new();
gunnar_pktline::write_text(&mut options, b"reviewer=nobody").unwrap();
gunnar_pktline::write_flush(&mut options).unwrap();
let flush_at = input.len();
input.splice(flush_at.., options);
input.extend_from_slice(&empty_pack(Kind::Sha1));
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(outcome.push_options, vec!["reviewer=nobody".to_owned()]);
}
#[test]
fn a_gate_refuses_on_a_push_option_it_read_out_of_the_facts() {
struct DemandsAnOption;
impl PushGate for DemandsAnOption {
fn check(
&self,
_command: &RefCommand,
facts: &gunnar_wire::PushFacts<'_>,
) -> Result<(), String> {
if facts.push_options.iter().any(|o| o == "ci.ok") {
Ok(())
} else {
Err(format!("the gate saw {:?}", facts.push_options))
}
}
}
fn push_with(
refname: &str,
options: &[&str],
) -> (gunnar_wire::receive_pack::PushOutcome, bool) {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let mut input = push_bytes(
&[(&zero, &fixture.merge.to_string(), refname)],
"report-status push-options object-format=sha1",
&[],
);
let mut section = Vec::new();
for option in options {
gunnar_pktline::write_text(&mut section, option.as_bytes()).unwrap();
}
gunnar_pktline::write_flush(&mut section).unwrap();
let at = input.len();
input.splice(at.., section);
input.extend_from_slice(&empty_pack(Kind::Sha1));
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&DemandsAnOption,
)
.unwrap()
.unwrap();
let exists = fixture.read_ref(refname).is_some();
(outcome, exists)
}
let (refused, moved) = push_with("refs/heads/needs-opt", &["ci.nope"]);
match &refused.statuses[0] {
RefStatus::Rejected(reason) => assert!(
reason.contains("ci.nope"),
"the gate did not see the option the client sent: {reason}"
),
other => panic!("the gate did not refuse: {other:?}"),
}
assert!(
!moved,
"the reference was applied despite the gate refusing on a push option"
);
let (bare, moved) = push_with("refs/heads/needs-opt", &[]);
assert!(matches!(bare.statuses[0], RefStatus::Rejected(_)));
assert!(!moved);
let (allowed, moved) = push_with("refs/heads/has-opt", &["ci.ok"]);
assert_eq!(
allowed.statuses,
vec![RefStatus::Ok],
"a gate that refuses everything would satisfy both reds above"
);
assert!(moved, "the allowed push did not apply its reference");
}
#[test]
fn a_gate_that_refuses_is_obeyed() {
struct RefuseEverything;
impl PushGate for RefuseEverything {
fn check(
&self,
command: &RefCommand,
_facts: &gunnar_wire::PushFacts<'_>,
) -> Result<(), String> {
Err(format!("{} is not allowed here", command.name))
}
}
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/gated")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&RefuseEverything,
)
.unwrap()
.unwrap();
assert!(matches!(outcome.statuses[0], RefStatus::Rejected(_)));
assert!(fixture.read_ref("refs/heads/gated").is_none());
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/gated")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(outcome.statuses, vec![RefStatus::Ok]);
}
#[test]
fn the_sidebanded_report_is_wrapped_and_then_the_stream_is_flushed() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/banded")],
"report-status side-band-64k object-format=sha1",
&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,
)
.unwrap();
let response = Response::parse(&out);
let inner = Response::parse(&response.band(1));
assert!(inner.has_line("unpack ok"), "{:?}", inner.lines());
assert!(
inner.has_line("ok refs/heads/banded"),
"{:?}",
inner.lines()
);
assert_eq!(
inner.packets.last(),
Some(&Packet::Flush),
"the report inside the band is not flush-terminated"
);
assert_eq!(
response.packets.last(),
Some(&Packet::Flush),
"the outer stream is not flush-terminated"
);
}
#[test]
fn a_client_that_only_wanted_the_advertisement_is_not_a_push() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let mut input = std::io::Cursor::new(Vec::new());
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&(|_: &mut dyn std::io::BufRead| panic!("no pack")),
&AllowAll,
)
.expect("hanging up after the advertisement is normal");
assert!(outcome.is_none());
assert!(!Response::parse(&out).lines().is_empty());
}
#[test]
fn an_empty_repository_still_advertises_its_hash_kind() {
let store = MemGitServe::new(Kind::Sha256);
let receive = ReceivePack::new(&store, Kind::Sha256);
let mut out = Vec::new();
receive.write_ref_advertisement(&mut out).unwrap();
let response = Response::parse(&out);
let Packet::Data(first) = &response.packets[0] else {
panic!("no advertisement line");
};
let text = String::from_utf8_lossy(first);
assert!(text.starts_with(&Kind::Sha256.null().to_string()), "{text}");
assert!(text.contains("capabilities^{}"), "{text}");
assert!(text.contains("object-format=sha256"), "{text}");
let upload = UploadPack::new(&store, Kind::Sha256);
let mut out = Vec::new();
upload.write_ref_advertisement(&mut out).unwrap();
let response = Response::parse(&out);
let Packet::Data(first) = &response.packets[0] else {
panic!("no advertisement line");
};
let text = String::from_utf8_lossy(first);
assert!(text.contains("capabilities^{}"), "{text}");
assert!(text.contains("object-format=sha256"), "{text}");
let _: String = name("refs/heads/main");
}
#[test]
fn ack_names_the_head_of_a_have_chain_and_nothing_behind_it() {
let fixture = Fixture::build(Kind::Sha1);
let mut chain: Vec<ObjectId> = Vec::new();
let mut head = fixture.four;
for index in 0..40 {
head = fixture.extend(head, 1, &format!("shared{index}"));
chain.push(head);
}
let server_tip = fixture.extend(head, 3, "new");
fixture.force_ref("refs/heads/main", server_tip);
let haves: Vec<ObjectId> = chain.iter().rev().copied().collect();
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
let outcome = upload
.fetch(
&FetchRequest {
wants: vec![server_tip],
haves: haves.clone(),
done: false,
..Default::default()
},
&mut out,
)
.expect("a fetch round");
let response = Response::parse(&out);
let acks: Vec<String> = response
.lines()
.into_iter()
.filter(|l| l.starts_with("ACK "))
.collect();
assert_eq!(
acks,
vec![format!("ACK {}", chain[chain.len() - 1])],
"40 `have`s forming one parent chain are one piece of news, and only \
the head of the chain is it; git sends exactly this one line"
);
assert_eq!(
outcome.common.len(),
chain.len(),
"the pack is computed from every common `have`, not from the `ACK`ed \
ones; shrinking the wire list must not shrink this"
);
assert!(outcome.ready && outcome.sent_pack, "{:?}", response.lines());
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
3 * OBJECTS_PER_COMMIT,
"the client is sent the three commits it lacks and nothing else"
);
let mut again = Vec::new();
upload
.fetch(
&FetchRequest {
wants: vec![server_tip],
haves,
done: false,
..Default::default()
},
&mut again,
)
.expect("a second round");
let repeat = Response::parse(&again);
assert!(
repeat.has_line("NAK"),
"a round that repeats known `have`s owes the client a NAK, not an \
empty acknowledgments section: {:?}",
repeat.lines()
);
assert!(
!repeat.lines().iter().any(|l| l.starts_with("ACK ")),
"nothing in the second round was news: {:?}",
repeat.lines()
);
}
fn v0_negotiation(want: &ObjectId, blocks: &[&[ObjectId]]) -> Vec<u8> {
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!("want {want} side-band-64k thin-pack ofs-delta agent=test/1").as_bytes(),
)
.unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
for block in blocks {
for id in *block {
gunnar_pktline::write_text(&mut request, format!("have {id}").as_bytes()).unwrap();
}
gunnar_pktline::write_flush(&mut request).unwrap();
}
gunnar_pktline::write_text(&mut request, b"done").unwrap();
request
}
fn v0_acknowledgments(response: &Response) -> Vec<String> {
response
.packets
.iter()
.filter_map(|packet| match packet {
Packet::Data(payload) => {
if matches!(payload.first(), Some(1..=3)) {
return None;
}
let text = String::from_utf8_lossy(
payload.strip_suffix(b"\n").unwrap_or(payload.as_slice()),
)
.into_owned();
(text == "NAK" || text.starts_with("ACK ")).then_some(text)
}
_ => None,
})
.collect()
}
#[test]
fn a_v0_negotiation_that_finds_a_common_commit_acknowledges_it_once() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let request = v0_negotiation(&fixture.four, &[&[fixture.two]]);
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
let response = Response::parse(&out);
let lines = v0_acknowledgments(&response);
let acks = lines.iter().filter(|l| l.starts_with("ACK ")).count();
assert_eq!(
acks, 1,
"single-ACK v0 owes the client exactly one `ACK`; a second one is read \
as a sideband band number: {lines:?}"
);
assert_eq!(
lines.iter().filter(|l| l.as_str() == "NAK").count(),
0,
"nothing may follow the `ACK`: {lines:?}"
);
}
#[test]
fn a_v0_negotiation_says_nothing_more_after_the_block_that_was_acknowledged() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let stranger = ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap();
let request = v0_negotiation(
&fixture.four,
&[&[stranger], &[fixture.two], &[fixture.three]],
);
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
let response = Response::parse(&out);
let lines = v0_acknowledgments(&response);
assert_eq!(
lines,
vec!["NAK".to_owned(), format!("ACK {}", fixture.two)],
"after the acknowledgment the server owes the client nothing but the \
pack; git reads the next byte as a band number"
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"the pack must still carry what the negotiation did not cover"
);
}
fn commit_with_blob_of(
git: &dyn GitServe,
file: &str,
bytes: usize,
parents: &[ObjectId],
at: i64,
) -> ObjectId {
let content = "x".repeat(bytes).into_bytes();
let blob = gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, &content)
.expect("hash the blob");
let mut tree = Vec::new();
tree.extend_from_slice(b"100644 ");
tree.extend_from_slice(file.as_bytes());
tree.push(0);
tree.extend_from_slice(blob.as_bytes());
let tree_id =
gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Tree, &tree).expect("hash the tree");
let mut text = format!("tree {}\n", tree_id.to_hex());
for parent in parents {
text.push_str(&format!("parent {}\n", parent.to_hex()));
}
text.push_str(&format!(
"author Gunnar Test <gunnar@example.invalid> {at} +0000\n\
committer Gunnar Test <gunnar@example.invalid> {at} +0000\n\nsized\n"
));
let ids = common::write_pack_of(
git,
Kind::Sha1,
&[
(gix_object::Kind::Blob, content),
(gix_object::Kind::Tree, tree),
(gix_object::Kind::Commit, text.into_bytes()),
],
);
assert_eq!(
git.header(blob.as_bytes())
.expect("header")
.expect("the blob exists"),
(ObjType::Blob, bytes as u64),
"the fixture blob is not the size this test is about"
);
ids[2]
}
#[test]
fn a_blob_limit_omits_the_blob_that_is_exactly_the_limit() {
let store = MemGitServe::new(Kind::Sha1);
let under = commit_with_blob_of(&store, "under.txt", 1023, &[], 1_700_000_001);
let exact = commit_with_blob_of(&store, "exact.txt", 1024, &[under], 1_700_000_002);
let over = commit_with_blob_of(&store, "over.txt", 1025, &[exact], 1_700_000_003);
store
.put_refs(&[gunnar_store::git_storage_trait::RefUpdate::set(
"refs/heads/main",
over.to_string(),
)])
.expect("install the reference");
let upload = UploadPack::new(&store, Kind::Sha1);
let mut out = Vec::new();
upload
.fetch(
&FetchRequest {
wants: vec![over],
done: true,
thin_pack: false,
ofs_delta: true,
filter: Some(gunnar_wire::Filter::BlobLimit(1024)),
..Default::default()
},
&mut out,
)
.expect("a filtered fetch");
let response = Response::parse(&out);
let pack = response.band(1);
let delivered = blob_sizes_in_pack(&pack, Kind::Sha1);
assert!(
delivered.contains(&1023),
"the blob UNDER the limit must be delivered; got {delivered:?}"
);
assert!(
!delivered.contains(&1024),
"git omits a blob of exactly the limit and so must gunnar; got {delivered:?}"
);
assert!(
!delivered.contains(&1025),
"the blob OVER the limit must be omitted; got {delivered:?}"
);
}
fn blob_sizes_in_pack(pack: &[u8], kind: Kind) -> Vec<u64> {
let dir = tempfile::tempdir().expect("tempdir");
let out = std::process::Command::new("git")
.args(["init", "-q", "--bare"])
.arg(format!(
"--object-format={}",
match kind {
Kind::Sha256 => "sha256",
_ => "sha1",
}
))
.arg(dir.path())
.output()
.expect("git init");
assert!(out.status.success(), "git init: {out:?}");
let mut child = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["index-pack", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("git index-pack");
use std::io::Write as _;
child
.stdin
.as_mut()
.expect("stdin")
.write_all(pack)
.expect("write the pack");
let done = child.wait_with_output().expect("index-pack");
assert!(
done.status.success(),
"stock git refused the pack: {}",
String::from_utf8_lossy(&done.stderr)
);
let listed = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args([
"cat-file",
"--batch-all-objects",
"--batch-check=%(objecttype) %(objectsize)",
])
.output()
.expect("cat-file");
String::from_utf8_lossy(&listed.stdout)
.lines()
.filter_map(|line| line.strip_prefix("blob "))
.filter_map(|size| size.trim().parse::<u64>().ok())
.collect()
}
fn v2_served_bytes(fixture: &Fixture, request: &[u8]) -> (Vec<u8>, Option<String>) {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut input = std::io::Cursor::new(request.to_vec());
let mut out = Vec::new();
let outcome = upload.serve_session(
ProtocolVersion::V2,
gunnar_wire::Session::Connected,
&mut input,
&mut out,
);
(out, outcome.err().map(|err| err.to_string()))
}
#[test]
fn a_refused_want_tells_the_client_which_object_and_why() {
let fixture = Fixture::build(Kind::Sha1);
let stranger = ObjectId::from_hex(b"0000000000000000000000000000000000000001").unwrap();
let request = v2_request(
"fetch",
&["object-format=sha1"],
&[&format!("want {stranger}"), "done"],
);
let (bytes, err) = v2_served_bytes(&fixture, &request);
assert!(err.is_some(), "the fetch must still fail");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("ERR "),
"a refusal owes the client an `ERR` line, or git prints only \"the \
remote end hung up unexpectedly\": {text:?}"
);
assert!(
text.contains(&stranger.to_string()),
"the `ERR` line must name the object that was refused: {text:?}"
);
}
#[test]
fn a_refused_filter_tells_the_client_what_is_supported() {
let fixture = Fixture::build(Kind::Sha1);
let request = v2_request(
"fetch",
&["object-format=sha1"],
&[
&format!("want {}", fixture.four),
"filter sparse:oid=deadbeef",
"done",
],
);
let (bytes, err) = v2_served_bytes(&fixture, &request);
assert!(err.is_some(), "an unsupported filter must still fail");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("ERR ") && text.contains("sparse:oid"),
"the client must be told which filter was refused: {text:?}"
);
assert!(
text.contains("blob:none"),
"and what it could have asked for instead: {text:?}"
);
}
#[test]
fn a_client_message_never_carries_a_server_path() {
let leaky = gunnar_store::Error::StoreLocked {
path: std::path::PathBuf::from("/srv/gunnar/data/repos/acme/secret.git/gunnar-store.lock"),
};
let rendered = leaky.to_string();
assert!(
rendered.contains("/srv/gunnar/data/repos/acme/secret.git"),
"the fixture must actually carry a path, or this test proves nothing: \
{rendered}"
);
let wrapped: gunnar_wire::Error = leaky.into();
let message = wrapped.client_message();
assert!(
!message.contains('/'),
"a client message must not carry a server path: {message:?}"
);
assert!(
!message.is_empty(),
"and it must still say something, or the client learns nothing"
);
let named = gunnar_wire::Error::UnsupportedFilter {
spec: "sparse:oid=deadbeef".to_owned(),
reason: "gunnar implements blob:none, blob:limit=<n> and tree:<depth>",
};
assert!(
named.client_message().contains("sparse:oid=deadbeef"),
"a refusal that names the client's own request must not be redacted"
);
}
fn shallow_push_bytes(
grafts: &[ObjectId],
commands: &[(&str, &str, &str)],
capabilities: &str,
pack: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
for graft in grafts {
gunnar_pktline::write_text(&mut out, format!("shallow {graft}").as_bytes()).unwrap();
}
for (index, (old, new, name)) in commands.iter().enumerate() {
let mut line = format!("{old} {new} {name}").into_bytes();
if index == 0 {
line.push(0);
line.extend_from_slice(capabilities.as_bytes());
}
gunnar_pktline::write_text(&mut out, &line).unwrap();
}
gunnar_pktline::write_flush(&mut out).unwrap();
out.extend_from_slice(pack);
out
}
#[test]
fn a_push_from_a_shallow_client_is_not_derailed_by_its_grafts() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = shallow_push_bytes(
&[fixture.one, fixture.two],
&[(&zero, &fixture.merge.to_string(), "refs/heads/from-shallow")],
"report-status side-band-64k object-format=sha1 agent=git/2.53.0",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("a shallow client's push is still a push")
.expect("it carried a command");
assert_eq!(
outcome.statuses,
vec![RefStatus::Ok],
"the graft lines are not commands and must not reach the reference name"
);
assert_eq!(
fixture.read_ref("refs/heads/from-shallow"),
Some(fixture.merge),
"and the reference has to have actually moved"
);
let response = Response::parse(&out);
assert!(
response
.packets
.iter()
.any(|packet| matches!(packet, Packet::Data(payload) if payload.first() == Some(&1))),
"the capabilities rode the first COMMAND, not the first packet; without \
them a shallow pusher silently loses report-status, side-band-64k, \
atomic, push-options and the object-format check: {:?}",
response.lines()
);
}
#[test]
fn the_connectivity_budget_refuses_a_push_rather_than_accepting_it_unchecked() {
let fixture = Fixture::build(Kind::Sha1);
let tip = fixture.extend(fixture.four, 6, "budget");
let zero = Kind::Sha1.null().to_string();
let push = |budget: Option<usize>| {
let receive = ReceivePack::new(&fixture.git, fixture.kind).with_config(
gunnar_wire::receive_pack::Config {
budget,
..gunnar_wire::receive_pack::Config::default()
},
);
let input = push_bytes(
&[(&zero, &tip.to_string(), "refs/heads/budgeted")],
"report-status object-format=sha1",
&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("the session itself completes")
.expect("a push outcome")
};
let served = push(gunnar_wire::receive_pack::Config::default().budget);
assert_eq!(
served.statuses[0],
RefStatus::Ok,
"the same push under the default budget was refused, so the arm below is not about the \
budget"
);
assert_eq!(
fixture.read_ref("refs/heads/budgeted"),
Some(tip),
"the control reported ok and the reference did not move"
);
fixture
.git
.update_ref("refs/heads/budgeted", Some(tip.as_bytes()), None)
.expect("remove the control's reference");
let refused = push(Some(1));
match &refused.statuses[0] {
RefStatus::Rejected(reason) => assert!(
!reason.is_empty(),
"the reference was rejected with an EMPTY reason, so `git push` prints a refusal \
that says nothing"
),
RefStatus::Ok => panic!(
"a budget of 1 served a push over 6 new commits: the connectivity walk either never \
ran or ran unbounded, and either way the ceiling is decoration"
),
}
assert!(
fixture.read_ref("refs/heads/budgeted").is_none(),
"the push was reported as rejected and the reference moved anyway, which is the worst of \
both answers"
);
}