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

nordisk/gunnar

crates/gunnar-cli/tests/lfs_http.rs

//! **Git LFS over HTTP, judged by the real `git-lfs` binary.**
//!
//! > **Runs only under `--features http`**, like `tests/http_transport.rs`, and
//! > for the same reason: the transport is default-off. With the feature
//! > off this file compiles to nothing and reports nothing, which is *not* a
//! > pass — `xtask ci` runs the workspace with `--features gunnar-cli/http`.
//!
//! # What "it works" is allowed to mean here
//!
//! Not `git clone` exiting zero. **A clone of a repository with LFS content
//! exits zero when the smudge filter is missing, when the batch API is not
//! served, and when the server answers a batch with no actions at all** — in
//! every one of those cases the working tree holds a 130-byte pointer file
//! that says `version https://git-lfs.github.com/spec/v1` and the user finds
//! out later. So every assertion in this file ends at the **bytes on disk in
//! the clone**, compared against the bytes that were committed.
//!
//! [`assert_materialised`] carries the floor: the content is larger than any
//! pointer file, it is not text, and it must match byte for byte.
//!
//! # The negative is the point of the suite
//!
//! `a_clone_fails_visibly_when_the_server_does_not_hold_the_object` deletes one
//! object out of the server's store and clones again. That has to **fail**.
//! Over the pure-SSH grammar the same situation answers `noop`, git-lfs reads
//! an object with no action as one it already has, and the clone succeeds with
//! a pointer file — the mirror of the push pinned in
//! `gunnar-lfs/tests/interop.rs`. The HTTP batch API can say `error: {code:
//! 404}` inside a `200`, and this asserts that it does.
//!
//! # Both hashes
//!
//! Every clone/push test runs under sha1 **and** sha256. An LFS oid is SHA-256
//! of the content whichever hash the repository uses, so the two are
//! independent and a suite that ran one would not notice a path that mixed them
//! up.

#![cfg(feature = "http")]

mod common;

use std::collections::BTreeMap;
use std::io::{Read as _, Write as _};
use std::net::{SocketAddr, TcpStream};
use std::path::Path;

use common::{Harness, HarnessOptions};
use gunnar_core::{Grant, HashKind, Level, Principal, PrincipalKind, RepoRef, Visibility};

/// The LFS payload. Big enough that no pointer file could be mistaken for it,
/// and high-entropy so a filter that silently passed the file through
/// unchanged would still be caught by the byte comparison.
const PAYLOAD_BYTES: usize = 300 * 1024;

/// The two hash kinds every transfer test runs under.
const HASHES: &[(&str, HashKind)] = &[("sha1", HashKind::Sha1), ("sha256", HashKind::Sha256)];

fn harness(hash: HashKind, visibility: Visibility) -> Harness {
    Harness::start_with_options(
        "team",
        "project",
        hash,
        HarnessOptions {
            http: true,
            visibility,
            ..HarnessOptions::default()
        },
    )
}

/// Deterministic pseudo-random bytes: reproducible, and not compressible enough
/// for a store that quietly deflated them to come out the same size.
fn payload(seed: u64) -> Vec<u8> {
    let mut state = seed | 1;
    (0..PAYLOAD_BYTES)
        .map(|_| {
            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
            (state >> 33) as u8
        })
        .collect()
}

