crates/gunnar-cli/tests/http_transport.rs
//! **Smart HTTP, judged by stock `git`.**
//!
//! > **Runs only under `--features http`.** The transport is default-off (the
//! > shape the systemd verb set), so this file is gated to the feature it tests. That is
//! > a silently-skipping suite, which is a shape worth naming rather than
//! > leaving to be discovered: with the feature off, `cargo test` reports
//! > *nothing here*, not a pass. `xtask ci` therefore runs the workspace with
//! > `--features gunnar-cli/http`, and `tests/feature_split.rs` asserts from a
//! > resolved graph that the appliance build does **not** carry it — so the
//! > default-off claim is measured rather than assumed.
//!
//! Nothing in this file talks to gunnar's own client. The only honest proof
//! that a git server works is that the `git` binary talks to it, and the only
//! honest proof that a *clone* worked is that the clone holds the same objects
//! the server does — not that `git` exited zero. `git clone` of an empty
//! repository exits zero. So does a clone that fetches nothing because
//! negotiation went wrong.
//!
//! Every assertion here therefore ends at one of two places:
//!
//! * [`object_graph`], which is `git cat-file --batch-all-objects` plus every
//! ref tip, compared **oid by oid** between the two sides; or
//! * the stored refs, read back out of the server's own `RefStore` after a
//! push, so "the client said ok" is never the evidence.
//!
//! And each of them carries a **non-vacuity floor**: a fixture with fewer than
//! [`MIN_OBJECTS`] objects fails the comparison outright. Without it, an empty
//! repository compared against an empty clone is a green test for a server
//! that serves nothing at all, which is precisely the shape a measured
//! assertion exists to catch.
//!
//! # Both protocol versions, deliberately forced
//!
//! `git` negotiates v2 by default over HTTP, so a suite that did not say
//! otherwise would never exercise the v0 path — and v0 over HTTP is where
//! stateless-rpc is hardest, because v0 has no `command=` boundary to end a
//! round on. Every test that can run under both runs under both, with
//! `-c protocol.version=…` on the command line, and asserts *which one was
//! actually spoken* rather than trusting the flag.
#![cfg(feature = "http")]
mod common;
use std::path::Path;
use common::{Harness, HarnessOptions};
use gunnar_core::{Grant, HashKind, Level, Principal, PrincipalKind, RepoRef, Visibility};
/// The floor a fixture must clear for a graph comparison to mean anything.
///
/// Seven commits with a merge and a tag produce comfortably more than this. The
/// number is a floor and not an equality on purpose: it must fail an empty or
/// truncated fetch, not track the fixture.
const MIN_OBJECTS: usize = 12;
/// The two protocol versions, and what `git` has to be told to speak each.
const VERSIONS: &[(&str, u8)] = &[("0", 0), ("2", 2)];
fn harness(hash: HashKind, visibility: Visibility) -> Harness {
Harness::start_with_options(
"team",
"project",
hash,
HarnessOptions {
http: true,
visibility,
..HarnessOptions::default()
},
)
}
/// A working tree with real history: a branch that diverges and merges, a tag,
/// and enough objects to clear [`MIN_OBJECTS`].
///
/// A merge and not a chain. A straight line makes negotiation look easy,
/// because the client's first `have` is always the answer; the second parent is
/// what catches a traversal that follows `parent[0]` only.
fn seed(harness: &Harness, work: &Path) {
std::fs::create_dir_all(work).expect("the working tree");
harness
.git(work, &["init", "-q", "-b", "main"])
.expect_ok("a git command in the http suite");
for index in 0..4 {
commit(harness, work, &format!("main-{index}"));
}
harness
.git(work, &["checkout", "-q", "-b", "side", "HEAD~2"])
.expect_ok("a git command in the http suite");
commit(harness, work, "side-0");
harness
.git(work, &["checkout", "-q", "main"])
.expect_ok("a git command in the http suite");
harness
.git(
work,
&["merge", "-q", "--no-ff", "-m", "merge side", "side"],
)
.expect_ok("a git command in the http suite");
commit(harness, work, "after-merge");
harness
.git(work, &["tag", "-a", "v1", "-m", "release one"])
.expect_ok("a git command in the http suite");
}
fn commit(harness: &Harness, work: &Path, name: &str) {
std::fs::write(work.join(format!("{name}.txt")), format!("{name}\n")).expect("write a file");
harness
.git(work, &["add", "-A"])
.expect_ok("a git command in the http suite");
harness
.git(work, &["commit", "-q", "-m", name])
.expect_ok("a git command in the http suite");
}
/// Every object a repository holds, and every ref tip, as one sorted list.
///
/// `cat-file --batch-all-objects` is the whole object database — not what is
/// reachable, not what a log walk finds — so a pack that arrived missing a tree
/// shows up here and nowhere else. The ref tips are appended because two
/// repositories can hold identical objects and disagree about where `main`
/// points, which is a push that did nothing.
///
/// # Refs are compared in ONE namespace
///
/// A clone stores what a bare repository calls `refs/heads/main` under
/// `refs/remotes/origin/main`, so a raw comparison fails on every clone that
/// worked perfectly. [`normalise_ref`] folds the remote-tracking namespace onto
/// the local one and drops the two `HEAD` symrefs, which differ by design.
///
/// This is a narrowing and it is worth being explicit about what survives it:
/// the **oid** of every branch and every tag still has to match on both sides,
/// so a fetch that moved no ref, or moved one to the wrong commit, still fails.
/// What is given up is only *where the client filed it*, which is git's
/// business and not gunnar's.
fn object_graph(harness: &Harness, repo: &Path) -> Vec<String> {
let objects = harness.git(
repo,
&[
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname) %(objecttype)",
],
);
objects.expect_ok("cat-file --batch-all-objects");
let refs = harness.git(repo, &["show-ref", "-d"]).stdout.clone();
// Remote-tracking first, local second, so that when a working repository
// holds both — `git push <url>` does NOT update a remote-tracking ref, so
// origin/main goes stale the moment you push by URL — the LOCAL one wins.
// That is the right way round: the local ref is what was pushed, and it is
// what the server must now agree with.
let mut tips: std::collections::BTreeMap<String, String> = Default::default();
for pass in [Namespace::RemoteTracking, Namespace::Local] {
for line in refs.lines() {
let Some((oid, name)) = line.split_once(' ') else {
continue;
};
// `refs/remotes/origin/HEAD` is the clone's note of where the
// server's HEAD pointed; the server has `HEAD`, which `show-ref -d`
// does not list. Neither has a counterpart on the other side.
if name == "refs/remotes/origin/HEAD" {
continue;
}
match (pass, name.strip_prefix("refs/remotes/origin/")) {
(Namespace::RemoteTracking, Some(branch)) => {
tips.insert(format!("refs/heads/{branch}"), oid.to_string());
}
(Namespace::Local, None) => {
tips.insert(name.to_string(), oid.to_string());
}
_ => {}
}
}
}
let mut lines: Vec<String> = objects
.stdout
.lines()
.map(str::to_string)
.chain(
tips.into_iter()
.map(|(name, oid)| format!("ref {oid} {name}")),
)
.collect();
lines.sort();
lines
}
/// Which half of [`object_graph`]'s two-pass ref fold is running.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Namespace {
RemoteTracking,
Local,
}
/// Compare two graphs, with the floor that makes the comparison mean something.
fn assert_same_graph(what: &str, server_side: &[String], client_side: &[String]) {
let objects = server_side
.iter()
.filter(|line| !line.starts_with("ref "))
.count();
assert!(
objects >= MIN_OBJECTS,
"{what}: the SERVER side holds {objects} objects, under the {MIN_OBJECTS}-object \
floor. An empty repository compared against an empty clone is a green test for a \
server that serves nothing, so this fails rather than passes."
);
assert_eq!(server_side, client_side, "{what}: the object graphs differ");
}
/// **The server's whole graph, read the only way the SHIPPED engine
/// answers:** a fresh `--mirror` clone by stock `git`, then [`object_graph`]
/// on the clone.
///
/// # 🔴 This was `git` pointed INSIDE the server's git directory until 2026-10-11
///
/// Which `tests/replication.rs` found and wrote down on 2026-08-30: on the
/// znippy engine that directory holds no `refs/`, no `packed-refs`, no
/// `objects/` and no loose object — a push is appended verbatim into
/// `gunnar-git.znippy/objects.pack` and the refs are rows in a log — so
/// `cat-file`, `show-ref` and `fsck` all died there with "not a git
/// repository", on every call, whatever the transport had done. The mirror
/// clone is the *stronger* read: the pack has to be emitted, sent and indexed
/// by a client nobody here wrote. What is narrowed is only that objects
/// unreachable on the server are not carried — and nothing in this suite
/// creates one.
fn server_graph(harness: &Harness, url: &str, probe: &str) -> Vec<String> {
let probe = harness.dir.path().join(probe);
harness
.git_http(
harness.dir.path(),
&[
"clone",
"-q",
"--mirror",
url,
probe.to_str().expect("utf-8 path"),
],
)
.expect_ok("mirror-clone the server to read its graph");
object_graph(harness, &probe)
}
// ── clone ───────────────────────────────────────────────────────────────────
/// **`git clone http://…` of a public repository, with no credentials at all,
/// on both protocol versions.**
///
/// This is the headline: a stranger, over HTTP, cloning a repository they were
/// never given a key for, and ending up with byte-identical objects. It is also
/// the anonymous path arriving on a second transport, which is the thing
/// that had to keep obeying one switch rather than growing a second.
#[test]
fn a_stranger_clones_a_public_repository_over_http_on_both_protocol_versions() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let expected = server_graph(
&harness,
&harness.http_url("team", "project"),
"server-probe",
);
for (flag, number) in VERSIONS {
let target = harness.dir.path().join(format!("clone-v{number}"));
harness
.git_http(
harness.dir.path(),
&[
"-c",
&format!("protocol.version={flag}"),
"clone",
"-q",
&harness.http_url("team", "project"),
target.to_str().expect("utf-8 path"),
],
)
.expect_ok("a git command in the http suite");
assert_same_graph(
&format!("anonymous http clone, protocol v{number}"),
&expected,
&object_graph(&harness, &target),
);
}
}
/// The same over **sha256**, which is where `object-format` echoing goes wrong
/// silently: answering `sha1` to a sha256 repository yields an *empty*
/// response, not an error (object-format negotiation, and the bug that cost a session in
/// `nornir-git`).
#[test]
fn a_sha256_repository_clones_over_http_on_both_protocol_versions() {
let harness = harness(HashKind::Sha256, Visibility::Public);
let work = harness.dir.path().join("work");
std::fs::create_dir_all(&work).expect("the working tree");
harness
.git(
&work,
&["init", "-q", "-b", "main", "--object-format=sha256"],
)
.expect_ok("a git command in the http suite");
for index in 0..8 {
commit(&harness, &work, &format!("c{index}"));
}
harness
.git(&work, &["tag", "-a", "v1", "-m", "one"])
.expect_ok("a git command in the http suite");
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let expected = server_graph(
&harness,
&harness.http_url("team", "project"),
"server-probe",
);
for (flag, number) in VERSIONS {
let target = harness.dir.path().join(format!("sha256-clone-v{number}"));
harness
.git_http(
harness.dir.path(),
&[
"-c",
&format!("protocol.version={flag}"),
"clone",
"-q",
&harness.http_url("team", "project"),
target.to_str().expect("utf-8 path"),
],
)
.expect_ok("a git command in the http suite");
// The oids themselves are the assertion: a sha256 repository's object
// names are 64 hex characters, so a clone that somehow landed sha1
// objects could not match.
let actual = object_graph(&harness, &target);
assert!(
actual
.iter()
.filter(|line| !line.starts_with("ref "))
.all(|line| line.split(' ').next().is_some_and(|oid| oid.len() == 64)),
"protocol v{number}: a sha256 clone must hold 64-character object names: {actual:?}"
);
assert_same_graph(
&format!("sha256 http clone, protocol v{number}"),
&expected,
&actual,
);
}
}
// ── ls-remote ───────────────────────────────────────────────────────────────
/// `git ls-remote http://…` on both versions, against the server's own refs.
///
/// Asserted against what the **server** stores rather than against a literal,
/// so the test cannot drift from the fixture, and including the peeled tag,
/// which is the entry a `peel`-less advertisement drops silently.
#[test]
fn ls_remote_over_http_lists_exactly_the_stored_refs_on_both_versions() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
// The server's refs are exactly what `--mirror` just pushed, and `work` is
// a directory stock `git` can read — the server's own is not (the znippy
// engine keeps its refs as rows in a log; see [`server_graph`]).
let show_ref = harness.git(&work, &["show-ref", "-d"]);
let mut expected: Vec<String> = show_ref
.expect_ok("show-ref on the mirror-pushed source")
.stdout
.lines()
.map(|line| line.replace(' ', "\t"))
.collect();
expected.sort();
assert!(
expected.len() >= 4,
"the fixture must have main, side, v1 and v1^{{}} at least: {expected:?}"
);
for (flag, number) in VERSIONS {
let listing = harness.git_http(
harness.dir.path(),
&[
"-c",
&format!("protocol.version={flag}"),
"ls-remote",
&harness.http_url("team", "project"),
],
);
let mut listed: Vec<String> = listing
.expect_ok("ls-remote over http")
.stdout
.lines()
.filter(|line| !line.ends_with("HEAD"))
.map(str::to_string)
.collect();
listed.sort();
assert_eq!(
listed, expected,
"protocol v{number}: ls-remote over HTTP disagrees with the stored refs"
);
}
}
// ── fetch, with a real negotiation ──────────────────────────────────────────
/// **An incremental fetch over HTTP**, on both versions.
///
/// This is the one stateless-rpc is really about. The client already holds most
/// of the history, so it sends `have`s, and over HTTP each round is a separate
/// `POST` with the whole request repeated. A server that carried state between
/// rounds, or that kept reading after the body ended, hangs here rather than
/// failing — so the test also asserts the fetch **moved the ref**, which a
/// no-op fetch that exited zero would not.
#[test]
fn an_incremental_fetch_over_http_gets_the_new_commits_on_both_versions() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
let url = harness.url("team", "project");
harness
.git(&work, &["push", "-q", &url, "--mirror"])
.expect_ok("a git command in the http suite");
for (flag, number) in VERSIONS {
let clone = harness.dir.path().join(format!("fetch-v{number}"));
harness
.git_http(
harness.dir.path(),
&[
"-c",
&format!("protocol.version={flag}"),
"clone",
"-q",
&harness.http_url("team", "project"),
clone.to_str().expect("utf-8"),
],
)
.expect_ok("a git command in the http suite");
let before = rev_parse(&harness, &clone, "origin/main");
// Three more commits on the server, pushed over SSH so the fetch below
// is the only HTTP operation being measured.
for index in 0..3 {
commit(&harness, &work, &format!("later-v{number}-{index}"));
}
harness
.git(&work, &["push", "-q", &url, "main"])
.expect_ok("a git command in the http suite");
let server_tip = rev_parse(&harness, &work, "main");
harness
.git_http(
&clone,
&[
"-c",
&format!("protocol.version={flag}"),
"fetch",
"-q",
"origin",
],
)
.expect_ok("a git command in the http suite");
let after = rev_parse(&harness, &clone, "origin/main");
assert_ne!(
before, after,
"protocol v{number}: the fetch left origin/main where it was, so it fetched \
nothing; an exit code of zero says nothing here"
);
assert_eq!(
after, server_tip,
"protocol v{number}: origin/main did not reach the server's tip"
);
// …and the new objects really arrived, not just the ref.
harness
.git(
&clone,
&["cat-file", "-e", &format!("{server_tip}^{{tree}}")],
)
.expect_ok("a git command in the http suite");
}
}
// ── push ────────────────────────────────────────────────────────────────────
/// **`git push http://…`**, authenticated with an access token, asserted
/// against the refs the server actually stored.
///
/// Push is v0 on every transport — protocol v2 has no push side — so there is
/// one version here and that is not an omission.
#[test]
fn a_token_holder_pushes_over_http_and_the_server_stores_it() {
let harness = harness(HashKind::Sha1, Visibility::Private);
let work = harness.dir.path().join("work");
seed(&harness, &work);
let token = harness.mint_token("tester");
let url = harness.http_url_with_token("team", "project", &token);
harness
.git(&work, &["push", "-q", &url, "--mirror"])
.expect_ok("a git command in the http suite");
// Read back from the SERVER's own ref store, not from what git reported.
let path = RepoRef::new("team", "project").expect("legal");
let repo = harness
.stores
.catalog()
.open(&path)
.expect("the repository");
let store = harness.stores.repo_store(&repo).expect("the store");
// `RefStore::resolve` → `GitOps::refs` + `RefRow::oid` (2026-08-10, same
// migration as the ssh and anonymous suites). A symbolic row carries
// `oid: None`, so a symref where a push landed still fails, by name.
let rows = gunnar_store::git_storage_trait::GitOps::refs(store.serve()).expect("read the refs");
let main = rows
.iter()
.find(|row| row.name == "refs/heads/main")
.expect("refs/heads/main was pushed");
let stored = main
.oid
.as_ref()
.unwrap_or_else(|| panic!("refs/heads/main is symbolic after a push: {main:?}"));
let expected = rev_parse(&harness, &work, "main");
assert_eq!(
gix_hash::ObjectId::try_from(stored.as_slice())
.expect("a well-sized oid")
.to_string(),
expected,
"the server stored a different tip than the client pushed"
);
// And the whole graph, so a push that landed the ref without the objects
// fails here.
assert_same_graph(
"http push",
&server_graph(&harness, &url, "stored-probe"),
&object_graph(&harness, &work),
);
// A second push with nothing to do must also succeed: `git push` with no
// work takes the advertisement and hangs up, which over HTTP is a GET with
// no POST after it, and a server that expected one would wait.
harness
.git(&work, &["push", &url, "main"])
.expect_ok("a git command in the http suite");
}
/// The clone-then-push round trip over HTTP alone, ending in `git fsck`.
///
/// `fsck` is the assertion an object-graph comparison cannot make: it walks
/// every object it has and complains about missing links. A thin pack whose
/// bases never resolved passes a graph comparison against the pusher (both
/// sides lack the same object) and fails here.
#[test]
fn clone_push_and_fsck_over_http_only() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
let token = harness.mint_token("tester");
let authed = harness.http_url_with_token("team", "project", &token);
harness
.git(&work, &["push", "-q", &authed, "--mirror"])
.expect_ok("a git command in the http suite");
let clone = harness.dir.path().join("roundtrip");
harness
.git_http(
harness.dir.path(),
&["clone", "-q", &authed, clone.to_str().expect("utf-8")],
)
.expect_ok("a git command in the http suite");
commit(&harness, &clone, "from-the-clone");
harness
.git(&clone, &["push", "-q", &authed, "main"])
.expect_ok("a git command in the http suite");
// The round trip, read back through a mirror clone ([`server_graph`]) —
// and `fsck` walks THAT clone, because the server's own directory is not
// one stock `git` can read. The pack had to be emitted, sent and indexed
// to get here, which is the integrity question this test asks.
let server_side = server_graph(&harness, &authed, "fsck-probe");
harness
.git(
&harness.dir.path().join("fsck-probe"),
&["fsck", "--strict", "--no-progress"],
)
.expect_ok("a git command in the http suite");
assert_same_graph(
"http round trip",
&server_side,
&object_graph(&harness, &clone),
);
}
// ── the refusals ────────────────────────────────────────────────────────────
/// **`allow_anonymous: false` shuts the HTTP door too.**
///
/// The switch this test was written for: it was found stubbed to `false` on
/// one path today and served strangers a full clone of every public repository.
/// One field in `ServeConfig` feeds both listeners, and this asserts the HTTP
/// end of it — with the *live* half beside it, because a test that only ever
/// saw the door shut would pass against a transport that never worked at all.
#[test]
fn the_anonymous_switch_governs_http_exactly_as_it_governs_ssh() {
// Open: a stranger clones.
let open = harness(HashKind::Sha1, Visibility::Public);
let work = open.dir.path().join("work");
seed(&open, &work);
open.git(
&work,
&["push", "-q", &open.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
open.git_http(
open.dir.path(),
&[
"clone",
"-q",
&open.http_url("team", "project"),
open.dir.path().join("open").to_str().expect("utf-8"),
],
)
.expect_ok("a git command in the http suite");
// Shut: the same repository, the same request, refused.
let shut = Harness::start_with_options(
"team",
"project",
HashKind::Sha1,
HarnessOptions {
http: true,
visibility: Visibility::Public,
allow_anonymous: false,
..HarnessOptions::default()
},
);
let shut_work = shut.dir.path().join("work");
seed(&shut, &shut_work);
shut.git(
&shut_work,
&["push", "-q", &shut.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let refused = shut.git_http(
shut.dir.path(),
&[
"clone",
"-q",
&shut.http_url("team", "project"),
shut.dir.path().join("shut").to_str().expect("utf-8"),
],
);
refused.expect_failure("--no-anonymous must refuse a stranger over HTTP as it does over SSH");
assert!(
!shut.dir.path().join("shut/.git").exists()
|| object_graph(&shut, &shut.dir.path().join("shut"))
.iter()
.all(|line| line.starts_with("ref ")),
"the refused clone left objects behind, so something was served"
);
}
/// **Anonymous may never push**, on HTTP as on SSH: rule 0 gives `Read` only.
#[test]
fn anonymous_cannot_push_over_http() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let tip_before = stored_tip(&harness);
commit(&harness, &work, "anonymous-attempt");
harness
.git_http(
&work,
&["push", &harness.http_url("team", "project"), "main"],
)
.expect_failure("an ANONYMOUS PUSH to a public repository");
assert_eq!(
stored_tip(&harness),
tip_before,
"the refused push moved the stored ref anyway"
);
}
/// A token whose principal holds only `Read` may fetch and may **not** push.
///
/// Both directions on one credential, which is the assertion that a token
/// carries no authority of its own: the same secret that succeeds at reading
/// fails at writing, so what decided the outcome was the grant.
#[test]
fn a_read_only_tokens_holder_fetches_and_cannot_push() {
let harness = harness(HashKind::Sha1, Visibility::Private);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let path = RepoRef::new("team", "project").expect("legal");
harness
.stores
.auth()
.add_principal(Principal::new("reader", "reader", PrincipalKind::User).expect("legal"))
.expect("add");
harness
.stores
.auth()
.grant(Grant::repo("reader", path, Level::Read))
.expect("grant read");
let token = harness.mint_token("reader");
let url = harness.http_url_with_token("team", "project", &token);
let clone = harness.dir.path().join("reader-clone");
harness
.git_http(
harness.dir.path(),
&["clone", "-q", &url, clone.to_str().expect("utf-8")],
)
.expect_ok("a git command in the http suite");
assert_same_graph(
"read-only token clone",
&server_graph(&harness, &url, "reader-probe"),
&object_graph(&harness, &clone),
);
let tip_before = stored_tip(&harness);
commit(&harness, &clone, "reader-attempt");
harness
.git_http(&clone, &["push", &url, "main"])
.expect_failure("a READ-ONLY token must not push");
assert_eq!(stored_tip(&harness), tip_before, "the refused push landed");
}
/// A revoked token stops working immediately, and the live half beside it.
#[test]
fn a_revoked_token_stops_working() {
let harness = harness(HashKind::Sha1, Visibility::Private);
let work = harness.dir.path().join("work");
seed(&harness, &work);
let token = harness.mint_token("tester");
let url = harness.http_url_with_token("team", "project", &token);
harness
.git(&work, &["push", "-q", &url, "--mirror"])
.expect_ok("a git command in the http suite");
harness
.git_http(
harness.dir.path(),
&[
"clone",
"-q",
&url,
harness.dir.path().join("before").to_str().expect("utf-8"),
],
)
.expect_ok("a git command in the http suite");
let hash = gunnar_core::token_hash(&token);
harness
.stores
.auth()
.revoke_token(&hash)
.expect("revoke")
.expect("it was registered");
harness
.git_http(
harness.dir.path(),
&[
"clone",
"-q",
&url,
harness.dir.path().join("after").to_str().expect("utf-8"),
],
)
.expect_failure("a REVOKED token must not clone");
}
/// **A revoked token cannot push either.**
///
/// `a_revoked_token_stops_working` revokes and then re-*clones*. Read and write
/// are different verbs down different code paths — upload-pack against
/// receive-pack — and a credential check that was dropped on the write side
/// would leave that test green while a revoked token kept its ability to
/// rewrite history. The destructive verb is the one worth asserting.
///
/// The tip is read out of the store before and after, because "the push
/// command failed" and "the push did not land" are not the same claim: a push
/// can be reported as failed after the reference has already moved.
#[test]
fn a_revoked_token_cannot_push_either() {
let harness = harness(HashKind::Sha1, Visibility::Private);
let work = harness.dir.path().join("work");
seed(&harness, &work);
let token = harness.mint_token("tester");
let url = harness.http_url_with_token("team", "project", &token);
// The acceptance half: this token pushes, while it is live.
harness
.git(&work, &["push", "-q", &url, "--mirror"])
.expect_ok("a git command in the http suite");
let tip_before = stored_tip(&harness);
assert!(
tip_before.is_some(),
"the seeded push left no tip, so the comparison below would be vacuous"
);
// A second commit, so the revoked push has something real to carry.
std::fs::write(work.join("after-revocation.txt"), "new work\n").expect("write");
harness
.git(&work, &["add", "after-revocation.txt"])
.expect_ok("a git command in the http suite");
harness
.git(
&work,
&["commit", "-q", "-m", "work done after the token died"],
)
.expect_ok("a git command in the http suite");
let local_tip = harness
.git(&work, &["rev-parse", "HEAD"])
.expect_ok("a git command in the http suite")
.stdout
.clone();
assert_ne!(
Some(local_tip.trim().to_owned()),
tip_before,
"the new commit did not move HEAD, so the push would be a no-op"
);
let hash = gunnar_core::token_hash(&token);
harness
.stores
.auth()
.revoke_token(&hash)
.expect("revoke")
.expect("it was registered");
harness
.git_http(
&work,
&["push", "-q", &url, "refs/heads/main:refs/heads/main"],
)
.expect_failure("a REVOKED token must not push");
assert_eq!(
stored_tip(&harness),
tip_before,
"the push by a revoked token was reported as failed but moved the reference anyway"
);
}
/// A token that was never registered must reach nothing, on the write path as
/// well as the read one.
///
/// The credential is well formed — it carries gunnar's own token prefix — so
/// this is the ACL refusing an unknown secret and not a parser refusing a
/// malformed header.
#[test]
fn an_unregistered_token_cannot_clone_or_push() {
let harness = harness(HashKind::Sha1, Visibility::Private);
let work = harness.dir.path().join("work");
seed(&harness, &work);
let good = harness.mint_token("tester");
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let tip_before = stored_tip(&harness);
// The acceptance half, so this is not a server refusing every token.
let good_url = harness.http_url_with_token("team", "project", &good);
harness
.git_http(
harness.dir.path(),
&[
"clone",
"-q",
&good_url,
harness.dir.path().join("good").to_str().expect("utf-8"),
],
)
.expect_ok("a git command in the http suite");
// Never minted by this server, and shaped exactly like one that was.
let forged = format!("{}{}", "gunnar_pat_", "A".repeat(43));
let forged_url = harness.http_url_with_token("team", "project", &forged);
harness
.git_http(
harness.dir.path(),
&[
"clone",
"-q",
&forged_url,
harness.dir.path().join("forged").to_str().expect("utf-8"),
],
)
.expect_failure("an UNREGISTERED token must not clone");
harness
.git_http(
&work,
&["push", "-q", &forged_url, "refs/heads/main:refs/heads/main"],
)
.expect_failure("an UNREGISTERED token must not push");
assert_eq!(
stored_tip(&harness),
tip_before,
"the push by an unregistered token moved the reference"
);
}
/// A private repository and one that does not exist look the same to a
/// stranger, and both invite authentication rather than reporting absence.
///
/// The anonymous-read property, stated for SSH, on HTTP. Measured with `curl`-shaped
/// requests through `git ls-remote`'s own reporting, comparing the two
/// *statuses*: a 404 on one and a 401 on the other would be an enumeration
/// oracle.
#[test]
fn a_private_repository_and_a_missing_one_are_indistinguishable_to_a_stranger() {
let harness = harness(HashKind::Sha1, Visibility::Private);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("a git command in the http suite");
let private = status_of(&harness.http_url("team", "project"));
let missing = status_of(&harness.http_url("team", "no-such-repo"));
let bad_namespace = status_of(&harness.http_url("nobody", "project"));
assert_eq!(
private, missing,
"a private repository and a missing one must answer identically; got {private} and \
{missing}, which is an enumeration oracle"
);
assert_eq!(private, bad_namespace);
assert_eq!(
private, 401,
"an unauthenticated caller must be invited to authenticate, whatever the truth is"
);
// The live half: with a credential, the private one is served. Without
// this, a server that answered 401 to everything would pass above.
let token = harness.mint_token("tester");
assert_eq!(
status_of(&harness.http_url_with_token("team", "project", &token)),
200
);
// …and a *missing* repository, authenticated, is a 404 rather than a 401:
// the caller already knows they are somebody.
assert_eq!(
status_of(&harness.http_url_with_token("team", "no-such-repo", &token)),
404
);
}
/// **The advertisement's opening bytes, per protocol version.**
///
/// `git http-backend` 2.53.0 opens a v0 advertisement with
/// `001e# service=git-upload-pack` + flush and a v2 one with `000eversion 2`
/// and no preamble at all. gunnar matches that byte for byte.
///
/// # This test exists because the obvious one was blind
///
/// The first attempt asserted the difference only through `git clone`, on the
/// reasoning that a v2 client would parse the preamble as a capability line.
/// **That reasoning was wrong.** Measured 2026-08-03 by sending the preamble on
/// a v2 response deliberately: `git 2.53.0` clones perfectly, because
/// `remote-curl.c`'s `discover_refs` consumes a leading `# service=` line
/// whatever version follows it. So no clone test can ever fail on this, and a
/// suite that relied on one would be recording a byte-level property it could
/// not measure.
///
/// The property is still worth holding — thirty bytes of divergence from the
/// reference server is thirty bytes for some other client to disagree about —
/// so it is asserted where it can actually fail: on the bytes.
#[test]
fn the_advertisement_opens_with_the_bytes_git_http_backend_opens_with() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("seeding the server over ssh");
let addr = harness.http_addr.expect("the http listener").to_string();
let path = "/team/project.git/info/refs?service=git-upload-pack";
let (status, v0) = request_with(&addr, path, None, None);
assert_eq!(status, 200);
let v0_body = body_of(&v0);
assert!(
v0_body.starts_with("001e# service=git-upload-pack\n0000"),
"the v0 advertisement must open with the service preamble and a flush; \
it opened {:?}",
&v0_body[..v0_body.len().min(64)]
);
let (status, v2) = request_with(&addr, path, None, Some("version=2"));
assert_eq!(status, 200);
let v2_body = body_of(&v2);
assert!(
v2_body.starts_with("000eversion 2\n"),
"the v2 advertisement must open with `version 2` and NO preamble; it \
opened {:?}",
&v2_body[..v2_body.len().min(64)]
);
assert!(
!v2_body.starts_with("001e# service="),
"a v2 advertisement must not carry the v0 preamble"
);
// Both carry the advertisement content type, which is the same string for
// both versions — asserted so a future split of the two paths cannot drop
// it on one of them.
for (version, response) in [("v0", &v0), ("v2", &v2)] {
assert!(
response
.to_ascii_lowercase()
.contains("content-type: application/x-git-upload-pack-advertisement"),
"{version}: wrong content type in {response:?}"
);
}
}
/// **The v0 advertisement offers `multi_ack_detailed` and `no-done`.**
///
/// Not a style assertion. A v0 client picks its ACK mode from this list, and
/// offered neither it falls back to single-ACK, in which a bare `ACK <oid>` is
/// terminal. Over a connected session that is right and SSH has always worked.
/// Over stateless-rpc it cannot be: the round ends with the HTTP response, the
/// client opens a fresh `POST` carrying `want` and `done` and no `have`s, and
/// then reads that response as a raw sideband packfile — while the server,
/// seeing no commons, correctly opens it with `NAK`. git demuxes the `N` as a
/// band number and dies with `bad band #78`.
///
/// MEASURED 2026-08-05: `git fetch http://…` exited **128** against a
/// gunnar whose advertisement lacked these two, and **0** against `git
/// http-backend` serving the identical repository. See
/// [`a_multi_round_fetch_over_http_completes_on_both_versions`], which is the
/// end-to-end guard; this one names the cause, so a regression reports which
/// capability went missing rather than only that a fetch broke.
#[test]
fn the_v0_advertisement_offers_the_ack_mode_stateless_rpc_needs() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("seeding the server over ssh");
let addr = harness.http_addr.expect("the http listener").to_string();
let (status, response) = request_with(
&addr,
"/team/project.git/info/refs?service=git-upload-pack",
None,
None,
);
assert_eq!(status, 200);
let body = body_of(&response);
// The capability list rides on the FIRST ref line, after a NUL. Reading it
// off the whole body would let a capability advertised anywhere at all pass
// — including inside a ref name — so the line is isolated first.
let caps = body
.split('\0')
.nth(1)
.expect("the v0 capability list rides after a NUL on the first ref line")
.lines()
.next()
.expect("a capability line");
for capability in ["multi_ack_detailed", "no-done"] {
assert!(
caps.split_whitespace().any(|c| c == capability),
"the v0 advertisement must offer `{capability}`, or stock git \
negotiates in single-ACK mode and cannot complete a multi-round \
fetch over stateless-rpc; it offered: {caps:?}"
);
}
// Anti-vacuity: if the split above ever stopped finding the capability
// list, `caps` would be some other string and the loop would fail loudly
// rather than pass — but an EMPTY one would also fail, and this says which.
assert!(
caps.split_whitespace().count() >= 5,
"the capability list parsed out to {caps:?}, which is too short to be one"
);
}
/// **A fetch that really negotiates, over HTTP, on both protocol versions.**
///
/// The difference from
/// [`an_incremental_fetch_over_http_gets_the_new_commits_on_both_versions`] is
/// the fixture, and the fixture is the whole test. That one adds three commits
/// to a fresh clone: the client's first `have` is already the answer, the
/// server is `ready` on the first round, and the exchange is over before
/// stateless-rpc has to carry any state at all. It passed for the entire time
/// v0 multi-round fetch over HTTP was broken.
///
/// This one resets the client **fifty commits back** and deletes its
/// reflog and remote-tracking refs, so `git` must send `have` batches and the
/// server must answer them across a boundary that, over HTTP, is a whole
/// separate request. That is the only shape in which the ACK mode matters.
///
/// # The depth is load-bearing, and 30 was not enough
///
/// Written first at 40 commits and `HEAD~30`, this test **passed against the
/// broken server**. `git` sends its first `have` batch and then, if it has run
/// out of history to offer, appends `done` to the *same* request — so the
/// whole negotiation fits in one `POST`, the terminal ACK and the packfile
/// come back together, and the second request that stateless-rpc actually
/// breaks on never happens. Only a client left holding **more history than it
/// will offer in one batch** flushes without `done` and forces the round trip.
/// At 100 commits and `HEAD~50` it fails on the unfixed server with the
/// production symptom, `fetch-pack: protocol error: bad band #78`. Do not trim
/// the fixture: shortening it does not make this test faster, it makes it
/// blind, and it was blind once already.
///
/// The assertion is the ref tip and the object graph, never the exit code
/// alone — though the exit code is checked too, because the failure this
/// guards against was a hard `fatal:` and a silent one would be worse.
#[test]
fn a_multi_round_fetch_over_http_completes_on_both_versions() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
// Deep enough that git cannot settle it inside its first `have` batch,
// which is 16. A fixture shorter than that is the hollow one above.
for index in 0..100 {
commit(&harness, &work, &format!("deep-{index}"));
}
let url = harness.url("team", "project");
harness
.git(&work, &["push", "-q", &url, "--mirror"])
.expect_ok("seeding the server over ssh");
let server_tip = rev_parse(&harness, &work, "main");
for (flag, number) in VERSIONS {
let clone = harness.dir.path().join(format!("negotiate-v{number}"));
harness
.git_http(
harness.dir.path(),
&[
"-c",
&format!("protocol.version={flag}"),
"clone",
"-q",
&harness.http_url("team", "project"),
clone.to_str().expect("utf-8"),
],
)
.expect_ok("a git command in the http suite");
// Wind the client back and take away every other way it could know the
// history it just cloned. Without the reflog expiry and the `gc`, the
// objects are still there and `git` short-circuits the negotiation.
harness
.git(&clone, &["reset", "-q", "--hard", "HEAD~50"])
.expect_ok("winding the client back");
// `remote remove`, not `update-ref -d` over the tracking refs one by
// one: that leaves `refs/remotes/origin/HEAD` as a symref pointing at a
// ref that no longer exists, which `git fsck --strict` reports as an
// invalid pointer — a failure of the cleanup, in a test whose whole
// point is that `fsck` is clean afterwards.
harness
.git(&clone, &["remote", "remove", "origin"])
.expect_ok("dropping the remote-tracking refs");
harness
.git(&clone, &["reflog", "expire", "--all", "--expire=now"])
.expect_ok("expiring the reflog");
harness
.git(&clone, &["gc", "-q", "--prune=now"])
.expect_ok("pruning the wound-back history");
let before = rev_parse(&harness, &clone, "HEAD");
assert_ne!(
before, server_tip,
"protocol v{number}: the client was not actually wound back, so the \
fetch below has nothing to negotiate and this test proves nothing"
);
harness
.git_http(
&clone,
&[
"-c",
&format!("protocol.version={flag}"),
"-c",
"transfer.fsckObjects=true",
"fetch",
"-q",
&harness.http_url("team", "project"),
"+refs/heads/main:refs/heads/fetched",
],
)
.expect_ok("a multi-round fetch over http");
assert_eq!(
rev_parse(&harness, &clone, "refs/heads/fetched"),
server_tip,
"protocol v{number}: the negotiated fetch did not land the server's tip"
);
// The ref is not the objects. `fsck` walks what actually arrived.
harness
.git(&clone, &["fsck", "--full", "--strict", "--no-progress"])
.expect_ok("fsck after a negotiated http fetch");
}
}
/// The dumb protocol is refused with a sentence, not a 404.
#[test]
fn the_dumb_protocol_is_refused_by_name() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let (status, body) = get(&harness, "/team/project.git/objects/info/packs");
assert_eq!(status, 403, "body was {body:?}");
assert!(
body.contains("smart HTTP"),
"the refusal must say what gunnar does serve: {body:?}"
);
}
// ── helpers that speak raw HTTP ─────────────────────────────────────────────
/// `git rev-parse <rev>`, trimmed.
fn rev_parse(harness: &Harness, repo: &Path, rev: &str) -> String {
let out = harness.git(repo, &["rev-parse", rev]);
out.expect_ok("rev-parse").stdout.trim().to_string()
}
/// The stored tip of `refs/heads/main`, read from the server's own ref store.
fn stored_tip(harness: &Harness) -> Option<String> {
let path = RepoRef::new("team", "project").expect("legal");
let repo = harness
.stores
.catalog()
.open(&path)
.expect("the repository");
let store = harness.stores.repo_store(&repo).expect("the store");
// Same `GitOps::refs` + `RefRow::oid` reading as above: a symbolic row is
// `oid: None`, which maps to `None` here — same answer `resolve` gave.
let rows = gunnar_store::git_storage_trait::GitOps::refs(store.serve()).expect("read the refs");
rows.iter()
.find(|row| row.name == "refs/heads/main")
.and_then(|row| row.oid.as_ref())
.map(|raw| {
gix_hash::ObjectId::try_from(raw.as_slice())
.expect("a well-sized oid")
.to_string()
})
}
/// The HTTP status of the advertisement for a clone URL.
fn status_of(url: &str) -> u16 {
// `<scheme>://[user:pass@]host/path` → the path, and the credentials.
let rest = url.strip_prefix("http://").expect("an http url");
let (authority, path) = rest.split_once('/').expect("a path");
let (credentials, host) = match authority.split_once('@') {
Some((credentials, host)) => (Some(credentials.to_string()), host),
None => (None, authority),
};
request(
host,
&format!("/{path}/info/refs?service=git-upload-pack"),
credentials.as_deref(),
)
.0
}
fn get(harness: &Harness, path: &str) -> (u16, String) {
let addr = harness.http_addr.expect("the http listener");
request(&addr.to_string(), path, None)
}
/// Everything after the header/body separator, de-chunked.
///
/// The advertisement arrives with `Transfer-Encoding: chunked`, which is itself
/// worth noticing: gunnar streams the response rather than buffering it and
/// sending a `Content-Length`, so a clone of a large repository starts moving
/// before the last object is written. A test that read the raw bytes would be
/// reading chunk headers.
fn body_of(response: &str) -> String {
let (head, body) = response
.split_once("\r\n\r\n")
.expect("a response with a body");
if !head
.to_ascii_lowercase()
.contains("transfer-encoding: chunked")
{
return body.to_string();
}
let mut out = String::new();
let mut rest = body;
while let Some((size, tail)) = rest.split_once("\r\n") {
let Ok(size) = usize::from_str_radix(size.trim(), 16) else {
break;
};
if size == 0 || tail.len() < size {
break;
}
out.push_str(&tail[..size]);
rest = tail[size..].strip_prefix("\r\n").unwrap_or(&tail[size..]);
}
out
}
/// A minimal HTTP/1.1 GET.
///
/// The writer is `common::http_request` — shared with the scrape endpoint's
/// suite rather than copied into each (one implementation); see it for why
/// there is no HTTP client crate in the dev-dependencies.
fn request(host: &str, path: &str, credentials: Option<&str>) -> (u16, String) {
request_with(host, path, credentials, None)
}
/// The same, with an optional `Git-Protocol` header — the one thing that
/// selects protocol v2 over HTTP.
fn request_with(
host: &str,
path: &str,
credentials: Option<&str>,
git_protocol: Option<&str>,
) -> (u16, String) {
common::http_request("GET", host, path, credentials, git_protocol)
}
// ───────────────────────────────────────────────────────────────────────────
// The request id (the half HTTP never had)
// ───────────────────────────────────────────────────────────────────────────
/// The value of `x-gunnar-request-id` in a raw HTTP/1.1 response, if any.
fn request_id_header(response: &str) -> Option<String> {
common::http_header(response, "x-gunnar-request-id")
}
/// **Every HTTP response names the log line that produced it — including every
/// refusal.**
///
/// SSH has had a connection id on a span since the observability work. HTTP had
/// nothing: its
/// lines named a principal here, a path there, a repository somewhere else, and
/// the `git.upload_pack.served` line `gunnar-cli` emits for an HTTP transfer
/// named none of them. An operator holding a client's `403` had no way to find
/// the line that produced it.
///
/// Asserted on **four** responses that take four different paths out of
/// `call` — a served advertisement, a route refusal, an auth refusal and an
/// authorisation refusal — because the header is stamped once on the way out
/// and the claim is that no arm can escape it. And the four ids must all
/// DIFFER: one span per request, not per connection, or a keep-alive client's
/// clone and the `401` before it would file under one id.
///
/// Seen red: with the stamp moved from `call` into `streaming` (where it would
/// naturally have gone), the three refusals come back with no header at all and
/// this fails naming which one.
#[test]
fn every_http_response_carries_a_request_id_and_no_two_requests_share_one() {
let harness = harness(HashKind::Sha1, Visibility::Public);
let work = harness.dir.path().join("work");
seed(&harness, &work);
harness
.git(
&work,
&["push", "-q", &harness.url("team", "project"), "--mirror"],
)
.expect_ok("seeding the server over ssh");
let addr = harness.http_addr.expect("the http listener").to_string();
let cases: [(&str, &str, Option<&str>, u16); 4] = [
(
"a served advertisement",
"/team/project.git/info/refs?service=git-upload-pack",
None,
200,
),
("a route refusal", "/not/a/git/route", None, 404),
(
"an auth refusal",
"/team/project.git/info/refs?service=git-upload-pack",
Some("gunnar:not-a-token"),
401,
),
(
"an authorisation refusal",
"/team/private.git/info/refs?service=git-receive-pack",
None,
401,
),
];
let mut seen: Vec<String> = Vec::new();
for (what, path, credentials, expected) in cases {
let (status, response) = request_with(&addr, path, credentials, None);
assert_eq!(
status,
expected,
"{what} came back {status}, so this case is not exercising the path it names:\n{}",
response.chars().take(400).collect::<String>()
);
let id = request_id_header(&response).unwrap_or_else(|| {
panic!(
"{what} carried NO x-gunnar-request-id. An operator holding this response \
cannot name the server line that produced it, which is the whole point:\n{}",
response.chars().take(400).collect::<String>()
)
});
// The same rendering `conn=` uses on the SSH side — `RequestId`'s
// own `Display`, so an operator greps one shape and not two.
assert!(
!id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit()),
"{what} carried a request id of {id:?}, which is not a RequestId"
);
assert!(
!seen.contains(&id),
"{what} reused request id {id}; ids are per REQUEST, and a keep-alive connection \
serves many. Already seen: {seen:?}"
);
seen.push(id);
}
assert_eq!(seen.len(), 4, "four requests, four ids: {seen:?}");
}