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/hostile.rs

//! **What a client that is not stock `git` can say.**
//!
//! `real_git_interop.rs` and `real_git_surface.rs` prove gunnar works when the
//! peer is `/usr/bin/git`. They cannot prove anything about a peer that is not,
//! because stock git will not send a malformed ref name, a truncated pack, or a
//! header claiming a four-gigabyte object — it refuses those on its own side,
//! and a server that trusted git to keep doing so is a server with no input
//! validation at all.
//!
//! So these are driven over `Vec<u8>`, where the bytes can be anything.
//!
//! # Every refusal is paired with an acceptance
//!
//! A server that refuses every push passes any test that only asserts a
//! refusal, which is why [`hostile_ref_names_are_refused`] pushes the
//! `ACCEPTED` table through the **same code path** in the same test, and why
//! the corrupt-pack cases each build their fixture, prove the *intact* pack is
//! taken, and only then damage it. The assertion is never "it failed"; it is
//! "it failed on this input and succeeded on that one".
//!
//! # Refusal means the reference did not move
//!
//! An error return is not the property that matters. A server can refuse a
//! push and still have written the ref, and that is the bug worth catching, so
//! every refusal here also asserts the store afterwards.

mod common;

use gix_hash::Kind;
use gunnar_pack::{IndexOptions, DEFAULT_ALLOC_LIMIT_BYTES};
use gunnar_store::git_storage_trait::GitServe;
use gunnar_wire::receive_pack::AllowAll;
use gunnar_wire::{ProtocolVersion, ReceivePack, UploadPack};

use common::{empty_pack, limited_ingest, push_bytes, validating_ingest, Fixture, Response};

/// Run one push and report whether the server took it, plus what it said.
fn push(fixture: &Fixture, commands: &[(&str, &str, &str)], pack: &[u8]) -> (bool, String) {
    let receive = ReceivePack::new(&fixture.git, fixture.kind);
    let input = push_bytes(
        commands,
        "report-status object-format=sha1 agent=test/1",
        pack,
    );
    let mut input = std::io::Cursor::new(input);
    let mut out = Vec::new();
    match receive.serve(
        &mut input,
        &mut out,
        &validating_ingest(Kind::Sha1),
        &AllowAll,
    ) {
        Ok(_) => {
            let response = Response::parse(&out);
            let lines = response.lines().join(" | ");
            // **`serve` returning `Ok` is not the push being applied.** A pack
            // the ingest refused comes back as an `unpack <error>` line and a
            // per-ref refusal as `ng <ref> <reason>`, both inside a session
            // that succeeded — that IS the report-status protocol. A test that
            // read the return value alone would call every one of those a
            // successful push.
            let accepted = lines.contains("unpack ok") && !lines.contains("ng ");
            (accepted, lines)
        }
        Err(err) => (false, format!("Err: {err}")),
    }
}

/// Does a reference exist in the store, by raw bytes?
///
/// Raw bytes rather than a validated name type, because half the point of this
/// file is names the validator refuses to build, and a probe that could only
/// ask about legal names could not see an illegal one that got written anyway.
///
/// The contract's `RefRow::name` is a `String`, so what comes back is UTF-8 by
/// construction; the byte comparison is kept anyway because the *question* is
/// about bytes and the day that type widens (see
/// `receive_pack_wire_bytes.rs`'s non-UTF-8 case) this keeps asking the same
/// thing.
fn stored_names(fixture: &Fixture) -> Vec<Vec<u8>> {
    fixture
        .ref_names()
        .into_iter()
        .map(|name| name.into_bytes())
        .collect()
}

fn holds(fixture: &Fixture, name: &str) -> bool {
    stored_names(fixture)
        .iter()
        .any(|stored| stored == name.as_bytes())
}

// ── reference names ─────────────────────────────────────────────────────────

/// Names `git check-ref-format` rejects, and the reason gunnar's validator
/// gives. `refs/heads/../../etc/passwd` is the one that matters most: a `..`
/// component is how a path sandbox gets walked out of, and the `fs` backend
/// turns a ref name into a file path.
const REFUSED: &[(&str, &str)] = &[
    ("", "an empty name"),
    ("heads/main", "no refs/ prefix"),
    ("refs/heads/a..b", "a `..` component"),
    ("refs/heads/../../etc/passwd", "a sandbox escape"),
    ("refs/heads/x@{0}", "an `@{` reflog selector"),
    ("refs/heads/x.lock", "a `.lock` suffix"),
    ("refs/heads/.hidden", "a leading dot"),
    ("refs/heads/trailing.", "a trailing dot"),
    ("refs/heads/trailing/", "a trailing slash"),
    ("refs/heads/a//b", "an empty path component"),
    ("refs/heads/with space", "a space"),
    ("refs/heads/with\ttab", "a control character"),
    ("refs/heads/with\nnewline", "a newline"),
    ("refs/heads/star*", "a `*`"),
    ("refs/heads/colon:x", "a `:`"),
    ("refs/heads/caret^x", "a `^`"),
    ("refs/heads/tilde~x", "a `~`"),
    ("refs/heads/question?x", "a `?`"),
    ("refs/heads/bracket[x", "a `[`"),
    ("refs/heads/back\\slash", "a backslash"),
];