/// A working tree tracking `*.bin` through LFS, with one committed object.
///
/// `git lfs install` writes the smudge/clean filters into `$HOME/.gitconfig`,
/// and the harness points `HOME` at its own temp dir, so this configures
/// git-lfs for this test and for nothing else on the machine.
fn seed_lfs(harness: &Harness, work: &Path, hash: HashKind, content: &[u8]) {
    std::fs::create_dir_all(work).expect("the working tree");
    // **The working tree's object format has to be the server's.** `git init`
    // defaults to sha1, and pushing that at a sha256 repository fails with
    // *"the receiving end does not support this repository's hash algorithm"* —
    // a mismatch in the fixture, not in the transport, and one that would have
    // made the sha256 half of every test below prove nothing.
    let format = match hash {
        HashKind::Sha1 => "sha1",
        HashKind::Sha256 => "sha256",
    };
    harness
        .git(
            work,
            &[
                "init",
                "-q",
                &format!("--object-format={format}"),
                "-b",
                "main",
            ],
        )
        .expect_ok("git init in the lfs suite");
    // **Global, not `--local`.** `git lfs install --local` writes the
    // smudge/clean filters into *this* repository's config, so the push would
    // run git-lfs and a later `git clone` would not — and a clone with no
    // smudge filter writes the POINTER to the working tree and exits zero. The
    // harness points `HOME` at its own temp dir, so "global" here is this
    // test's own gitconfig and nothing else on the machine.
    harness
        .git(work, &["lfs", "install"])
        .expect_ok("git lfs install");
    harness
        .git(work, &["lfs", "track", "*.bin"])
        .expect_ok("git lfs track");
    std::fs::write(work.join("big.bin"), content).expect("write the payload");
    std::fs::write(work.join("plain.txt"), "not lfs\n").expect("write a plain file");
    harness
        .git(work, &["add", ".gitattributes", "big.bin", "plain.txt"])
        .expect_ok("git add");
    harness
        .git(work, &["commit", "-q", "-m", "one big file"])
        .expect_ok("git commit");

    // **The fixture itself must be an LFS pointer in the object database**, or
    // everything below would be testing an ordinary blob over an ordinary
    // clone. Asserted from git-lfs's own view rather than from the fact that
    // `git lfs track` exited zero.
    let listed = harness.git(work, &["lfs", "ls-files"]);
    listed.expect_ok("git lfs ls-files");
    assert!(
        listed.stdout.contains("big.bin"),
        "the fixture did not become an LFS object, so nothing below tests LFS: {}",
        listed.stdout.trim()
    );
    let stored = harness.git(work, &["show", "HEAD:big.bin"]);
    stored.expect_ok("git show HEAD:big.bin");
    assert!(
        stored.stdout.starts_with("version https://git-lfs"),
        "the committed blob is the file itself, not a pointer: {:?}",
        stored.stdout.chars().take(40).collect::<String>()
    );
}

/// The bytes in a clone are the bytes that were committed — **not** a pointer.
fn assert_materialised(clone: &Path, name: &str, expected: &[u8], what: &str) {
    let path = clone.join(name);
    let found = std::fs::read(&path)
        .unwrap_or_else(|err| panic!("{what}: {} could not be read: {err}", path.display()));
    assert_ne!(found.len(), 0, "{what}: {} is empty", path.display());
    assert!(
        !found.starts_with(b"version https://git-lfs"),
        "{what}: {} is an LFS POINTER FILE ({} bytes), not the content. The clone exited \
         zero and the working tree is wrong, which is the whole failure this suite exists \
         for.\n{}",
        path.display(),
        found.len(),
        String::from_utf8_lossy(&found[..found.len().min(200)])
    );
    assert_eq!(
        found.len(),
        expected.len(),
        "{what}: {} is {} bytes and should be {}",
        path.display(),
        found.len(),
        expected.len()
    );
    assert!(
        found == expected,
        "{what}: {} has the right length and the wrong bytes",
        path.display()
    );
}

/// Where an LFS object lands on the server, so a test can look at it or remove
/// it. The layout is `gunnar_lfs::fs_store`'s and this is the only place a test
/// spells it out.
fn server_object_path(harness: &Harness, oid: &str) -> std::path::PathBuf {
    harness
        .git_dir("team", "project")
        .join("lfs")
        .join("objects")
        .join(&oid[0..2])
        .join(&oid[2..4])
        .join(oid)
}

/// The single LFS oid a seeded fixture produced.
fn only_oid(harness: &Harness, work: &Path) -> String {
    let listed = harness.git(work, &["lfs", "ls-files", "--long"]);
    listed.expect_ok("git lfs ls-files --long");
    let line = listed
        .stdout
        .lines()
        .find(|line| line.contains("big.bin"))
        .expect("the fixture's object is listed");
    line.split_whitespace()
        .next()
        .expect("the oid is the first field")
        .to_owned()
}

// ── the headline ────────────────────────────────────────────────────────────

