Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}