/// Names that are **legal** and unusual. `-dash` is the interesting one: git
/// permits it, and a server that ever passed a ref name to a subprocess would
/// have it read as an option. gunnar runs no subprocess, and this asserts the
/// name survives as itself rather than being rejected out of superstition.
const ACCEPTED: &[&str] = &[
    "refs/heads/legit",
    "refs/heads/-dash-leading",
    "refs/heads/ünïcøde-namn",
    "refs/heads/deeply/nested/branch/name",
    "refs/heads/dots.in.the.middle",
    "refs/pull/42/head",
    "refs/heads/UPPER_and_lower-123",
];

#[test]
fn hostile_ref_names_are_refused_and_legal_ones_are_not() {
    let zero = Kind::Sha1.null().to_string();

    // The acceptance half FIRST, so a server that refuses everything fails
    // here rather than passing the refusal half in silence.
    for legal in ACCEPTED {
        let fixture = Fixture::build(Kind::Sha1);
        let target = fixture.merge.to_string();
        let (ok, said) = push(
            &fixture,
            &[(&zero, &target, legal)],
            &empty_pack(Kind::Sha1),
        );
        assert!(ok, "the legal name {legal:?} was refused: {said}");
        assert!(
            holds(&fixture, legal),
            "the legal name {legal:?} was accepted but not stored; the store holds {:?}",
            stored_names(&fixture)
                .iter()
                .map(|n| String::from_utf8_lossy(n).into_owned())
                .collect::<Vec<_>>()
        );
    }

    for (hostile, why) in REFUSED {
        let fixture = Fixture::build(Kind::Sha1);
        let before = stored_names(&fixture);
        let target = fixture.merge.to_string();
        let (ok, said) = push(
            &fixture,
            &[(&zero, &target, hostile)],
            &empty_pack(Kind::Sha1),
        );
        assert!(!ok, "the push of {hostile:?} ({why}) was ACCEPTED: {said}");
        // The property that matters is not the error, it is that nothing moved.
        assert_eq!(
            stored_names(&fixture),
            before,
            "the push of {hostile:?} ({why}) was refused but changed the store anyway"
        );
    }
}

/// A very long reference name.
///
/// # This test was blind for as long as it existed, and this is what it missed
///
/// Its original assertion was `ok == holds(..)` — *report and store agree* —
/// and its doc named the reason: *"a filesystem backend does: a path component
/// over 255 bytes cannot be created on ext4"*. **[`Fixture`] is
/// [`MemGitServe`](gunnar_store::mem::MemGitServe).** No filesystem is anywhere near it, `mem`
/// took a 400-byte name and stored it, so report and store agreed and the check
/// passed — on the one backend for which the property it names does not exist.
/// It could not have gone red for its own stated reason.
///
/// MEASURED 2026-08-06, load1 36.6: `mem`, the Arrow log and the in-memory
/// Arrow log all took a **70 011-byte** name; the `fs` backend refused anything
/// over **250** bytes per component, which is exactly where `git update-ref`
/// 2.53.0 draws its own line (250 accepted, 251 *"File name too long"*),
/// `NAME_MAX` minus the `.lock` the lock file needs.
///
/// So the rule now lives in the validator as
/// [`gunnar_store::MAX_REF_COMPONENT_BYTES`], where it binds every
/// backend, and the backend-by-backend proof is
/// `gunnar_store::conformance`'s `ref_name_size`, which runs against all four.
/// What is left for this file is the part only the wire can answer: **the
/// refusal reaches the client as a per-ref `ng` line and not as a dropped
/// session or a silent success**, with the acceptance of a name one byte under
/// the ceiling pushed through the same code path so a server that refuses
/// everything cannot pass.
#[test]
fn a_very_long_reference_name_is_refused_at_the_ceiling_and_taken_below_it() {
    let zero = Kind::Sha1.null().to_string();
    let ceiling = gunnar_store::MAX_REF_COMPONENT_BYTES;

    // At the ceiling: taken, stored, and reported as taken.
    let at = format!("refs/heads/{}", "x".repeat(ceiling));
    let fixture = Fixture::build(Kind::Sha1);
    let (ok, said) = push(
        &fixture,
        &[(&zero, &fixture.merge.to_string(), &at)],
        &empty_pack(Kind::Sha1),
    );
    assert!(
        ok && holds(&fixture, &at),
        "a {ceiling}-byte component is what git itself accepts and it was refused \
         (reported ok={ok}, stored={}): {said}",
        holds(&fixture, &at)
    );

    // One byte over, and as far over as a client can physically get: a command
    // line is one pkt-line, so a 70 000-byte name cannot be *sent* at all
    // (`gunnar_pktline::MAX_PAYLOAD` is 65 516, and the encoder refuses with
    // `TooLong` rather than emitting a wrong header). The store-side answer at
    // that size is `gunnar_store::conformance`'s `ref_name_size`; what the wire
    // can be asked is the largest thing that fits in a packet.
    for over in [ceiling + 1, 60_000] {
        let long = format!("refs/heads/{}", "x".repeat(over));
        let fixture = Fixture::build(Kind::Sha1);
        let before = stored_names(&fixture);
        let (ok, said) = push(
            &fixture,
            &[(&zero, &fixture.merge.to_string(), &long)],
            &empty_pack(Kind::Sha1),
        );
        assert!(
            !ok && !holds(&fixture, &long),
            "a {over}-byte path component was ACCEPTED. No loose-ref backend and no stock \
             git can hold it, so this is a repository nobody can clone: {said}"
        );
        assert_eq!(
            stored_names(&fixture),
            before,
            "the over-long push was refused but changed the store anyway"
        );
        assert!(
            said.contains("ng "),
            "the refusal did not reach the client as a per-ref `ng` line, so a pushing \
             client cannot tell which reference was refused or why: {said}"
        );
    }
}