/// **A clone over `http://` materialises its LFS content.**
///
/// Push, clone, compare bytes. Then push the clone's own new object back, and
/// compare again from a third checkout, because a server that could receive
/// only what it had already sent would pass the first half.
#[test]
fn a_clone_over_http_materialises_its_lfs_content_and_a_push_puts_more_back() {
    if !common::tools_present() || !git_lfs_present() {
        panic!("git, ssh and git-lfs are required; a suite that skipped would report a pass");
    }
    for (name, hash) in HASHES {
        let harness = harness(*hash, Visibility::Private);
        let token = harness.mint_token("tester");
        let url = harness.http_url_with_token("team", "project", &token);
        let root = harness.dir.path();

        let content = payload(0xf00d);
        let work = root.join(format!("work-{name}"));
        seed_lfs(&harness, &work, *hash, &content);
        let oid = only_oid(&harness, &work);

        harness
            .git_http(&work, &["remote", "add", "origin", &url])
            .expect_ok("add the http remote");
        harness
            .git_http(&work, &["push", "-q", "origin", "main"])
            .expect_ok(&format!("push over http, {name}"));

        // **Applied output, on the server's own disk.** `git push` exiting zero
        // says the git objects went; this says the LFS bytes did, under the oid
        // the client computed, at the size it declared.
        let stored = server_object_path(&harness, &oid);
        let landed = std::fs::read(&stored).unwrap_or_else(|err| {
            panic!(
                "the LFS object did not reach the server at {}: {err}",
                stored.display()
            )
        });
        assert!(
            landed == content,
            "{name}: the object on the server is not the object that was pushed \
             ({} bytes against {})",
            landed.len(),
            content.len()
        );

        // ── the clone ──────────────────────────────────────────────────────
        let clone = root.join(format!("clone-{name}"));
        harness
            .git_http(root, &["clone", "-q", &url, clone.to_str().expect("utf-8")])
            .expect_ok(&format!("clone over http, {name}"));
        assert_materialised(
            &clone,
            "big.bin",
            &content,
            &format!("{name}: the clone's working tree"),
        );

        // ── and back the other way ─────────────────────────────────────────
        let second = payload(0xbeef);
        std::fs::write(clone.join("second.bin"), &second).expect("write the second payload");
        harness
            .git_http(&clone, &["add", "second.bin"])
            .expect_ok("git add in the clone");
        harness
            .git_http(&clone, &["commit", "-q", "-m", "a second big file"])
            .expect_ok("git commit in the clone");
        harness
            .git_http(&clone, &["push", "-q", "origin", "main"])
            .expect_ok(&format!("push the clone's object back, {name}"));

        let second_oid = only_second_oid(&harness, &clone);
        let stored = server_object_path(&harness, &second_oid);
        assert!(
            stored.exists(),
            "{name}: the second object never reached {}",
            stored.display()
        );

        let third = root.join(format!("third-{name}"));
        harness
            .git_http(root, &["clone", "-q", &url, third.to_str().expect("utf-8")])
            .expect_ok(&format!("clone again, {name}"));
        assert_materialised(
            &third,
            "second.bin",
            &second,
            &format!("{name}: the object the clone pushed back"),
        );
        // The plain file rides the ordinary object path and must be untouched
        // by any of this.
        assert_eq!(
            std::fs::read_to_string(third.join("plain.txt")).expect("the plain file"),
            "not lfs\n"
        );
    }
}

fn only_second_oid(harness: &Harness, work: &Path) -> String {
    let listed = harness.git(work, &["lfs", "ls-files", "--long"]);
    listed.expect_ok("git lfs ls-files --long");
    let line = listed
        .stdout
        .lines()
        .find(|line| line.contains("second.bin"))
        .expect("the second object is listed");
    line.split_whitespace().next().expect("an oid").to_owned()
}

