crates/gunnar-wire/tests/stateless_rpc.rs
//! [`Session::StatelessRpc`]: what smart HTTP needs from these state machines,
//! and nothing more.
//!
//! Smart HTTP is not a second protocol. It is the same pkt-line conversation cut
//! into independent request/response pairs, so the only thing `gunnar-wire` has
//! to grow is a way to say *"this call is one pair"*. These tests pin the three
//! differences that produces, and each one is written so it **fails against
//! [`Session::Connected`]** — which is the same code path with the flag the
//! other way, so the red half is a real alternative implementation and not a
//! contrived one.
//!
//! | asserted here | why it matters over HTTP |
//! |---|---|
//! | a stateless call writes no advertisement | a `POST` body that began with the ref list would put every ref in front of every negotiation round, and `git` would read it as a protocol error |
//! | [`UploadPack::advertise`] equals what the connected path writes inline | the `GET /info/refs` bytes and the SSH bytes must be one implementation, or the two transports drift |
//! | a v0 round with no `done` answers and returns | otherwise the server blocks reading a body that has already ended, while the client waits for a response |
//! | a v2 call serves exactly one command | a second command would be answered against negotiation state the next `POST` cannot see |
mod common;
use std::io::Cursor;
use gix_hash::Kind;
use gunnar_wire::{ProtocolVersion, Session, UploadPack};
use common::{Fixture, Response};
fn upload<'a>(fixture: &'a Fixture) -> UploadPack<'a> {
UploadPack::new(&fixture.git, fixture.kind)
}
/// A v0 request body as `git`'s HTTP client sends it, captured from
/// `git 2.53.0` against `git http-backend` on 2026-08-03:
///
/// ```text
/// 00aawant <oid> multi_ack_detailed no-done side-band-64k thin-pack …
/// 0000
/// 0009done
/// ```
///
/// Note the shape: **the `want` section's flush, then `done` with no trailing
/// flush.** A round that is still negotiating replaces `done` with a `have`
/// list and its own flush, which is the [`no_done`](v0_body_no_done) form below.
fn v0_body_with_done(want: &str, capabilities: &str) -> Vec<u8> {
let mut body = Vec::new();
line(&mut body, &format!("want {want} {capabilities}\n"));
body.extend_from_slice(b"0000");
line(&mut body, "done\n");
body
}
/// The same, but negotiating: `want`s, flush, `have`s, flush, and no `done`.
fn v0_body_no_done(want: &str, capabilities: &str, haves: &[String]) -> Vec<u8> {
let mut body = Vec::new();
line(&mut body, &format!("want {want} {capabilities}\n"));
body.extend_from_slice(b"0000");
for have in haves {
line(&mut body, &format!("have {have}\n"));
}
body.extend_from_slice(b"0000");
body
}
fn line(out: &mut Vec<u8>, text: &str) {
out.extend_from_slice(format!("{:04x}", text.len() + 4).as_bytes());
out.extend_from_slice(text.as_bytes());
}
/// The v2 `POST` body for one command, exactly as captured from `git 2.53.0`.
fn v2_body(command: &str, capabilities: &[&str], arguments: &[&str]) -> Vec<u8> {
common::v2_request(command, capabilities, arguments)
}
// ── the advertisement is a different request ────────────────────────────────
/// **A stateless call writes no advertisement.**
///
/// Red half: the identical body served with [`Session::Connected`] *does*, and
/// the assertion below distinguishes them by looking at the first packet rather
/// than at a byte count, so a shorter advertisement could not sneak past.
#[test]
fn a_stateless_v0_post_does_not_re_advertise() {
let fixture = Fixture::build(Kind::Sha1);
let upload = upload(&fixture);
let body = v0_body_with_done(&fixture.four.to_string(), "side-band-64k ofs-delta");
let mut stateless = Vec::new();
upload
.serve_session(
ProtocolVersion::V0,
Session::StatelessRpc,
&mut Cursor::new(body.clone()),
&mut stateless,
)
.expect("serve one stateless v0 round");
let first = Response::parse(&stateless).lines().first().cloned();
assert_eq!(
first.as_deref().map(str::trim),
Some("NAK"),
"a stateless-rpc POST must answer with the negotiation, not with a ref \
advertisement; got {:?}",
Response::parse(&stateless).lines()
);
// The red half, run rather than described: the same body, connected.
let mut connected = Vec::new();
upload
.serve_session(
ProtocolVersion::V0,
Session::Connected,
&mut Cursor::new(body),
&mut connected,
)
.expect("serve the same body on a connection");
let first_connected = Response::parse(&connected).lines().first().cloned();
assert!(
first_connected
.as_deref()
.is_some_and(|line| line.contains("refs/heads/main")),
"the connected path is supposed to advertise first; if it does not, the \
assertion above proves nothing. Got {first_connected:?}"
);
}
/// The same for v2: no `version 2` capability advertisement inside a `POST`.
#[test]
fn a_stateless_v2_post_does_not_re_advertise() {
let fixture = Fixture::build(Kind::Sha1);
let upload = upload(&fixture);
let body = v2_body(
"ls-refs",
&["object-format=sha1"],
&["peel", "symrefs", "ref-prefix refs/heads/"],
);
let mut stateless = Vec::new();
upload
.serve_session(
ProtocolVersion::V2,
Session::StatelessRpc,
&mut Cursor::new(body.clone()),
&mut stateless,
)
.expect("serve one stateless v2 command");
let lines = Response::parse(&stateless).lines();
assert!(
!lines.iter().any(|line| line.trim() == "version 2"),
"a stateless-rpc POST must not repeat the capability advertisement: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("refs/heads/main")),
"…but it must still answer the command: {lines:?}"
);
let mut connected = Vec::new();
upload
.serve_session(
ProtocolVersion::V2,
Session::Connected,
&mut Cursor::new(body),
&mut connected,
)
.expect("serve the same body on a connection");
assert!(
Response::parse(&connected)
.lines()
.iter()
.any(|line| line.trim() == "version 2"),
"the connected path is supposed to advertise; without that the check \
above is vacuous"
);
}
/// **`advertise()` and the inline advertisement are one implementation.**
///
/// A `GET /info/refs` that produced *nearly* the same bytes as an SSH session
/// would be the classic two-copies bug: both look right in isolation and only
/// disagree about something like `symref=HEAD:` that no smoke test reads.
/// Asserted byte-for-byte, for both versions and both hash kinds.
#[test]
fn the_http_advertisement_is_byte_identical_to_the_connected_one() {
for kind in [Kind::Sha1, Kind::Sha256] {
let fixture = Fixture::build(kind);
let upload = upload(&fixture);
for version in [ProtocolVersion::V0, ProtocolVersion::V2] {
let mut standalone = Vec::new();
upload
.advertise(version, &mut standalone)
.expect("the standalone advertisement");
// What the connected path writes before it reads anything: served
// with an empty input, so the session ends immediately after.
let mut inline = Vec::new();
upload
.serve_session(
version,
Session::Connected,
&mut Cursor::new(Vec::new()),
&mut inline,
)
.expect("the inline advertisement");
assert_eq!(
standalone, inline,
"{version:?}/{kind:?}: UploadPack::advertise and the inline \
advertisement disagree, so smart HTTP and SSH would serve \
different capabilities from the same repository"
);
assert!(
!standalone.is_empty(),
"{version:?}/{kind:?}: both advertisements are EMPTY, which \
would make the equality above true and meaningless"
);
}
}
}
// ── one round per call ──────────────────────────────────────────────────────
/// **A v0 round with no `done` answers `NAK`/`ACK` and returns.**
///
/// This is the trap the brief names. Over SSH the same flush-pkt ends only a
/// round and the server keeps reading; over HTTP the body is finished, so a
/// server that kept reading would block on a stream that will never produce
/// another byte while the client waits for the response it is holding.
///
/// Asserted on **applied output**: the response must carry the negotiation
/// answer and must NOT carry a packfile, because sending a pack on a round the
/// client has not finished negotiating is the other way to get this wrong.
#[test]
fn a_v0_round_without_done_answers_and_stops() {
let fixture = Fixture::build(Kind::Sha1);
let upload = upload(&fixture);
let body = v0_body_no_done(
&fixture.four.to_string(),
"side-band-64k ofs-delta",
&[fixture.two.to_string()],
);
let mut out = Vec::new();
let summary = upload
.serve_session(
ProtocolVersion::V0,
Session::StatelessRpc,
&mut Cursor::new(body),
&mut out,
)
.expect("a negotiation round with no done");
let response = Response::parse(&out);
let lines = response.lines();
assert_eq!(
lines.len(),
1,
"one round, one answer, nothing else: {lines:?}"
);
assert_eq!(
lines[0].trim(),
format!("ACK {}", fixture.two),
"`have {}` is in the store, so the round must ACK it",
fixture.two
);
assert!(
!out.windows(4).any(|w| w == b"PACK"),
"no packfile may be sent on a round the client has not ended with `done`"
);
assert_eq!(
summary.common, 1,
"the summary must report the common object it found, or an operator \
reading metrics cannot tell negotiation from a blind full send"
);
assert_eq!(summary.objects_sent, 0);
}
/// **A stateless v2 call serves exactly one command**, even when a second is
/// present in the same body.
///
/// A body carrying two commands is not what stock `git` sends — it sends one
/// per `POST` — which is precisely why this has to be asserted rather than
/// observed: the difference between "returns after one" and "happens to hit
/// EOF after one" is invisible against a well-behaved client, and only the
/// first is safe, because the second command would be answered against
/// negotiation state the next request cannot see.
#[test]
fn a_stateless_v2_call_serves_one_command_and_returns() {
let fixture = Fixture::build(Kind::Sha1);
let upload = upload(&fixture);
let mut body = v2_body("ls-refs", &["object-format=sha1"], &["ref-prefix HEAD"]);
body.extend_from_slice(&v2_body(
"ls-refs",
&["object-format=sha1"],
&["ref-prefix refs/heads/"],
));
let mut out = Vec::new();
let summary = upload
.serve_session(
ProtocolVersion::V2,
Session::StatelessRpc,
&mut Cursor::new(body.clone()),
&mut out,
)
.expect("serve");
assert_eq!(
summary.ls_refs, 1,
"stateless-rpc must serve ONE command per call; served {}",
summary.ls_refs
);
// The red half: connected, the very same bytes serve both, so a
// `serve_session` that ignored `Session` would report 2 above.
let mut connected_out = Vec::new();
let connected = upload
.serve_session(
ProtocolVersion::V2,
Session::Connected,
&mut Cursor::new(body),
&mut connected_out,
)
.expect("serve");
assert_eq!(
connected.ls_refs, 2,
"the connected path is supposed to serve both commands off one stream; \
if it does not, the assertion above cannot fail"
);
}
/// The v0 advertisement counter must not lie about a `POST`.
///
/// `UploadSummary::ls_refs` is what the server logs as "a ref list went out".
/// A stateless `POST` writes none, so a summary that reported one — which is
/// what a hard-coded `ls_refs: 1` does — would put an advertisement in the
/// metrics for every negotiation round of every HTTP clone.
#[test]
fn a_stateless_v0_post_does_not_count_an_advertisement() {
let fixture = Fixture::build(Kind::Sha1);
let upload = upload(&fixture);
let body = v0_body_with_done(&fixture.four.to_string(), "side-band-64k ofs-delta");
let stateless = upload
.serve_session(
ProtocolVersion::V0,
Session::StatelessRpc,
&mut Cursor::new(body.clone()),
&mut Vec::new(),
)
.expect("serve");
assert_eq!(stateless.ls_refs, 0);
let connected = upload
.serve_session(
ProtocolVersion::V0,
Session::Connected,
&mut Cursor::new(body),
&mut Vec::new(),
)
.expect("serve");
assert_eq!(
connected.ls_refs, 1,
"the connected path advertises exactly once; without that the zero \
above is not a measurement of anything"
);
}