/// **One bad name must not take the whole push down with it.**
///
/// # What the twenty-name table above cannot see
///
/// [`hostile_ref_names_are_refused_and_legal_ones_are_not`] runs twenty hostile
/// names through [`push`] and asserts `!ok` for each. `ok` is false both when
/// the server answers `ng <ref> <reason>` over a live session **and** when
/// `serve` returns `Err` and the conversation ends — the helper folds them into
/// the same boolean. So every one of those twenty was, until 2026-08-06, taking
/// the second path and the table could not tell.
///
/// It matters because of what the second path costs a real client. `serve`
/// returned `Err` out of `read_commands`, *before* the pack was read: the client
/// is still writing 8 GB into a pipe nobody is draining, gets `EPIPE`, prints
/// `the remote end hung up unexpectedly`, and is told nothing about which
/// reference was refused or why. Every other reference in the same push is lost
/// with it.
///
/// **git does not do that**, and it was measured rather than assumed —
/// `git receive-pack` 2.53.0, 2026-08-06, raw two-command push:
/// `unpack ok` / `ok refs/heads/goodone` / `ng refs/heads/bad~name funny
/// refname`, exit 0, and `refs/heads/goodone` created on the server.
///
/// This asserts all three halves of that on gunnar: the good reference lands,
/// the bad one comes back as a named `ng` line, and the session completes.
#[test]
fn a_refused_ref_name_does_not_take_the_rest_of_the_push_with_it() {
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();
    let target = fixture.merge.to_string();

    let (ok, said) = push(
        &fixture,
        &[
            (&zero, &target, "refs/heads/goodone"),
            (&zero, &target, "refs/heads/bad~name"),
            (&zero, &target, "refs/heads/goodtwo"),
        ],
        &empty_pack(Kind::Sha1),
    );

    // The session completed and produced a report at all. `Err:` is what the
    // helper prefixes a dropped session with, and that is the whole defect.
    assert!(
        !said.starts_with("Err:"),
        "one unacceptable ref name ended the session instead of being reported. \
         A client streaming a pack into this gets EPIPE and `the remote end hung up \
         unexpectedly`, and learns nothing about which reference was at fault: {said}"
    );
    assert!(!ok, "the push as a whole must not report success: {said}");

    // The bad one is named, with a reason, in a per-ref line.
    assert!(
        said.contains("ng refs/heads/bad~name"),
        "the refused reference was not named in an `ng` line: {said}"
    );
    // And the two good ones landed, exactly as git leaves them.
    for good in ["refs/heads/goodone", "refs/heads/goodtwo"] {
        assert!(
            said.contains(&format!("ok {good}")),
            "{good} shares a push with one bad name and was not reported ok: {said}"
        );
        assert!(
            holds(&fixture, good),
            "{good} was reported ok and is not in the store: {said}"
        );
    }
    assert!(
        !holds(&fixture, "refs/heads/bad~name"),
        "the refused name was written anyway"
    );
}