/// **THE RED HALF, and the bug this whole surface exists not to have.**
///
/// The server is made to *not* hold an object the pointer names. A clone must
/// fail. If the batch answered `200` with an object carrying neither `actions`
/// nor `error` — which is the natural translation of the SSH grammar's `noop` —
/// git-lfs would take it as content it already had, the clone would exit zero,
/// and the working tree would hold a pointer file.
#[test]
fn a_clone_fails_visibly_when_the_server_does_not_hold_the_object() {
    if !common::tools_present() || !git_lfs_present() {
        panic!("git, ssh and git-lfs are required");
    }
    let harness = harness(HashKind::Sha256, Visibility::Private);
    let token = harness.mint_token("tester");
    let url = harness.http_url_with_token("team", "project", &token);
    let root = harness.dir.path();

    let content = payload(0x1234);
    let work = root.join("work");
    seed_lfs(&harness, &work, HashKind::Sha256, &content);
    let oid = only_oid(&harness, &work);
    harness
        .git_http(&work, &["remote", "add", "origin", &url])
        .expect_ok("add the http remote");
    harness
        .git_http(&work, &["push", "-q", "origin", "main"])
        .expect_ok("push over http");

    // The control: with the object present the clone works. Without this the
    // failure below would prove only that something is broken.
    let good = root.join("good");
    harness
        .git_http(root, &["clone", "-q", &url, good.to_str().expect("utf-8")])
        .expect_ok("the control clone");
    assert_materialised(&good, "big.bin", &content, "the control clone");

    // Now take the object away, exactly as a half-restored backup or a purged
    // store would.
    let stored = server_object_path(&harness, &oid);
    std::fs::remove_file(&stored).expect("remove the object from the server");

    let bad = root.join("bad");
    let outcome = harness.git_http(root, &["clone", "-q", &url, bad.to_str().expect("utf-8")]);
    // git-lfs fails the smudge, and `git clone` reports it. Either the clone
    // command fails outright or — depending on how git surfaces a filter error
    // — it succeeds and leaves a pointer, which is exactly what must not
    // happen. Both are checked, because only checking the exit code would let
    // the second through.
    let pointer_left = bad.join("big.bin").exists()
        && std::fs::read(bad.join("big.bin"))
            .map(|bytes| bytes.starts_with(b"version https://git-lfs"))
            .unwrap_or(false);
    assert!(
        !outcome.ok || !pointer_left,
        "the clone exited zero AND left a pointer file: the server answered a download \
         batch for an object it does not hold as though there were nothing to do, and \
         git-lfs believed it.\n  stdout: {}\n  stderr: {}",
        outcome.stdout.trim(),
        outcome.stderr.trim()
    );
    assert!(
        !outcome.ok,
        "an absent object must make the clone FAIL, visibly.\n  stdout: {}\n  stderr: {}",
        outcome.stdout.trim(),
        outcome.stderr.trim()
    );
    // **And it must say WHY.** Without the per-object 404 the clone still
    // fails, but with `Smudge error: error opening media file: … no such file
    // or directory` — a sentence about the client's own cache, for a fault that
    // is entirely the server's. MEASURED both ways on 2026-08-05 by rendering
    // `Plan::Missing` as an empty object: the exit code is the same and this
    // assertion is the one that moves.
    assert!(
        outcome.stderr.contains("404")
            || outcome.stderr.to_lowercase().contains("not on this server")
            || outcome
                .stderr
                .to_lowercase()
                .contains("object does not exist"),
        "the failure must name the missing object on the SERVER rather than report a \
         missing file in the client's own cache: {}",
        outcome.stderr.trim()
    );

    // **`git lfs fetch` is the sharper knife**, and it is the half an operator
    // never sees. The clone above fails either way, because its smudge filter
    // cannot open a file that is not there — but it fails saying
    // `Smudge error: error opening media file: … no such file or directory`,
    // which is a sentence about the *client's* cache for a fault that is
    // entirely the server's.
    //
    // `git lfs fetch` has no filter to fail. It asks the batch API and reports
    // what it got. MEASURED on 2026-08-05 by rendering `Plan::Missing` as an
    // object with neither an action nor an error: `fetch` printed
    // `1 object found, done.` and exited **zero**, having transferred nothing.
    // Run against the fresh clone, whose cache is empty — running it in a
    // checkout that already holds the object would find it locally and be
    // green whatever the server said.
    let fetched = harness.git_http(&bad, &["lfs", "fetch", "--all"]);
    assert!(
        !fetched.ok,
        "`git lfs fetch --all` for an object the server does not hold exited ZERO: the \
         batch answered it with no action and no error, git-lfs took it as one it \
         already had, and nothing was transferred.\n  stdout: {}\n  stderr: {}",
        fetched.stdout.trim(),
        fetched.stderr.trim()
    );
}

// ── the ACL, over the batch API ─────────────────────────────────────────────

/// **The same grants, the same answers.** Read may download and may not upload;
/// a stranger gets `401` and cannot tell a private repository from an absent
/// one; and none of it is decided in `gunnar-http`.
#[test]
fn the_batch_api_enforces_the_grant_ssh_enforces() {
    let harness = harness(HashKind::Sha256, Visibility::Private);
    let addr = harness.http_addr.expect("the http listener");

    // A second principal with Read and nothing more.
    harness
        .stores
        .auth()
        .add_principal(Principal::new("reader", "reader", PrincipalKind::User).expect("valid"))
        .expect("add the reader");
    harness
        .stores
        .auth()
        .grant(Grant::repo(
            "reader",
            RepoRef::new("team", "project").expect("a legal path"),
            Level::Read,
        ))
        .expect("grant read");
    let reader = harness.mint_token("reader");
    let writer = harness.mint_token("tester");

    let batch = |token: Option<&str>, operation: &str, path: &str| {
        let body = format!(
            r#"{{"operation":"{operation}","transfers":["basic"],"objects":[{{"oid":"{}","size":4}}]}}"#,
            "a".repeat(64)
        );
        let mut headers = vec![
            ("accept", "application/vnd.git-lfs+json".to_owned()),
            ("content-type", "application/vnd.git-lfs+json".to_owned()),
        ];
        if let Some(token) = token {
            headers.push(("authorization", basic(token)));
        }
        request(addr, "POST", path, &headers, body.as_bytes())
    };

    let path = "/team/project.git/info/lfs/objects/batch";

    // Write may upload and may download.
    assert_eq!(batch(Some(&writer), "upload", path).status, 200);
    assert_eq!(batch(Some(&writer), "download", path).status, 200);

    // Read may download and may NOT upload. This is the directional grant, and
    // it is the same `GitService::LfsTransfer(Operation::Upload)` row that
    // demands Write over SSH.
    assert_eq!(batch(Some(&reader), "download", path).status, 200);
    let refused = batch(Some(&reader), "upload", path);
    assert_eq!(
        refused.status,
        404,
        "a Read principal must not be able to upload: {}",
        refused.text()
    );

    // A stranger gets 401 — not 404 — and the same 401 for a repository that
    // does not exist, so probing tells them nothing.
    let anon = batch(None, "download", path);
    assert_eq!(anon.status, 401, "{}", anon.text());
    assert!(
        anon.headers.contains_key("lfs-authenticate"),
        "a 401 must carry LFS-Authenticate, which is the header the batch API names: {:?}",
        anon.headers
    );
    let absent = batch(None, "download", "/team/nope.git/info/lfs/objects/batch");
    assert_eq!(
        absent.status, anon.status,
        "a private repository and an absent one must be indistinguishable to a stranger"
    );

    // An authenticated caller with no grant at all on a repository that does
    // not exist gets 404, the same as one that does but is not theirs.
    let unknown = batch(
        Some(&reader),
        "download",
        "/team/nope.git/info/lfs/objects/batch",
    );
    assert_eq!(unknown.status, 404, "{}", unknown.text());

    // And a bad token is 401, not 404.
    let bogus = batch(Some("gunnar_pat_not_a_real_token"), "download", path);
    assert_eq!(bogus.status, 401, "{}", bogus.text());
}

