crates/gunnar-http/tests/front_door.rs
//! **The smart-HTTP listener's other doors: LFS and every refusal**
//! (lane OSSF-SILVER-CODE, 2026-10-31).
//!
//! `tests/admission.rs` drives the git advertisement through a real bound listener; the
//! LFS batch API, the object `GET`/`PUT`/verify and the refusals in
//! front of all of them had no test that went over a socket. Each test here binds a real
//! listener on port 0 with a real in-memory control plane and asserts on what comes back
//! on the wire: the status code, the headers a client reads and the body it parses. The
//! refusals are paired with an admission wherever one exists, so a door that refused
//! everything would fail as surely as one that let everything in.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use gunnar_core::model::PrincipalKind;
use gunnar_core::{
memory_control_store, AuthTable, Grant, Level, NewRepo, Principal, RepoCatalog, RepoRef,
Visibility,
};
use gunnar_http::{Cleartext, GunnarHttp, HttpConfig};
use gunnar_lfs::{Limits, MemLfsStore, Oid};
use gunnar_ssh::seam::{BannerTransport, LfsAccess, LfsTransport, ServiceRequest, TransportError};
use gunnar_ssh::server::Dispatch;
use gunnar_ssh::Control;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::TcpStream;
/// The writer's token: a grant of `Write` on `team/project`.
const WRITER: &str = "gunnar_pat_WRITERwriterWRITERwriterWRITERwr";
/// The stranger's token: a principal with no grant on anything.
const STRANGER: &str = "gunnar_pat_STRANGERstrangerSTRANGERstrange";
/// `team/project` is Public (anonymous read), `team/secret` is Private; `writer` may
/// push to `team/project`, `stranger` exists and may do nothing.
fn control() -> Control {
let store = memory_control_store().expect("open the control store");
let auth = AuthTable::new(Arc::clone(&store)).expect("open the identity table");
let catalog = RepoCatalog::new(store).expect("open the catalog");
catalog
.create_namespace("team")
.expect("create the namespace");
let mut public = NewRepo::new(RepoRef::new("team", "project").expect("a legal path"));
public.visibility = Visibility::Public;
catalog.create(public).expect("create team/project");
catalog
.create(NewRepo::new(
RepoRef::new("team", "secret").expect("a legal path"),
))
.expect("create team/secret");
for (id, secret) in [("writer", WRITER), ("stranger", STRANGER)] {
auth.add_principal(Principal::new(id, id, PrincipalKind::User).expect("a legal id"))
.expect("add the principal");
auth.register_token(id, secret, "test", None)
.expect("register the token");
}
auth.grant(Grant::repo(
"writer",
RepoRef::new("team", "project").expect("a legal path"),
Level::Write,
))
.expect("grant write");
Control::new(auth, catalog)
}
/// An LFS transport over one in-memory store, with the limits under test.
struct MemLfs {
store: Arc<MemLfsStore>,
limits: Limits,
}
impl LfsTransport for MemLfs {
fn open_lfs(&self, _request: &ServiceRequest) -> Result<LfsAccess, TransportError> {
Ok(LfsAccess {
store: Arc::clone(&self.store) as Arc<dyn gunnar_lfs::LfsStore>,
limits: self.limits,
allowance: None,
})
}
}
/// An LFS transport whose store never opens: the shape of a repository on a disk that
/// went away after the ACL said yes.
struct BrokenLfs;
impl LfsTransport for BrokenLfs {
fn open_lfs(&self, _request: &ServiceRequest) -> Result<LfsAccess, TransportError> {
Err(TransportError::Unimplemented("the test's lfs store"))
}
}
/// What a listener is built with.
#[derive(Default)]
struct Wiring {
lfs: Option<Arc<dyn LfsTransport>>,
/// `RequireProxyTls` (the default) when false.
cleartext_allowed: bool,
anonymous_off: bool,
}
async fn serve(wiring: Wiring) -> SocketAddr {
let mut server = GunnarHttp::new(
HttpConfig {
listen: "127.0.0.1:0".parse().expect("a literal address"),
cleartext: if wiring.cleartext_allowed {
Cleartext::Allowed
} else {
Cleartext::RequireProxyTls
},
allow_anonymous: !wiring.anonymous_off,
..HttpConfig::default()
},
Dispatch::new(control()),
Arc::new(BannerTransport::default()),
);
if let Some(lfs) = wiring.lfs {
server = server.with_lfs(lfs);
}
let bound = server.bind().await.expect("bind the listener");
let addr = bound.local_addr();
tokio::spawn(async move {
let _ = bound.serve().await;
});
addr
}
/// The listener every LFS test talks to: a store with a 64-byte object ceiling and a
/// three-object batch cap, cleartext credentials allowed (a loopback test).
async fn lfs_server() -> (SocketAddr, Arc<MemLfsStore>) {
let store = Arc::new(MemLfsStore::new());
let limits = Limits {
max_object_bytes: 64,
max_batch_objects: 3,
..Limits::default()
};
let addr = serve(Wiring {
lfs: Some(Arc::new(MemLfs {
store: Arc::clone(&store),
limits,
})),
cleartext_allowed: true,
..Wiring::default()
})
.await;
(addr, store)
}
/// One answer off the wire.
#[derive(Debug)]
struct Reply {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Reply {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body)
.unwrap_or_else(|e| panic!("{e}: not JSON: {:?}", self.text()))
}
}
/// Decode a `Transfer-Encoding: chunked` body.
fn dechunk(mut raw: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
loop {
let Some(end) = raw.windows(2).position(|w| w == b"\r\n") else {
return out;
};
let size = std::str::from_utf8(&raw[..end])
.ok()
.and_then(|s| usize::from_str_radix(s.split(';').next()?.trim(), 16).ok())
.unwrap_or(0);
if size == 0 {
return out;
}
raw = &raw[end + 2..];
out.extend_from_slice(&raw[..size.min(raw.len())]);
raw = raw.get(size + 2..).unwrap_or_default();
}
}
/// Send one request (`Connection: close`) and read the whole answer.
async fn send(
addr: SocketAddr,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Reply {
let mut head = format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n");
for (k, v) in headers {
head.push_str(&format!("{k}: {v}\r\n"));
}
let chunked = headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("transfer-encoding") && v.contains("chunked"));
let mut wire = Vec::new();
match body {
Some(body) if chunked => {
wire.extend_from_slice(head.as_bytes());
wire.extend_from_slice(b"\r\n");
wire.extend_from_slice(format!("{:x}\r\n", body.len()).as_bytes());
wire.extend_from_slice(body);
wire.extend_from_slice(b"\r\n0\r\n\r\n");
}
Some(body) => {
head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
wire.extend_from_slice(head.as_bytes());
wire.extend_from_slice(body);
}
None => {
wire.extend_from_slice(head.as_bytes());
wire.extend_from_slice(b"\r\n");
}
}
let mut socket = TcpStream::connect(addr).await.expect("connect");
socket.write_all(&wire).await.expect("write the request");
let mut raw = Vec::new();
tokio::time::timeout(Duration::from_secs(20), socket.read_to_end(&mut raw))
.await
.expect("the answer ends")
.expect("read the answer");
let split = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.unwrap_or_else(|| {
panic!(
"{method} {path}: no HTTP head in {:?}",
String::from_utf8_lossy(&raw)
)
});
let head = String::from_utf8_lossy(&raw[..split]).into_owned();
let mut lines = head.lines();
let status = lines
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse().ok())
.expect("a status line");
let headers: Vec<(String, String)> = lines
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
.collect();
let rest = &raw[split + 4..];
let is_chunked = headers.iter().any(|(k, v)| {
k.eq_ignore_ascii_case("transfer-encoding") && v.eq_ignore_ascii_case("chunked")
});
let body = if is_chunked {
dechunk(rest)
} else {
rest.to_vec()
};
Reply {
status,
headers,
body,
}
}
fn bearer(secret: &str) -> String {
format!("Bearer {secret}")
}
const LFS_JSON: &str = "application/vnd.git-lfs+json";
const BATCH: &str = "/team/project.git/info/lfs/objects/batch";
async fn batch(addr: SocketAddr, auth: Option<&str>, body: serde_json::Value) -> Reply {
let bearer = auth.map(bearer);
let mut headers = vec![("Accept", LFS_JSON), ("Content-Type", LFS_JSON)];
if let Some(b) = bearer.as_deref() {
headers.push(("Authorization", b));
}
send(
addr,
"POST",
BATCH,
&headers,
Some(body.to_string().as_bytes()),
)
.await
}
/// **An object goes in and comes out**: the upload batch hands out the `PUT` and the
/// verify actions with the caller's own `Authorization`, the `PUT` stores the bytes,
/// verify says so (and refuses a wrong size), a second upload batch says "already
/// here", and the download batch and the `GET` hand the same bytes back.
#[tokio::test]
async fn an_lfs_object_goes_up_is_verified_and_comes_back_down() {
let (addr, store) = lfs_server().await;
let content = b"forty-two bytes of large file, for real!!";
let oid = Oid::of(content).to_hex();
let size = content.len();
let object = format!("/team/project.git/info/lfs/objects/{oid}");
let up = batch(
addr,
Some(WRITER),
serde_json::json!({ "operation": "upload", "transfers": ["basic"],
"objects": [{ "oid": oid, "size": size }], "hash_algo": "sha256" }),
)
.await;
assert_eq!(up.status, 200, "{}", up.text());
assert_eq!(up.header("content-type"), Some(LFS_JSON));
let answer = up.json();
assert_eq!(answer["transfer"], "basic");
assert_eq!(answer["hash_algo"], "sha256");
let actions = &answer["objects"][0]["actions"];
assert_eq!(
actions["upload"]["href"],
format!("http://localhost/team/project.git/info/lfs/objects/{oid}")
);
assert_eq!(actions["upload"]["header"]["Authorization"], bearer(WRITER));
assert_eq!(
actions["verify"]["href"],
"http://localhost/team/project.git/info/lfs/objects/verify"
);
let put = send(
addr,
"PUT",
&object,
&[("Authorization", &bearer(WRITER))],
Some(content),
)
.await;
assert_eq!(put.status, 200, "{}", put.text());
assert_eq!(
gunnar_lfs::LfsStore::size_of(&*store, &Oid::parse(&oid).unwrap()).unwrap(),
Some(size as u64),
"the PUT reached the store"
);
let verify = |claimed: usize| {
let body = serde_json::json!({ "oid": oid, "size": claimed }).to_string();
let auth = bearer(WRITER);
async move {
send(
addr,
"POST",
"/team/project.git/info/lfs/objects/verify",
&[("Authorization", &auth), ("Accept", LFS_JSON)],
Some(body.as_bytes()),
)
.await
}
};
assert_eq!(verify(size).await.status, 200);
let wrong = verify(size + 1).await;
assert_eq!(wrong.status, 422, "{}", wrong.text());
assert!(
wrong.json()["message"]
.as_str()
.unwrap()
.contains(&format!("is {size} bytes")),
"{}",
wrong.text()
);
let again = batch(
addr,
Some(WRITER),
serde_json::json!({ "operation": "upload", "objects": [{ "oid": oid, "size": size }] }),
)
.await;
let again = again.json();
assert!(
again["objects"][0].get("actions").is_none() && again["objects"][0].get("error").is_none(),
"an object the server holds is answered with neither actions nor an error: {again}"
);
// The download batch is anonymous: team/project is Public.
let down = batch(
addr,
None,
serde_json::json!({ "operation": "download", "objects": [{ "oid": oid, "size": size }] }),
)
.await;
assert_eq!(down.status, 200, "{}", down.text());
let href = down.json()["objects"][0]["actions"]["download"]["href"]
.as_str()
.expect("a download href")
.to_owned();
assert!(href.ends_with(&object), "{href}");
let got = send(addr, "GET", &object, &[], None).await;
assert_eq!(got.status, 200, "{}", got.text());
assert_eq!(got.header("content-type"), Some("application/octet-stream"));
assert_eq!(
got.header("content-length"),
Some(size.to_string().as_str())
);
assert_eq!(got.body, content);
}
/// **What the batch API refuses, and how.** A sha1 hash algorithm, a transfer list
/// without `basic`, an unknown operation, a batch over the cap, a body that is not JSON
/// and an `Accept` that is not LFS's: each is refused with the status a client acts on and
/// the LFS error document, never `200` with nothing in it.
#[tokio::test]
async fn the_batch_api_refuses_what_it_cannot_serve() {
let (addr, _store) = lfs_server().await;
let oid = Oid::of(b"x").to_hex();
let sha1 = batch(
addr,
None,
serde_json::json!({ "operation": "download", "objects": [], "hash_algo": "sha1" }),
)
.await;
assert_eq!(sha1.status, 422);
assert!(sha1.json()["message"].as_str().unwrap().contains("sha256"));
assert!(sha1.json()["request_id"]
.as_str()
.is_some_and(|id| !id.is_empty()));
let multipart = batch(
addr,
None,
serde_json::json!({ "operation": "download", "transfers": ["multipart-basic"], "objects": [] }),
)
.await;
assert_eq!(multipart.status, 422);
assert!(
multipart.text().contains("multipart-basic"),
"{}",
multipart.text()
);
let many: Vec<_> = (0..4)
.map(|i| serde_json::json!({ "oid": Oid::of(&[i]).to_hex(), "size": 1 }))
.collect();
let over = batch(
addr,
None,
serde_json::json!({ "operation": "download", "objects": many }),
)
.await;
assert_eq!(over.status, 422);
assert!(over.text().contains("at most 3 objects"), "{}", over.text());
// Per object, inside a 200: an oid that is not one, an object that is not here, and
// an upload over the size ceiling.
let mixed = batch(
addr,
Some(WRITER),
serde_json::json!({ "operation": "upload", "objects": [
{ "oid": "not-an-oid", "size": 1 },
{ "oid": oid, "size": 65 },
] }),
)
.await;
assert_eq!(mixed.status, 200, "{}", mixed.text());
let objects = mixed.json()["objects"].clone();
assert_eq!(objects[0]["error"]["code"], 422, "{objects}");
assert_eq!(objects[1]["error"]["code"], 413, "{objects}");
let missing = batch(
addr,
None,
serde_json::json!({ "operation": "download", "objects": [{ "oid": oid, "size": 1 }] }),
)
.await;
let missing = missing.json();
assert_eq!(missing["objects"][0]["error"]["code"], 404, "{missing}");
assert!(missing["objects"][0].get("actions").is_none());
let garbage = send(
addr,
"POST",
BATCH,
&[("Accept", LFS_JSON)],
Some(b"{ this is not json"),
)
.await;
assert_eq!(garbage.status, 400);
assert!(garbage.text().contains("not valid"), "{}", garbage.text());
let html = send(
addr,
"POST",
BATCH,
&[("Accept", "text/html")],
Some(br#"{"operation":"download","objects":[]}"#),
)
.await;
assert_eq!(html.status, 406);
assert!(html.text().contains(LFS_JSON), "{}", html.text());
let no_op = batch(addr, None, serde_json::json!({ "objects": [] })).await;
assert_eq!(
no_op.status, 400,
"a batch with no operation authorises as nothing"
);
}
/// **Who may move objects.** An anonymous upload batch is a `401` carrying both
/// `LFS-Authenticate` and `WWW-Authenticate`; a stranger's upload is a `404` (the fused
/// sentence); a private repository's download is a `401` anonymously; the writer is
/// admitted. Without a backend the listener says `501`, and a store that will not open
/// after the ACL said yes is a `503`.
#[tokio::test]
async fn lfs_access_follows_the_acl_and_the_wiring() {
let (addr, _store) = lfs_server().await;
let upload = serde_json::json!({ "operation": "upload", "objects": [] });
let anonymous = batch(addr, None, upload.clone()).await;
assert_eq!(anonymous.status, 401, "{}", anonymous.text());
assert_eq!(
anonymous.header("lfs-authenticate"),
Some("Basic realm=\"gunnar\"")
);
assert_eq!(
anonymous.header("www-authenticate"),
Some("Basic realm=\"gunnar\"")
);
let stranger = batch(addr, Some(STRANGER), upload.clone()).await;
assert_eq!(stranger.status, 404, "{}", stranger.text());
assert!(stranger.header("lfs-authenticate").is_none());
assert_eq!(batch(addr, Some(WRITER), upload.clone()).await.status, 200);
let private = send(
addr,
"POST",
"/team/secret.git/info/lfs/objects/batch",
&[("Accept", LFS_JSON)],
Some(br#"{"operation":"download","objects":[]}"#),
)
.await;
assert_eq!(private.status, 401, "{}", private.text());
let anonymous_put = send(
addr,
"PUT",
&format!(
"/team/project.git/info/lfs/objects/{}",
Oid::of(b"y").to_hex()
),
&[],
Some(b"y"),
)
.await;
assert_eq!(
anonymous_put.status, 401,
"a PUT is an upload whatever the batch said"
);
let unwired = serve(Wiring {
cleartext_allowed: true,
..Wiring::default()
})
.await;
let no_lfs = batch(unwired, None, upload.clone()).await;
assert_eq!(no_lfs.status, 501);
assert!(
no_lfs.text().contains("no LFS object store"),
"{}",
no_lfs.text()
);
let broken = serve(Wiring {
lfs: Some(Arc::new(BrokenLfs)),
cleartext_allowed: true,
..Wiring::default()
})
.await;
let down = batch(
broken,
None,
serde_json::json!({ "operation": "download", "objects": [] }),
)
.await;
assert_eq!(down.status, 503, "{}", down.text());
let object = format!(
"/team/project.git/info/lfs/objects/{}",
Oid::of(b"z").to_hex()
);
assert_eq!(send(broken, "GET", &object, &[], None).await.status, 503);
}
/// **The object doors refuse before they store.** An oid that is not one is a `422`
/// on `GET`, `PUT` and verify alike; a `PUT` with no `Content-Length` is a `411`; one
/// declaring more than the ceiling is a `413` before a byte is read; bytes that do not
/// hash to the oid are not committed; a `GET` and a verify of an object the server does
/// not hold are `404`s.
#[tokio::test]
async fn the_object_doors_refuse_before_they_store() {
let (addr, store) = lfs_server().await;
let auth = bearer(WRITER);
let auth = [("Authorization", auth.as_str())];
let bad = send(
addr,
"GET",
"/team/project.git/info/lfs/objects/abc",
&[],
None,
)
.await;
assert_eq!(bad.status, 422, "{}", bad.text());
let bad_put = send(
addr,
"PUT",
"/team/project.git/info/lfs/objects/abc",
&auth,
Some(b"abc"),
)
.await;
assert_eq!(bad_put.status, 422, "{}", bad_put.text());
let bad_verify = send(
addr,
"POST",
"/team/project.git/info/lfs/objects/verify",
&auth,
Some(br#"{"oid":"abc","size":3}"#),
)
.await;
assert_eq!(bad_verify.status, 422, "{}", bad_verify.text());
let not_json = send(
addr,
"POST",
"/team/project.git/info/lfs/objects/verify",
&auth,
Some(b"[]"),
)
.await;
assert_eq!(not_json.status, 400, "{}", not_json.text());
let content = b"small";
let oid = Oid::of(content).to_hex();
let object = format!("/team/project.git/info/lfs/objects/{oid}");
let chunked = send(
addr,
"PUT",
&object,
&[auth[0], ("Transfer-Encoding", "chunked")],
Some(content),
)
.await;
assert_eq!(chunked.status, 411, "{}", chunked.text());
let big = vec![7u8; 65];
let big_oid = Oid::of(&big).to_hex();
let too_big = send(
addr,
"PUT",
&format!("/team/project.git/info/lfs/objects/{big_oid}"),
&auth,
Some(&big),
)
.await;
assert_eq!(too_big.status, 413, "{}", too_big.text());
assert!(too_big.text().contains("at most 64"), "{}", too_big.text());
let lie = send(addr, "PUT", &object, &auth, Some(b"other")).await;
assert!(
lie.status >= 400 && lie.status < 500,
"bytes that are not the oid's are refused: {} {}",
lie.status,
lie.text()
);
let oid_value = Oid::parse(&oid).unwrap();
assert_eq!(
gunnar_lfs::LfsStore::size_of(&*store, &oid_value).unwrap(),
None,
"a refused upload left nothing behind"
);
let missing = send(addr, "GET", &object, &[], None).await;
assert_eq!(missing.status, 404, "{}", missing.text());
let unverified = send(
addr,
"POST",
"/team/project.git/info/lfs/objects/verify",
&auth,
Some(format!(r#"{{"oid":"{oid}","size":5}}"#).as_bytes()),
)
.await;
assert_eq!(unverified.status, 404, "{}", unverified.text());
}
/// **The front door's own refusals.** `/healthz` answers without a credential; a dumb
/// HTTP request is a `403` and a path that is not git a `404`; an `Authorization` that is
/// neither Basic nor Bearer is a `401`, an unknown token a `401`, and any token over a
/// plain connection a `403` unless the operator allowed cleartext; with anonymous access
/// off, a keyless request is a `401`; a private repository is `401` anonymously and `404`
/// to a principal with no grant; a gzipped body that is not gzip is a `400`.
#[tokio::test]
async fn the_front_door_refuses_by_name() {
let strict = serve(Wiring::default()).await;
let health = send(strict, "GET", "/healthz", &[], None).await;
assert_eq!((health.status, health.text().trim()), (200, "ok"));
let refs = "/team/project.git/info/refs?service=git-upload-pack";
assert_eq!(send(strict, "GET", refs, &[], None).await.status, 200);
let dumb = send(strict, "GET", "/team/project.git/info/refs", &[], None).await;
assert_eq!(dumb.status, 403, "{}", dumb.text());
assert_eq!(
send(strict, "GET", "/nothing/here", &[], None).await.status,
404
);
let digest = send(
strict,
"GET",
refs,
&[("Authorization", "Digest abc")],
None,
)
.await;
assert_eq!(digest.status, 401, "{}", digest.text());
assert_eq!(
digest.header("www-authenticate"),
Some("Basic realm=\"gunnar\"")
);
let cleartext = send(
strict,
"GET",
refs,
&[("Authorization", &bearer(WRITER))],
None,
)
.await;
assert_eq!(cleartext.status, 403, "{}", cleartext.text());
let behind_tls = send(
strict,
"GET",
refs,
&[
("Authorization", &bearer(WRITER)),
("X-Forwarded-Proto", "https"),
],
None,
)
.await;
assert_eq!(behind_tls.status, 200, "a proxy that spoke TLS is believed");
let open = serve(Wiring {
cleartext_allowed: true,
..Wiring::default()
})
.await;
let unknown = send(
open,
"GET",
refs,
&[(
"Authorization",
"Bearer gunnar_pat_nobodyNOBODYnobodyNOBODYnobody",
)],
None,
)
.await;
assert_eq!(unknown.status, 401, "{}", unknown.text());
let secret = "/team/secret.git/info/refs?service=git-upload-pack";
assert_eq!(send(open, "GET", secret, &[], None).await.status, 401);
let stranger = send(
open,
"GET",
secret,
&[("Authorization", &bearer(STRANGER))],
None,
)
.await;
assert_eq!(stranger.status, 404, "{}", stranger.text());
let gz = send(
open,
"POST",
"/team/project.git/git-upload-pack",
&[("Content-Encoding", "gzip")],
Some(b"this is not gzip at all"),
)
.await;
assert_eq!(gz.status, 400, "{}", gz.text());
assert!(gz.text().contains("gzipped"), "{}", gz.text());
let closed = serve(Wiring {
anonymous_off: true,
..Wiring::default()
})
.await;
let keyless = send(closed, "GET", refs, &[], None).await;
assert_eq!(keyless.status, 401, "{}", keyless.text());
}