/// **`--atomic` still means all or nothing, including for a name.**
///
/// The companion to the test above, and the case that makes the fix a decision
/// rather than a relaxation: the good references must *not* land when the client
/// asked for `atomic`, and the collapse has to happen **before** the reference
/// transaction runs, or the report says refused about something already on disk.
#[test]
fn an_atomic_push_with_one_bad_name_lands_nothing() {
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();
    let target = fixture.merge.to_string();

    let receive = ReceivePack::new(&fixture.git, fixture.kind);
    let input = push_bytes(
        &[
            (&zero, &target, "refs/heads/goodone"),
            (&zero, &target, "refs/heads/bad~name"),
        ],
        "report-status atomic object-format=sha1 agent=test/1",
        &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("an atomic push with one bad name is still a completed session");
    let said = Response::parse(&out).lines().join(" | ");

    assert!(
        said.contains("ng refs/heads/bad~name"),
        "the bad name was not reported: {said}"
    );
    assert!(
        said.contains("ng refs/heads/goodone"),
        "`atomic` was asked for and the good reference was not refused with it: {said}"
    );
    assert!(
        !holds(&fixture, "refs/heads/goodone"),
        "the report said `ng refs/heads/goodone` and the store holds it anyway — the \
         batch was collapsed AFTER the transaction, so the client is being told a lie \
         about what is on the server"
    );
}

/// **A reference name that is not UTF-8.**
///
/// git reference names are *bytes*; `refs/heads/\xff` is a name git will create
/// and serve. `ReceivePack::parse_command` reads the command line through
/// `String::from_utf8_lossy`, which replaces every invalid byte with U+FFFD
/// before the name is validated — so the name that gets stored is not the name
/// that was asked for, and two distinct hostile names collapse onto one.
///
/// This test states the property that matters and lets the implementation
/// answer: whatever gunnar stores must be either **exactly the requested
/// bytes** or **nothing at all**. Silently storing a third name is the failure.
#[test]
fn a_non_utf8_reference_name_is_stored_verbatim_or_refused() {
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();

    // Built as bytes, and handed to the wire as bytes: `\xff` is not
    // expressible in a Rust `&str`, which is the whole point.
    let mut wanted = b"refs/heads/".to_vec();
    wanted.push(0xff);

    let mut line = format!("{zero} {} ", fixture.merge).into_bytes();
    line.extend_from_slice(&wanted);
    line.push(0);
    line.extend_from_slice(b"report-status object-format=sha1");

    let mut input = Vec::new();
    gunnar_pktline::write_text(&mut input, &line).unwrap();
    gunnar_pktline::write_flush(&mut input).unwrap();
    input.extend_from_slice(&empty_pack(Kind::Sha1));

    let receive = ReceivePack::new(&fixture.git, fixture.kind);
    let mut cursor = std::io::Cursor::new(input);
    let mut out = Vec::new();
    let result = receive.serve(
        &mut cursor,
        &mut out,
        &validating_ingest(Kind::Sha1),
        &AllowAll,
    );

    let created: Vec<Vec<u8>> = stored_names(&fixture)
        .into_iter()
        .filter(|stored| stored.starts_with(b"refs/heads/") && !stored.ends_with(b"d"))
        .filter(|stored| stored != b"refs/heads/main" && stored != b"refs/heads/side")
        .collect();

    match created.as_slice() {
        // Refused outright: acceptable, and the store is untouched.
        [] => {}
        [stored] => assert_eq!(
            stored.as_slice(),
            wanted.as_slice(),
            "the push asked for {:?} and the store now holds {:?} — the name was MANGLED, \
             not stored and not refused. Two different non-UTF-8 names collapse onto the \
             same stored reference this way, so a push to one silently writes the other. \
             serve() returned {:?}",
            String::from_utf8_lossy(&wanted),
            String::from_utf8_lossy(stored),
            result.as_ref().map(|_| "Ok").map_err(|e| e.to_string()),
        ),
        many => panic!("one command created {} references: {many:?}", many.len()),
    }
}

// ── packs ───────────────────────────────────────────────────────────────────

/// A pack carrying one real object, so there is something to damage.
///
/// `empty_pack` cannot serve here: it is a header and a trailer with no entry
/// between them, so "truncate the middle" has no middle, and a corruption test
/// against it would be testing the trailer twice.
fn one_object_pack(fixture: &Fixture) -> Vec<u8> {
    let (obj_type, owned) = fixture
        .git
        .read(fixture.merge.as_bytes())
        .expect("read the commit")
        .expect("the commit is here");
    let kind = gix_object::Kind::from_bytes(obj_type.as_str().as_bytes())
        .expect("the contract's object type names git's");

    let mut out = Vec::new();
    let mut writer =
        gunnar_pack::PackWriter::new(&mut out, 1, &gunnar_pack::WriteOptions::new(Kind::Sha1))
            .expect("start a pack");
    writer.add_object(kind, &owned).expect("add the commit");
    writer.finish().expect("finish the pack");
    out
}

#[test]
fn a_truncated_pack_is_refused_and_the_reference_does_not_move() {
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();
    let intact = one_object_pack(&fixture);
    assert!(
        intact.len() > 40,
        "the fixture pack is {} bytes, too small to truncate meaningfully",
        intact.len()
    );

    // The intact pack is taken. Without this, "the truncated pack was refused"
    // is equally true of a server that refuses every pack.
    let (ok, said) = push(
        &fixture,
        &[(&zero, &fixture.merge.to_string(), "refs/heads/intact")],
        &intact,
    );
    assert!(
        ok,
        "the INTACT pack was refused, so this proves nothing: {said}"
    );
    assert!(holds(&fixture, "refs/heads/intact"));

    // Now the same bytes, minus the trailer and a little of the body.
    let fixture = Fixture::build(Kind::Sha1);
    let truncated = &intact[..intact.len() - 25];
    let (ok, said) = push(
        &fixture,
        &[(&zero, &fixture.merge.to_string(), "refs/heads/truncated")],
        truncated,
    );
    assert!(!ok, "a truncated pack was ACCEPTED: {said}");
    assert!(
        !holds(&fixture, "refs/heads/truncated"),
        "a truncated pack was refused but the reference moved anyway"
    );
}

#[test]
fn a_corrupt_pack_body_is_refused_and_the_reference_does_not_move() {
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();
    let intact = one_object_pack(&fixture);

    // Flip a bit in the compressed body, past the 12-byte header and well
    // before the 20-byte trailer, so this is a damaged *object* and not a
    // damaged checksum.
    let mut corrupt = intact.clone();
    let at = 12 + (corrupt.len() - 32) / 2;
    corrupt[at] ^= 0xff;
    assert_ne!(corrupt, intact, "the corruption did not change any byte");

    let (ok, said) = push(
        &fixture,
        &[(&zero, &fixture.merge.to_string(), "refs/heads/corrupt")],
        &corrupt,
    );
    assert!(!ok, "a pack with a corrupted body was ACCEPTED: {said}");
    assert!(
        !holds(&fixture, "refs/heads/corrupt"),
        "a corrupt pack was refused but the reference moved anyway"
    );
}

/// **The per-entry allocation ceiling is on by default, and governs a push.**
///
/// What this does *not* prove, stated plainly because the first version of it
/// claimed otherwise: it does not prove the 1 GiB default stops a hostile size
/// *claim*. A pack entry's header states its uncompressed size before any of it
/// arrives, so a 60-byte pack can ask for four gigabytes — but gix rejects such
/// a pack on its short zlib body first. Measured 2026-08-04: the crafted pack
/// below is refused identically with `no_alloc_limit()`, with an 8 GiB limit,
/// and with the default, so its refusal says nothing about the guard. Reaching
/// the guard through a claim alone needs a body that really does inflate past
/// 1 GiB, which is not a test that belongs in this suite.
///
/// What it does prove, and what nothing else did:
///
///  1. `IndexOptions::new` **wires the default in**. `gunnar-pack`'s own
///     `an_allocation_limit_stops_an_oversized_object` sets a limit explicitly
///     and calls the unset case "without a limit"; if the constructor ever
///     stopped defaulting to `Some(..)`, every production receive path would
///     lose the ceiling and that test would still pass.
///  2. The ceiling governs a **real push**, not just a direct `index_pack`
///     call: the same pack through `ReceivePack::serve` is refused under a
///     ceiling below its object and accepted under the default, and the
///     reference does not move when it is refused.
#[test]
fn the_allocation_ceiling_is_on_by_default_and_governs_a_push() {
    // 1. the constructor wires it in.
    assert_eq!(
        IndexOptions::new(Kind::Sha1).alloc_limit_bytes,
        Some(DEFAULT_ALLOC_LIMIT_BYTES),
        "IndexOptions::new no longer defaults to a ceiling — every receive path that \
         builds its options this way is now unguarded"
    );
    assert_eq!(
        gunnar_wire::DEFAULT_OBJECT_LIMIT_BYTES,
        DEFAULT_ALLOC_LIMIT_BYTES,
        "the serve-side and receive-side ceilings have drifted apart"
    );

    // 2. it governs a push. One object, comfortably over the ceiling used here
    //    and comfortably under the default.
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();
    let pack = one_object_pack(&fixture);
    let object_bytes = fixture
        .git
        .read(fixture.merge.as_bytes())
        .expect("read the commit")
        .expect("the commit is here")
        .1
        .len();
    let ceiling = object_bytes / 2;
    assert!(
        ceiling < object_bytes && object_bytes < DEFAULT_ALLOC_LIMIT_BYTES,
        "the fixture object is {object_bytes} bytes; it must sit between the {ceiling}-byte \
         ceiling and the {DEFAULT_ALLOC_LIMIT_BYTES}-byte default for this to discriminate"
    );

    let receive = ReceivePack::new(&fixture.git, fixture.kind);
    let input = push_bytes(
        &[(&zero, &fixture.merge.to_string(), "refs/heads/over-ceiling")],
        "report-status object-format=sha1",
        &pack,
    );
    let mut out = Vec::new();
    receive
        .serve(
            &mut std::io::Cursor::new(input),
            &mut out,
            &limited_ingest::<gunnar_wire::Error>(Kind::Sha1, ceiling),
            &AllowAll,
        )
        .expect("the session itself is well formed");
    let report = Response::parse(&out).lines().join(" | ");
    assert!(
        !report.contains("unpack ok"),
        "a push whose object is {object_bytes} bytes was accepted under a {ceiling}-byte \
         ceiling: {report}"
    );
    assert!(
        !holds(&fixture, "refs/heads/over-ceiling"),
        "the push was refused but the reference moved anyway"
    );

    // The acceptance half: the identical pack, under the default ceiling.
    let fixture = Fixture::build(Kind::Sha1);
    let (ok, said) = push(
        &fixture,
        &[(
            &zero,
            &fixture.merge.to_string(),
            "refs/heads/under-ceiling",
        )],
        &pack,
    );
    assert!(
        ok,
        "the same pack was refused under the {DEFAULT_ALLOC_LIMIT_BYTES}-byte default too, \
         so the refusal above was not about the ceiling: {said}"
    );
    assert!(holds(&fixture, "refs/heads/under-ceiling"));
}

/// A tiny pack whose header claims four gigabytes must not be indexed.
///
/// This is a safety floor, not a proof of the guard (see the case above for
/// why): whatever refuses it, the server must not report success and must not
/// try to honour the claim.
#[test]
fn a_tiny_pack_claiming_four_gigabytes_is_never_indexed() {
    let claimed: u64 = 4 * 1024 * 1024 * 1024;
    assert!(claimed as usize > DEFAULT_ALLOC_LIMIT_BYTES);
    let hostile = pack_claiming(claimed);
    assert!(
        hostile.len() < 4096,
        "the hostile pack is {} bytes; the attack is that it is small",
        hostile.len()
    );

    let indexed = gunnar_pack::index_pack(
        &mut std::io::Cursor::new(hostile.clone()),
        None,
        &IndexOptions::new(Kind::Sha1),
        None,
    );
    assert!(
        indexed.is_err(),
        "a {}-byte pack claiming {claimed} bytes was INDEXED",
        hostile.len()
    );

    // Discriminating: the crafting routine is capable of producing a pack the
    // indexer takes, so this is not a helper that only ever emits garbage.
    let fixture = Fixture::build(Kind::Sha1);
    let honest = one_object_pack(&fixture);
    gunnar_pack::index_pack(
        &mut std::io::Cursor::new(honest),
        None,
        &IndexOptions::new(Kind::Sha1),
        None,
    )
    .expect("an honest pack must index at the default ceiling");
}

/// A pack of one entry whose header claims `size` uncompressed bytes, with a
/// body far too short to be it.
///
/// Hand-built because no writer will produce it: the entry header is a varint
/// carrying the type in bits 4..6 of the first byte and the size in the low
/// nibble plus seven bits per continuation byte.
fn pack_claiming(size: u64) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(b"PACK");
    out.extend_from_slice(&2u32.to_be_bytes());
    out.extend_from_slice(&1u32.to_be_bytes());

    // type 3 (blob) in bits 4..6, low four bits of the size, MSB = continues.
    let mut byte = 0b0011_0000u8 | (size & 0x0f) as u8;
    let mut rest = size >> 4;
    while rest > 0 {
        out.push(byte | 0x80);
        byte = (rest & 0x7f) as u8;
        rest >>= 7;
    }
    out.push(byte);

    // A short, valid zlib stream. It does not decompress to anything like the
    // claimed size, which is the point: a decoder that sized its buffer from
    // the header has already lost before it reads this.
    let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
    std::io::Write::write_all(&mut encoder, b"not four gigabytes").unwrap();
    out.extend_from_slice(&encoder.finish().unwrap());

    let mut hasher = gix_hash::hasher(Kind::Sha1);
    hasher.update(&out);
    let digest = hasher.try_finalize().expect("hash the pack");
    out.extend_from_slice(digest.as_bytes());
    out
}

