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