crates/gunnar-http/src/route.rs
//! The URLs gunnar serves over HTTP, and nothing else.
//!
//! ```text
//! GET /<path>/info/refs?service=git-upload-pack the advertisement
//! GET /<path>/info/refs?service=git-receive-pack the advertisement
//! POST /<path>/git-upload-pack one fetch round
//! POST /<path>/git-receive-pack one push
//!
//! POST /<path>/info/lfs/objects/batch the LFS batch API
//! GET /<path>/info/lfs/objects/<oid> one LFS object out
//! PUT /<path>/info/lfs/objects/<oid> one LFS object in
//! POST /<path>/info/lfs/objects/verify "did you really store it"
//! ```
//!
//! # `info/lfs` is where git-lfs looks, and it is not negotiable
//!
//! git-lfs derives its endpoint from the git remote by appending `/info/lfs`
//! (`lfshttp/endpoint_finder.go`, `NewEndpointFromCloneURL`), so a remote of
//! `http://host/team/project.git` produces
//! `http://host/team/project.git/info/lfs`, and the batch URL is that plus
//! `/objects/batch`. gunnar does not get to choose the prefix; it gets to
//! choose whether it answers there.
//!
//! **These paths contain `/objects/`, which is also the dumb protocol's
//! signature**, so they are matched *before* the dumb check below. Getting that
//! order wrong is not a subtle failure — every LFS request would come back
//! `403 gunnar serves the smart HTTP protocol only`, which is a sentence about
//! the wrong thing.
//!
//! `<path>` is the repository, `team/project.git`, and it goes through
//! [`gunnar_core::resolve_repo_path`] — the **same** sandbox the SSH exec
//! parser uses, not a second one. That matters more here than it looks: an
//! HTTP path arrives percent-encoded and with whatever a proxy left on it, and
//! a path check written afresh for URLs is a path check that has not been shot
//! at.
//!
//! # Dumb HTTP is refused, loudly
//!
//! A client that finds no smart service falls back to `GET /<path>/objects/…`,
//! the dumb protocol: static files, no negotiation, and a full download of
//! everything on every fetch. gunnar does not serve it (the no-OS server rule), and the way to say
//! so is **403 with a sentence**, not 404. A 404 sends `git` off to try the
//! dumb paths one by one and report "repository not found" at the end of it,
//! which points at the wrong thing.
use gunnar_core::{resolve_repo_path, RepoRef};
use gunnar_ssh::GitService;
/// What a request resolved to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Route {
/// The repository, already through the sandbox.
pub repo: RepoRef,
/// **The URL path that named the repository, as the client wrote it** —
/// `/team/project.git`, suffix stripped, nothing normalised.
///
/// Not the same string as `repo.key()`, and the difference is load-bearing
/// for exactly one thing: the absolute `href`s in an LFS batch response
/// have to be URLs the client can come back to. `key()` is the catalog's
/// name for the repository (`team/project`) and drops the `.git` the
/// client's remote had, so an `href` built from it would point at a URL
/// that routes to the same repository only by luck of the sandbox
/// accepting both spellings. This is what was asked for.
pub base: String,
/// Which of gunnar's HTTP surfaces the URL selected.
pub kind: Kind,
}
/// Which surface, and for the git transport which half of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
/// `GET …/info/refs?service=…`: write the advertisement and stop.
Advertisement(GitService),
/// `POST …/git-upload-pack` or `…/git-receive-pack`: one stateless-rpc
/// round.
Rpc(GitService),
/// The LFS batch API and the object transfers it hands out.
///
/// **No [`GitService`] here, deliberately.** The two git verbs are in the
/// URL; an LFS request's direction is not. `batch` carries it in the JSON
/// body (`"operation": "download" | "upload"`) and an object transfer
/// carries it in the method (`GET` reads, `PUT` writes), so the service —
/// and therefore the grant level — is decided by the handler and not by the
/// router. Putting a guessed one here is how a `PUT` would come to be
/// authorised as a read.
Lfs(Lfs),
}
/// Which LFS endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lfs {
/// `POST …/info/lfs/objects/batch`.
Batch,
/// `GET` or `PUT` `…/info/lfs/objects/<oid>`.
///
/// The oid is carried as it arrived and parsed by
/// [`gunnar_lfs::Oid::parse`] in the handler — the one parser, which is
/// also what makes the 64-lowercase-hex shape a type rather than a check
/// repeated per call site.
Object(String),
/// `POST …/info/lfs/objects/verify`.
Verify,
}
/// Why a request is not a smart-HTTP request.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RouteError {
/// A URL shape gunnar does not serve.
#[error("not a smart-HTTP git URL")]
NotGit,
/// The dumb protocol. Refused with a sentence rather than a 404.
#[error(
"gunnar serves the smart HTTP protocol only; this URL is part of the dumb \
protocol, which is a plain file listing and downloads everything on every fetch"
)]
Dumb,
/// `?service=` named something that is not a git service, or the method
/// does not match the URL.
#[error("{0} is not a git service gunnar serves over HTTP")]
UnknownService(String),
/// The path did not survive [`gunnar_core::resolve_repo_path`].
#[error("{0}")]
Path(String),
}
/// Suffixes that mean "the dumb protocol", checked before anything else so the
/// refusal is specific.
const DUMB: &[&str] = &["/objects/info/packs", "/objects/info/alternates", "/HEAD"];
/// Resolve `method`, `path` and `query` to a [`Route`].
///
/// `path` is the URL path with no query string, already percent-decoded by the
/// caller ([`crate::service`] does it) — the sandbox refuses a `%` as the
/// illegal character it is, so decoding after the check would be decoding into
/// a value nothing had validated.
pub fn resolve(
method: &http::Method,
path: &str,
query: Option<&str>,
) -> Result<Route, RouteError> {
// **Before the `/objects/` check below**, which every LFS URL would
// otherwise hit. See the module docs.
if let Some((base, rest)) = split_once_suffix(path, "/info/lfs/objects/") {
let endpoint = match (rest, method) {
("batch", &http::Method::POST) => Lfs::Batch,
("verify", &http::Method::POST) => Lfs::Verify,
("batch" | "verify", _) => return Err(RouteError::NotGit),
// Anything else in this position is an object id. It is *not*
// validated here: `Oid::parse` in the handler is the one parser
// (one implementation), and a router that pre-screened it would be a second
// opinion about what an oid is. What is checked is that it is one
// path component — `a/b` under `objects/` is not an object, and
// letting it through would hand a `/` to a store that builds paths.
(oid, &http::Method::GET) | (oid, &http::Method::PUT) if !oid.contains('/') => {
Lfs::Object(oid.to_owned())
}
_ => return Err(RouteError::NotGit),
};
return Ok(Route {
repo: sandbox(base)?,
base: base.to_owned(),
kind: Kind::Lfs(endpoint),
});
}
if let Some(base) = path.strip_suffix("/info/refs") {
if method != http::Method::GET {
return Err(RouteError::NotGit);
}
// No `?service=` is the dumb protocol's first request. `git` sends it
// only after the smart one failed, so answering it would be answering
// a fallback gunnar does not implement.
let Some(service) = query.and_then(service_parameter) else {
return Err(RouteError::Dumb);
};
return Ok(Route {
repo: sandbox(base)?,
base: base.to_owned(),
kind: Kind::Advertisement(service_by_verb(service)?),
});
}
for verb in ["git-upload-pack", "git-receive-pack"] {
if let Some(base) = path.strip_suffix(&format!("/{verb}")) {
if method != http::Method::POST {
return Err(RouteError::NotGit);
}
return Ok(Route {
repo: sandbox(base)?,
base: base.to_owned(),
kind: Kind::Rpc(service_by_verb(verb)?),
});
}
}
if path.contains("/objects/") || DUMB.iter().any(|suffix| path.ends_with(suffix)) {
return Err(RouteError::Dumb);
}
Err(RouteError::NotGit)
}
fn sandbox(base: &str) -> Result<RepoRef, RouteError> {
resolve_repo_path(base).map_err(|err| RouteError::Path(err.to_string()))
}
/// Split `path` at the **last** occurrence of `marker`, returning what is
/// before it and what is after.
///
/// The last and not the first: a repository may legally be called
/// `team/info/lfs/objects` — nothing in [`gunnar_core::resolve_repo_path`]
/// forbids those components — and splitting at the first occurrence would route
/// `/team/info/lfs/objects/x.git/info/lfs/objects/batch` to the repository
/// `team` with a nonsense endpoint. The suffix is what git-lfs appends, so the
/// suffix is what is stripped.
fn split_once_suffix<'a>(path: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
let at = path.rfind(marker)?;
Some((&path[..at], &path[at + marker.len()..]))
}
/// The value of `service=` in a query string, without pulling in a URL parser
/// for one parameter.
fn service_parameter(query: &str) -> Option<&str> {
query
.split('&')
.find_map(|pair| pair.strip_prefix("service="))
}
/// The verb → service mapping, taken from **`gunnar_ssh::SERVICES`**.
///
/// The same table the SSH dispatch reads, so the grant level a verb requires is
/// stated once (one implementation). `git-lfs-transfer` is in that table and is deliberately
/// not reachable **through `?service=`**: it is git-lfs 3.0's *pure-SSH*
/// transfer, and LFS over HTTP is a different protocol entirely (a JSON batch
/// API), not this one with a different socket under it. gunnar serves that
/// protocol at [`Kind::Lfs`], on its own URLs, and it reaches the same
/// `GitService::LfsTransfer(operation)` and therefore the same grant — from the
/// JSON body or from the HTTP method, never from a query parameter a client
/// chose.
fn service_by_verb(verb: &str) -> Result<GitService, RouteError> {
gunnar_ssh::SERVICES
.iter()
.find(|spec| spec.verb == verb)
.map(|spec| spec.service)
.filter(|service| matches!(service, GitService::UploadPack | GitService::ReceivePack))
.ok_or_else(|| RouteError::UnknownService(verb.to_owned()))
}
/// The liveness probe's path: `GET /healthz` (and `HEAD`) answers `200 ok`.
///
/// It is not a repository path (no `info/refs`, no service verb), so it cannot
/// shadow a repository, and it carries no credential and reveals nothing but
/// "the HTTP listener is up". What `cargo install gunnar` documents as its
/// first-start check.
pub const HEALTH_PATH: &str = "/healthz";
/// Whether this request is the liveness probe.
pub fn is_health(method: &http::Method, path: &str) -> bool {
path == HEALTH_PATH && (method == http::Method::GET || method == http::Method::HEAD)
}
#[cfg(test)]
mod tests {
use super::*;
use http::Method;
fn get(path: &str, query: Option<&str>) -> Result<Route, RouteError> {
resolve(&Method::GET, path, query)
}
fn post(path: &str) -> Result<Route, RouteError> {
resolve(&Method::POST, path, None)
}
#[test]
fn the_four_urls_resolve() {
let advert = get(
"/team/project.git/info/refs",
Some("service=git-upload-pack"),
)
.expect("the upload-pack advertisement");
assert_eq!(advert.repo.key(), "team/project");
assert_eq!(advert.kind, Kind::Advertisement(GitService::UploadPack));
let advert = get(
"/team/project.git/info/refs",
Some("service=git-receive-pack"),
)
.expect("the receive-pack advertisement");
assert_eq!(advert.kind, Kind::Advertisement(GitService::ReceivePack));
let rpc = post("/team/project.git/git-upload-pack").expect("the fetch rpc");
assert_eq!(rpc.kind, Kind::Rpc(GitService::UploadPack));
let rpc = post("/team/project.git/git-receive-pack").expect("the push rpc");
assert_eq!(rpc.kind, Kind::Rpc(GitService::ReceivePack));
}
/// **The four LFS URLs, and the fact that they are not the dumb protocol.**
///
/// Every one of them contains `/objects/`, which is the dumb protocol's
/// signature and is refused `403` a few lines further down `resolve`. Order
/// is the whole of the fix, so the assertion is on the resolved endpoint and
/// not merely on "it did not error".
#[test]
fn the_lfs_urls_resolve_and_are_not_mistaken_for_the_dumb_protocol() {
let base = "/team/project.git/info/lfs/objects";
let oid = "a".repeat(64);
let batch = post(&format!("{base}/batch")).expect("the batch endpoint");
assert_eq!(batch.repo.key(), "team/project");
assert_eq!(batch.kind, Kind::Lfs(Lfs::Batch));
assert_eq!(
batch.base, "/team/project.git",
"the href a client is sent back to is built from the path it used, `.git` and all"
);
assert_eq!(
post(&format!("{base}/verify"))
.expect("the verify endpoint")
.kind,
Kind::Lfs(Lfs::Verify)
);
assert_eq!(
get(&format!("{base}/{oid}"), None)
.expect("an object download")
.kind,
Kind::Lfs(Lfs::Object(oid.clone()))
);
assert_eq!(
resolve(&Method::PUT, &format!("{base}/{oid}"), None)
.expect("an object upload")
.kind,
Kind::Lfs(Lfs::Object(oid.clone()))
);
// The red half. With the LFS arm placed AFTER the `/objects/` check —
// which is where a reader adding it would naturally put it — every
// assertion above comes back `RouteError::Dumb`; and a "fix" that
// deleted the dumb check instead would pass those four and fail this
// one.
assert_eq!(
get("/team/project.git/objects/info/packs", None),
Err(RouteError::Dumb),
"a real dumb-protocol URL must still be refused, or the LFS arm is \
swallowing the check rather than preceding it"
);
}
/// The method has to match the LFS endpoint too. `GET …/batch` is not a way
/// to reach the batch handler with no body, and `DELETE …/<oid>` is not a
/// way to remove an object gunnar has no delete verb for.
#[test]
fn the_lfs_methods_must_match() {
let base = "/team/project.git/info/lfs/objects";
let oid = "b".repeat(64);
assert_eq!(get(&format!("{base}/batch"), None), Err(RouteError::NotGit));
assert_eq!(
get(&format!("{base}/verify"), None),
Err(RouteError::NotGit)
);
assert_eq!(
post(&format!("{base}/{oid}")),
Err(RouteError::NotGit),
"an object is transferred with GET or PUT; POST to it is not an endpoint"
);
assert_eq!(
resolve(&Method::DELETE, &format!("{base}/{oid}"), None),
Err(RouteError::NotGit)
);
// An oid position holding a path is not an object. Without this a
// request could hand a `/` to code that builds file paths out of it.
assert_eq!(
get(&format!("{base}/ab/cd"), None),
Err(RouteError::NotGit),
"`ab/cd` is two components, not an oid"
);
}
/// The repository half of an LFS URL goes through the **same** sandbox, and
/// the split is on the LAST `/info/lfs/objects/`, so a repository that is
/// itself called `info/lfs/objects` cannot capture the endpoint.
#[test]
fn the_lfs_split_takes_the_suffix_git_lfs_appended() {
assert!(matches!(
post("/../../etc/passwd/info/lfs/objects/batch"),
Err(RouteError::Path(_))
));
let nested = post("/team/info/lfs/objects/info/lfs/objects/batch")
.expect("a repository may be called `info/lfs/objects`");
assert_eq!(nested.repo.key(), "team/info/lfs/objects");
assert_eq!(nested.kind, Kind::Lfs(Lfs::Batch));
}
/// The method has to match the URL. A `GET` on the rpc endpoint is not a
/// way to make the server read a request body it will never be sent.
#[test]
fn the_method_must_match() {
assert_eq!(
get("/team/project.git/git-upload-pack", None),
Err(RouteError::NotGit)
);
assert_eq!(post("/team/project.git/info/refs"), Err(RouteError::NotGit));
}
#[test]
fn the_dumb_protocol_is_named_rather_than_404() {
// `info/refs` with no service is the dumb protocol's opening move.
assert_eq!(
get("/team/project.git/info/refs", None),
Err(RouteError::Dumb)
);
assert_eq!(
get("/team/project.git/objects/info/packs", None),
Err(RouteError::Dumb)
);
assert_eq!(
get("/team/project.git/objects/ab/cdef", None),
Err(RouteError::Dumb)
);
assert_eq!(get("/team/project.git/HEAD", None), Err(RouteError::Dumb));
}
/// **The sandbox is the same one.** These are `gunnar-core`'s own escape
/// cases, run through the HTTP door: if this router ever stopped calling
/// `resolve_repo_path`, every one of them would resolve.
#[test]
fn the_path_sandbox_applies_to_urls() {
for escape in [
"/../../etc/passwd",
"/team/../../root/x",
"/team/pro%2fject.git",
"/team/proj\u{0}ect.git",
"/a/b/c/d/e/f/g",
] {
let path = format!("{escape}/info/refs");
let outcome = get(&path, Some("service=git-upload-pack"));
assert!(
matches!(outcome, Err(RouteError::Path(_))),
"{escape:?} must be refused by the sandbox, got {outcome:?}"
);
}
// The live half. Without it a `sandbox()` that refused everything would
// pass the loop above and the whole transport would be dead.
assert_eq!(
get(
"/team/project.git/info/refs",
Some("service=git-upload-pack")
)
.expect("a legal path")
.repo
.key(),
"team/project"
);
// Nested namespaces are legal over SSH and must be legal here.
assert_eq!(
get(
"/team/sub/project.git/info/refs",
Some("service=git-upload-pack")
)
.expect("a nested namespace")
.repo
.key(),
"team/sub/project"
);
}
/// A service name that is not a git service is refused, including the one
/// that IS in the SSH table.
#[test]
fn only_the_two_git_services_are_reachable() {
assert!(matches!(
get(
"/team/project.git/info/refs",
Some("service=git-lfs-transfer")
),
Err(RouteError::UnknownService(_))
));
assert!(matches!(
get("/team/project.git/info/refs", Some("service=bash")),
Err(RouteError::UnknownService(_))
));
assert_eq!(
post("/team/project.git/git-lfs-transfer"),
Err(RouteError::NotGit)
);
}
#[test]
fn the_service_parameter_is_found_among_others() {
assert_eq!(
get(
"/team/project.git/info/refs",
Some("foo=1&service=git-upload-pack&bar=2")
)
.expect("found")
.kind,
Kind::Advertisement(GitService::UploadPack)
);
}
#[test]
fn healthz_is_the_liveness_probe_and_nothing_else() {
assert!(is_health(&Method::GET, "/healthz"));
assert!(is_health(&Method::HEAD, "/healthz"));
assert!(!is_health(&Method::POST, "/healthz"));
assert!(!is_health(&Method::GET, "/healthz/x"));
assert!(!is_health(&Method::GET, "/team/healthz.git/info/refs"));
}
}