// ── degenerate but legal ────────────────────────────────────────────────────

/// A push with no commands at all is a client that changed its mind, not an
/// error, and must not be reported as a push.
#[test]
fn a_push_with_no_commands_is_not_a_push() {
    let fixture = Fixture::build(Kind::Sha1);
    let before = stored_names(&fixture);
    let receive = ReceivePack::new(&fixture.git, fixture.kind);

    let mut input = Vec::new();
    gunnar_pktline::write_flush(&mut input).unwrap();

    let mut cursor = std::io::Cursor::new(input);
    let mut out = Vec::new();
    let outcome = receive
        .serve(
            &mut cursor,
            &mut out,
            &validating_ingest(Kind::Sha1),
            &AllowAll,
        )
        .expect("an empty command list is legal");
    assert!(
        outcome.is_none(),
        "a flush-only session was reported as a push: {outcome:?}"
    );
    assert_eq!(
        stored_names(&fixture),
        before,
        "a no-op push moved a reference"
    );
}

/// A command whose object id is the wrong hash length for the repository.
///
/// A sha256 id against a sha1 store is what a client pointed at the wrong
/// remote sends, and it must be refused rather than truncated into something
/// that happens to parse.
#[test]
fn a_command_carrying_the_wrong_hash_kind_is_refused() {
    let fixture = Fixture::build(Kind::Sha1);
    let before = stored_names(&fixture);
    let zero = Kind::Sha1.null().to_string();
    let sha256_id = "a".repeat(64);

    let (ok, said) = push(
        &fixture,
        &[(&zero, &sha256_id, "refs/heads/wrong-kind")],
        &empty_pack(Kind::Sha1),
    );
    assert!(
        !ok,
        "a sha256 object id was accepted by a sha1 repository: {said}"
    );
    assert_eq!(
        stored_names(&fixture),
        before,
        "the refused push moved a reference anyway"
    );

    // Discriminating: the same shape with a correct-width id is taken.
    let fixture = Fixture::build(Kind::Sha1);
    let (ok, said) = push(
        &fixture,
        &[(&zero, &fixture.merge.to_string(), "refs/heads/right-kind")],
        &empty_pack(Kind::Sha1),
    );
    assert!(ok, "a well-formed sha1 push was refused too: {said}");
}