/// **Anonymous read over HTTP opens no door SSH keeps shut.**
///
/// A `Public` repository is readable with no credential — the same rule 0 of
/// `AuthTable::may` the SSH listener applies — and nothing else is: an
/// anonymous *upload* batch against the very same public repository is
/// refused.
#[test]
fn anonymous_lfs_is_read_on_public_and_nothing_else() {
    let harness = harness(HashKind::Sha256, Visibility::Public);
    let addr = harness.http_addr.expect("the http listener");
    let path = "/team/project.git/info/lfs/objects/batch";
    let json = [
        ("accept", "application/vnd.git-lfs+json".to_owned()),
        ("content-type", "application/vnd.git-lfs+json".to_owned()),
    ];
    let body = |operation: &str| {
        format!(
            r#"{{"operation":"{operation}","objects":[{{"oid":"{}","size":4}}]}}"#,
            "a".repeat(64)
        )
    };

    let read = request(addr, "POST", path, &json, body("download").as_bytes());
    assert_eq!(
        read.status,
        200,
        "a public repository is anonymously readable, exactly as over ssh: {}",
        read.text()
    );
    let write = request(addr, "POST", path, &json, body("upload").as_bytes());
    assert_eq!(
        write.status,
        401,
        "anonymous WRITE must be refused on a public repository too: {}",
        write.text()
    );

    // A private repository is not anonymously readable, or `Public` would mean
    // nothing.
    harness.set_visibility("team", "project", Visibility::Private);
    let read = request(addr, "POST", path, &json, body("download").as_bytes());
    assert_eq!(read.status, 401, "{}", read.text());
}

