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

nordisk/gunnar

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