// ── the upload side: malformed requests ─────────────────────────────────────

/// Serve one protocol-v2 session over the bytes given, on a connected session.
fn serve_v2(fixture: &Fixture, request: &[u8]) -> gunnar_wire::Result<Vec<u8>> {
    let upload = UploadPack::new(&fixture.git, fixture.kind);
    let mut out = Vec::new();
    upload.serve(
        ProtocolVersion::V2,
        &mut std::io::Cursor::new(request.to_vec()),
        &mut out,
    )?;
    Ok(out)
}

/// A v2 command section: `command=<name>`, capabilities, delim, arguments,
/// flush. [`common::v2_request`] builds the same shape for the byte-level
/// suite; this one exists so a case can put something illegal in it.
fn v2_command(name: &str, capabilities: &[&str], arguments: &[&str]) -> Vec<u8> {
    let mut out = Vec::new();
    gunnar_pktline::write_text(&mut out, format!("command={name}").as_bytes()).unwrap();
    for capability in capabilities {
        gunnar_pktline::write_text(&mut out, capability.as_bytes()).unwrap();
    }
    gunnar_pktline::write_delim(&mut out).unwrap();
    for argument in arguments {
        gunnar_pktline::write_text(&mut out, argument.as_bytes()).unwrap();
    }
    gunnar_pktline::write_flush(&mut out).unwrap();
    out
}