/// A `download` batch for an object the server does not hold answers `200` with
/// a per-object `404`, and a `download` for one it does hold answers with a
/// `download` action. Asserted on the JSON, because this is the exact
/// distinction the SSH grammar cannot make.
#[test]
fn a_download_batch_says_404_per_object_inside_a_200() {
    let harness = harness(HashKind::Sha256, Visibility::Private);
    let addr = harness.http_addr.expect("the http listener");
    let token = harness.mint_token("tester");

    // Put one object in through the PUT endpoint, which is also what proves
    // the upload half without git-lfs in the way.
    let content = b"the object that is here".to_vec();
    let oid = sha256_hex(&content);
    let absent = "b".repeat(64);

    let auth = || basic(&token);
    let put = request(
        addr,
        "PUT",
        &format!("/team/project.git/info/lfs/objects/{oid}"),
        &[
            ("authorization", auth()),
            ("content-type", "application/octet-stream".to_owned()),
        ],
        &content,
    );
    assert_eq!(put.status, 200, "{}", put.text());

    let body = format!(
        r#"{{"operation":"download","objects":[{{"oid":"{oid}","size":{}}},{{"oid":"{absent}","size":7}}]}}"#,
        content.len()
    );
    let answer = request(
        addr,
        "POST",
        "/team/project.git/info/lfs/objects/batch",
        &[
            ("authorization", auth()),
            ("accept", "application/vnd.git-lfs+json".to_owned()),
            ("content-type", "application/vnd.git-lfs+json".to_owned()),
        ],
        body.as_bytes(),
    );
    assert_eq!(answer.status, 200, "{}", answer.text());
    assert_eq!(
        answer.headers.get("content-type").map(String::as_str),
        Some("application/vnd.git-lfs+json"),
        "git-lfs refuses a batch response that is not its own media type"
    );
    let text = answer.text();
    assert!(
        text.contains(&format!(r#""oid":"{oid}""#)) && text.contains(r#""download""#),
        "the object the server holds must carry a download action: {text}"
    );
    assert!(
        text.contains(r#""error":{"code":404"#),
        "the object the server does NOT hold must carry a per-object 404, inside the 200. \
         With neither an action nor an error git-lfs takes it as content it already has, \
         and the clone leaves a pointer file: {text}"
    );

    // The object really comes back, byte for byte, from the href.
    let fetched = request(
        addr,
        "GET",
        &format!("/team/project.git/info/lfs/objects/{oid}"),
        &[("authorization", auth())],
        &[],
    );
    assert_eq!(fetched.status, 200);
    assert_eq!(fetched.body, content);
    assert_eq!(
        fetched.headers.get("content-length").map(String::as_str),
        Some(content.len().to_string().as_str())
    );

    // And an object that is not there is a plain 404 on the object endpoint.
    let missing = request(
        addr,
        "GET",
        &format!("/team/project.git/info/lfs/objects/{absent}"),
        &[("authorization", auth())],
        &[],
    );
    assert_eq!(missing.status, 404, "{}", missing.text());
}

/// The upload endpoint does not store bytes that are not the object they were
/// sent as, and it says so with the code
/// [`gunnar_lfs::Error::status`](gunnar_lfs::Error) gives a corrupt object.
#[test]
fn a_lying_upload_is_refused_and_stores_nothing() {
    let harness = harness(HashKind::Sha256, Visibility::Private);
    let addr = harness.http_addr.expect("the http listener");
    let token = harness.mint_token("tester");
    let auth = basic(&token);
    let content = b"these bytes".to_vec();
    let honest = sha256_hex(&content);
    let lie = "c".repeat(64);

    let refused = request(
        addr,
        "PUT",
        &format!("/team/project.git/info/lfs/objects/{lie}"),
        &[("authorization", auth.clone())],
        &content,
    );
    assert_eq!(refused.status, 422, "{}", refused.text());
    assert!(
        !server_object_path(&harness, &lie).exists(),
        "an object that does not hash to its oid must not be on disk"
    );

    // The honest half, so the assertion above is not passing against an
    // endpoint that refuses everything.
    let stored = request(
        addr,
        "PUT",
        &format!("/team/project.git/info/lfs/objects/{honest}"),
        &[("authorization", auth.clone())],
        &content,
    );
    assert_eq!(stored.status, 200, "{}", stored.text());
    assert!(server_object_path(&harness, &honest).exists());

    // `verify` answers from the store, so it says yes for the one that landed
    // and no for the one that did not.
    let verify = |oid: &str, size: usize| {
        request(
            addr,
            "POST",
            "/team/project.git/info/lfs/objects/verify",
            &[
                ("authorization", auth.clone()),
                ("accept", "application/vnd.git-lfs+json".to_owned()),
                ("content-type", "application/vnd.git-lfs+json".to_owned()),
            ],
            format!(r#"{{"oid":"{oid}","size":{size}}}"#).as_bytes(),
        )
    };
    assert_eq!(verify(&honest, content.len()).status, 200);
    assert_eq!(verify(&lie, content.len()).status, 404);
    assert_eq!(
        verify(&honest, content.len() + 1).status,
        422,
        "verify must compare the size it is given against the store, not merely exist"
    );
}

/// The operator's quota reaches this surface, and it reaches it **before the
/// bytes arrive**.
///
/// `WireConfig::lfs` is the one place an LFS limit is held, and until the seam
/// existed the HTTP side could only have had a second copy of it. A 413 here
/// with a 1-byte ceiling proves the operator's number arrived; the 200 under
/// the ceiling proves the endpoint is not simply refusing everything.
#[test]
fn the_operators_lfs_quota_binds_on_http_too() {
    let mut wire = gunnar_cli::server::WireConfig::default();
    wire.lfs = wire.lfs.max_object_bytes(8);
    let harness = Harness::start_with_wire_options(
        "team",
        "project",
        HashKind::Sha256,
        wire,
        HarnessOptions {
            http: true,
            ..HarnessOptions::default()
        },
    );
    let addr = harness.http_addr.expect("the http listener");
    let token = harness.mint_token("tester");
    let auth = basic(&token);

    let big = vec![b'x'; 9];
    let refused = request(
        addr,
        "PUT",
        &format!("/team/project.git/info/lfs/objects/{}", sha256_hex(&big)),
        &[("authorization", auth.clone())],
        &big,
    );
    assert_eq!(
        refused.status,
        413,
        "the operator's --max-lfs-object-bytes must bind on HTTP: {}",
        refused.text()
    );

    let small = vec![b'x'; 8];
    let accepted = request(
        addr,
        "PUT",
        &format!("/team/project.git/info/lfs/objects/{}", sha256_hex(&small)),
        &[("authorization", auth.clone())],
        &small,
    );
    assert_eq!(
        accepted.status,
        200,
        "exactly at the ceiling must be accepted, or the test above would pass against \
         an endpoint that refuses every upload: {}",
        accepted.text()
    );

    // And the batch refuses it too, per object, before a byte is offered.
    let planned = request(
        addr,
        "POST",
        "/team/project.git/info/lfs/objects/batch",
        &[
            ("authorization", auth),
            ("accept", "application/vnd.git-lfs+json".to_owned()),
            ("content-type", "application/vnd.git-lfs+json".to_owned()),
        ],
        format!(
            r#"{{"operation":"upload","objects":[{{"oid":"{}","size":4096}}]}}"#,
            "d".repeat(64)
        )
        .as_bytes(),
    );
    assert_eq!(planned.status, 200);
    assert!(
        planned.text().contains(r#""code":413"#),
        "an over-quota object must be refused in the batch, before the client offers a \
         byte of it: {}",
        planned.text()
    );
}

/// A listener built with no LFS backend says so, rather than 404ing as though
/// the URL were wrong.
///
/// This is the anti-vacuity half of `with_lfs`: if `gunnar serve` ever stopped
/// calling it, every test above would fail — and this one would start passing,
/// which is why it asserts the *sentence* and not merely a non-200.
#[test]
fn a_listener_without_an_lfs_backend_answers_501() {
    use std::sync::Arc;
    let harness = harness(HashKind::Sha256, Visibility::Private);
    let dispatch = gunnar_ssh::server::Dispatch::new(harness.stores.ssh_control());
    let transport: Arc<dyn gunnar_ssh::seam::GitTransport> = Arc::new(
        gunnar_cli::server::WireTransport::new(Arc::clone(&harness.stores)),
    );
    let bound = harness
        .runtime
        .block_on(
            gunnar_http::GunnarHttp::new(
                gunnar_http::HttpConfig {
                    listen: ([127, 0, 0, 1], 0).into(),
                    cleartext: gunnar_http::Cleartext::Allowed,
                    ..gunnar_http::HttpConfig::default()
                },
                dispatch,
                transport,
            )
            // No `.with_lfs(...)`.
            .bind(),
        )
        .expect("bind");
    let addr = bound.local_addr();
    let task = harness.runtime.spawn(bound.serve());

    let answer = request(
        addr,
        "POST",
        "/team/project.git/info/lfs/objects/batch",
        &[
            ("accept", "application/vnd.git-lfs+json".to_owned()),
            ("content-type", "application/vnd.git-lfs+json".to_owned()),
        ],
        br#"{"operation":"download","objects":[]}"#,
    );
    assert_eq!(answer.status, 501, "{}", answer.text());
    assert!(
        answer.text().contains("no LFS object store"),
        "the refusal must say what is missing: {}",
        answer.text()
    );
    task.abort();
}

/// The refusals the specification names, each with its own subject.
#[test]
fn the_batch_api_refuses_what_the_specification_says_to_refuse() {
    let harness = harness(HashKind::Sha256, Visibility::Public);
    let addr = harness.http_addr.expect("the http listener");
    let path = "/team/project.git/info/lfs/objects/batch";
    let json = |body: &str, accept: &str| {
        request(
            addr,
            "POST",
            path,
            &[
                ("accept", accept.to_owned()),
                ("content-type", "application/vnd.git-lfs+json".to_owned()),
            ],
            body.as_bytes(),
        )
    };
    let ok = r#"{"operation":"download","objects":[]}"#;

    // The control.
    assert_eq!(json(ok, "application/vnd.git-lfs+json").status, 200);

    // 406 on an Accept this endpoint cannot satisfy.
    assert_eq!(json(ok, "text/html").status, 406);

    // 400 on a body that is not the document.
    assert_eq!(json("{", "application/vnd.git-lfs+json").status, 400);
    assert_eq!(
        json(r#"{"objects":[]}"#, "application/vnd.git-lfs+json").status,
        400,
        "a batch with no `operation` must be refused, not authorised as whichever \
         direction a default happened to be"
    );

    // 422 on an operation that is not one, on a hash that is not sha256, and on
    // a transfer adapter gunnar does not implement.
    assert_eq!(
        json(
            r#"{"operation":"sideways","objects":[]}"#,
            "application/vnd.git-lfs+json"
        )
        .status,
        // The ACL runs first and an unparsable operation falls to the weaker
        // grant, which a public repository grants anonymously — so this reaches
        // the handler and is refused there.
        422
    );
    let sha1 = json(
        r#"{"operation":"download","hash_algo":"sha1","objects":[]}"#,
        "application/vnd.git-lfs+json",
    );
    assert_eq!(sha1.status, 422, "{}", sha1.text());
    let adapter = json(
        r#"{"operation":"download","transfers":["tus"],"objects":[]}"#,
        "application/vnd.git-lfs+json",
    );
    assert_eq!(adapter.status, 422, "{}", adapter.text());
    assert!(
        adapter.text().contains("basic"),
        "the refusal must name what gunnar does speak: {}",
        adapter.text()
    );

    // Every error body carries the request id the header carries, which is the
    // whole point of the field.
    let id = adapter
        .headers
        .get("x-gunnar-request-id")
        .expect("every response carries one");
    assert!(
        adapter.text().contains(&format!(r#""request_id":"{id}""#)),
        "the error body's request_id must be the same id as the header's, or an operator \
         cannot join a user's report to a log line: {} / {id}",
        adapter.text()
    );
}

// ── a small HTTP client, so the tests can assert status codes ───────────────

/// One HTTP/1.1 response.
struct Answer {
    status: u16,
    headers: BTreeMap<String, String>,
    body: Vec<u8>,
}

impl Answer {
    fn text(&self) -> String {
        String::from_utf8_lossy(&self.body).into_owned()
    }
}

/// Send one request and read the whole response.
///
/// Hand-written rather than pulled from a client crate, for the reason the rest
/// of this tree gives for the same choice: a test that asserts a status code
/// and a media type needs to *see* the bytes, and `Connection: close` plus
/// read-to-EOF is the whole of what that takes. `git-lfs` itself is what proves
/// interoperability; this proves the codes.
fn request(
    addr: SocketAddr,
    method: &str,
    path: &str,
    headers: &[(&str, String)],
    body: &[u8],
) -> Answer {
    let mut stream = TcpStream::connect(addr).expect("connect to the http listener");
    let mut head = format!(
        "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\
         Content-Length: {}\r\n",
        body.len()
    );
    for (name, value) in headers {
        head.push_str(&format!("{name}: {value}\r\n"));
    }
    head.push_str("\r\n");
    stream.write_all(head.as_bytes()).expect("write the head");
    stream.write_all(body).expect("write the body");
    stream.flush().expect("flush");

    let mut raw = Vec::new();
    stream.read_to_end(&mut raw).expect("read the response");
    let split = raw
        .windows(4)
        .position(|window| window == b"\r\n\r\n")
        .unwrap_or_else(|| panic!("no header terminator in {} bytes", raw.len()));
    let head = String::from_utf8_lossy(&raw[..split]).into_owned();
    let body = raw[split + 4..].to_vec();

    let mut lines = head.lines();
    let status_line = lines.next().expect("a status line");
    let status = status_line
        .split_whitespace()
        .nth(1)
        .and_then(|code| code.parse().ok())
        .unwrap_or_else(|| panic!("unparsable status line {status_line:?}"));
    let headers = lines
        .filter_map(|line| line.split_once(':'))
        .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned()))
        .collect();
    Answer {
        status,
        headers,
        body,
    }
}

/// An HTTP Basic header carrying `token` as the password. The username is
/// decorative; gunnar reads the password half and nothing else.
fn basic(token: &str) -> String {
    use base64ct::Encoding as _;
    format!(
        "Basic {}",
        base64ct::Base64::encode_string(format!("x-access-token:{token}").as_bytes())
    )
}

/// SHA-256 of raw bytes as lowercase hex: what an LFS oid is.
///
/// Through `gunnar_lfs`'s own hasher and not a second one, so a test cannot
/// agree with the server by both being wrong in the same way (one implementation).
fn sha256_hex(bytes: &[u8]) -> String {
    gunnar_lfs::Oid::of(bytes).to_hex()
}

/// Is the real `git-lfs` binary usable?
///
/// Checked so the panic names the missing tool. Never a skip: a suite that
/// reported success because git-lfs was absent is the failure this project
/// keeps finding.
fn git_lfs_present() -> bool {
    std::process::Command::new("git-lfs")
        .arg("version")
        .output()
        .map(|out| out.status.success())
        .unwrap_or(false)
}