#[test]
fn a_v2_command_the_server_does_not_implement_is_refused() {
    let fixture = Fixture::build(Kind::Sha1);

    // The acceptance half first: `ls-refs` over the identical framing.
    let good = serve_v2(
        &fixture,
        &v2_command("ls-refs", &["object-format=sha1"], &[]),
    )
    .expect("ls-refs is implemented");
    assert!(
        Response::parse(&good)
            .lines()
            .iter()
            .any(|line| line.contains("refs/heads/main")),
        "the control command returned no refs, so the refusal below proves nothing"
    );

    for unknown in ["git-archive", "object-info", "fetch-v3", ""] {
        let result = serve_v2(&fixture, &v2_command(unknown, &["object-format=sha1"], &[]));
        assert!(
            result.is_err(),
            "the unimplemented v2 command {unknown:?} was served rather than refused"
        );
    }
}

/// A client that stops talking in the middle of a command.
///
/// Over SSH this is a dropped connection, and it must come back as an error
/// rather than a hang or a panic. The three cuts are at different points of the
/// framing — mid-header, after a complete line with no flush, and after the
/// delim with no arguments — because a reader that only checked one of them
/// would be caught by only one of them.
#[test]
// **Un-ignored 2026-08-10.** The reason given was *"MemStore is not and cannot
// become an emitting backend"*, and half of that stopped being true: it became
// one. `MemGitServe::emit_pack` copies stored pack entries byte for byte and
// reports `recompressed = 0` as a measurement, and `gunnar-store`'s own
// conformance suite drives `gunnar_pack::index_pack` — an outside oracle — over
// what it emits. So a pack really is served here and these two can interrupt
// one mid-flight, which is what they were written to do.
//
// It is still not an *engine*: it deltifies nothing and ignores `caps.thin`, so
// nothing here may assert a byte count or a compression ratio. Neither does.
fn a_request_that_stops_early_is_an_error_and_not_a_hang() {
    let fixture = Fixture::build(Kind::Sha1);
    let whole = v2_command(
        "fetch",
        &["object-format=sha1"],
        &[&format!("want {}", fixture.merge), "done"],
    );

    // A truncated pkt-line header: three of the four length bytes.
    let cuts = [3usize, 12, whole.len() - 4];
    for cut in cuts {
        assert!(cut < whole.len(), "the cut must actually truncate");
        let result = serve_v2(&fixture, &whole[..cut]);
        assert!(
            result.is_err(),
            "a request cut off at byte {cut} of {} was served as if complete",
            whole.len()
        );
    }

    // Discriminating: the whole thing is served.
    serve_v2(&fixture, &whole).expect("the complete request must be served");
}

/// A pkt-line length header that is not a length.
///
/// `0001` is the delim-pkt and `0002` the response-end; `0003` is shorter than
/// the four bytes of the header itself and cannot mean anything.
#[test]
fn an_impossible_pkt_line_length_is_refused() {
    let fixture = Fixture::build(Kind::Sha1);
    for bad in [&b"0003"[..], &b"zzzz"[..], &b"000"[..]] {
        let result = serve_v2(&fixture, bad);
        assert!(
            result.is_err(),
            "the impossible pkt-line length {:?} was accepted",
            String::from_utf8_lossy(bad)
        );
    }
}

/// `object-format` is the one capability whose disagreement is unrecoverable:
/// the two ends would hash differently and every id on the wire would be
/// meaningless. It must be refused, and the matching value accepted.
#[test]
fn a_command_claiming_the_wrong_object_format_is_refused() {
    let fixture = Fixture::build(Kind::Sha1);

    serve_v2(
        &fixture,
        &v2_command("ls-refs", &["object-format=sha1"], &[]),
    )
    .expect("the matching object-format must be served");

    let result = serve_v2(
        &fixture,
        &v2_command("ls-refs", &["object-format=sha256"], &[]),
    );
    assert!(
        result.is_err(),
        "a sha256 client was served by a sha1 repository"
    );
}

/// Capability lines a real client would never send must not be able to steer
/// the server: unknown ones are ignored, and the command still answers.
#[test]
fn unknown_capability_lines_do_not_change_the_answer() {
    let fixture = Fixture::build(Kind::Sha1);
    let plain = serve_v2(
        &fixture,
        &v2_command("ls-refs", &["object-format=sha1"], &[]),
    )
    .expect("the plain command");

    let noisy = serve_v2(
        &fixture,
        &v2_command(
            "ls-refs",
            &[
                "object-format=sha1",
                "agent=not-git/0.0",
                "definitely-not-a-capability",
                "server-option=../../etc/passwd",
                "object-format=sha1",
            ],
            &[],
        ),
    )
    .expect("unknown capabilities must be ignored, not fatal");

    assert_eq!(
        Response::parse(&plain).lines(),
        Response::parse(&noisy).lines(),
        "unknown capability lines changed the advertisement"
    );
    assert!(
        !Response::parse(&plain).lines().is_empty(),
        "both answers are empty, so comparing them proves nothing"
    );
}

/// **The client hangs up while the pack is being written.**
///
/// The serving side is an `impl Write`, and a dropped connection surfaces as a
/// write error partway through. It must propagate: a server that swallowed it
/// would report a clone it never delivered, and cost summaries would
/// count objects that never reached anyone.
#[test]
// **Un-ignored 2026-08-10.** The reason given was *"MemStore is not and cannot
// become an emitting backend"*, and half of that stopped being true: it became
// one. `MemGitServe::emit_pack` copies stored pack entries byte for byte and
// reports `recompressed = 0` as a measurement, and `gunnar-store`'s own
// conformance suite drives `gunnar_pack::index_pack` — an outside oracle — over
// what it emits. So a pack really is served here and these two can interrupt
// one mid-flight, which is what they were written to do.
//
// It is still not an *engine*: it deltifies nothing and ignores `caps.thin`, so
// nothing here may assert a byte count or a compression ratio. Neither does.
fn a_write_that_fails_mid_pack_is_reported_and_not_swallowed() {
    let fixture = Fixture::build(Kind::Sha1);
    let request = v2_command(
        "fetch",
        &["object-format=sha1"],
        &[&format!("want {}", fixture.merge), "done"],
    );

    // How much a complete clone writes, so the cut is genuinely mid-stream.
    let whole = serve_v2(&fixture, &request).expect("the intact clone");
    assert!(
        whole.len() > 200,
        "the fixture clone is {} bytes, too small to cut in half",
        whole.len()
    );

    let upload = UploadPack::new(&fixture.git, fixture.kind);
    let mut sink = FailAfter {
        written: 0,
        fail_at: whole.len() / 2,
    };
    let result = upload.serve(
        ProtocolVersion::V2,
        &mut std::io::Cursor::new(request.clone()),
        &mut sink,
    );
    assert!(
        result.is_err(),
        "the peer hung up after {} of {} bytes and the server reported success",
        sink.fail_at,
        whole.len()
    );
    assert!(
        sink.written >= sink.fail_at,
        "the sink failed at {} bytes but only {} were ever offered, so the failure \
         happened before the pack and this is not the mid-pack case",
        sink.fail_at,
        sink.written
    );
}

/// A `Write` that accepts `fail_at` bytes and then behaves like a closed pipe.
struct FailAfter {
    written: usize,
    fail_at: usize,
}

impl std::io::Write for FailAfter {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        if self.written >= self.fail_at {
            return Err(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "the peer hung up",
            ));
        }
        self.written += data.len();
        Ok(data.len())
    }
    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

/// **The pushing client's connection resets partway through the packfile.**
///
/// The other direction from `a_write_that_fails_mid_pack…`, and a different
/// failure from `a_truncated_pack…`: a truncated stream ends cleanly at EOF,
/// which a reader can mistake for "the client sent a short pack". A reset
/// yields an `io::Error` instead, and the two travel different paths out of the
/// decoder. Neither may leave the reference moved.
#[test]
fn a_connection_reset_mid_pack_is_refused_and_the_reference_does_not_move() {
    let fixture = Fixture::build(Kind::Sha1);
    let zero = Kind::Sha1.null().to_string();
    let pack = one_object_pack(&fixture);
    let whole = push_bytes(
        &[(&zero, &fixture.merge.to_string(), "refs/heads/reset")],
        "report-status object-format=sha1",
        &pack,
    );

    // Reset once the command list and a little of the pack have gone by, so
    // this is genuinely mid-pack and not a client that never said anything.
    let commands_end = whole.len() - pack.len();
    let reset_at = commands_end + pack.len() / 2;
    assert!(
        reset_at > commands_end && reset_at < whole.len(),
        "the reset point must land inside the pack"
    );

    let receive = ReceivePack::new(&fixture.git, fixture.kind);
    let mut input = std::io::BufReader::new(ResetAfter {
        data: whole.clone(),
        position: 0,
        reset_at,
    });
    let mut out = Vec::new();
    let result = receive.serve(
        &mut input,
        &mut out,
        &validating_ingest(Kind::Sha1),
        &AllowAll,
    );

    let report = Response::parse(&out).lines().join(" | ");
    assert!(
        result.is_err() || !report.contains("unpack ok"),
        "a push whose connection reset mid-pack was reported as unpacked: {report}"
    );
    assert!(
        !holds(&fixture, "refs/heads/reset"),
        "the connection reset mid-pack but the reference moved anyway"
    );

    // The acceptance half: the identical bytes, delivered whole.
    let fixture = Fixture::build(Kind::Sha1);
    let (ok, said) = push(
        &fixture,
        &[(&zero, &fixture.merge.to_string(), "refs/heads/reset")],
        &pack,
    );
    assert!(ok, "the same push delivered whole was refused too: {said}");
    assert!(holds(&fixture, "refs/heads/reset"));
}

/// A `Read` that hands over `reset_at` bytes and then fails like a reset
/// connection, rather than ending at EOF.
struct ResetAfter {
    data: Vec<u8>,
    position: usize,
    reset_at: usize,
}

impl std::io::Read for ResetAfter {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        if self.position >= self.reset_at {
            return Err(std::io::Error::new(
                std::io::ErrorKind::ConnectionReset,
                "the client went away",
            ));
        }
        // Clamped to the data as well as to the reset point. Without the
        // first clamp this reader panics instead of ending at EOF whenever
        // reset_at is past the buffer — which is exactly the configuration a
        // red probe of this case uses, so the probe would die inside itself
        // and prove nothing.
        let end = (self.position + buf.len())
            .min(self.reset_at)
            .min(self.data.len());
        let n = end - self.position;
        buf[..n].copy_from_slice(&self.data[self.position..end]);
        self.position = end;
        Ok(n)
    }
}