smart_http.rs raw

//! Read-only clone and fetch over HTTP, by running `git http-backend` as a
//! CGI program for the two endpoints the smart protocol needs. Only public
//! repositories are reachable, and only fetching is served: the endpoint
//! for pushing is never routed, and `git http-backend` refuses pushes over
//! HTTP unless a repository is explicitly configured to accept them.

use std::sync::Arc;

use anyhow::anyhow;
use poem::{
    Body, Request, Response, Result,
    error::NotFoundError,
    handler,
    http::{HeaderName, HeaderValue, StatusCode, header},
    web::{Data, Path},
};

use crate::{App, git::Process, repos::RepoName};

/// The smart protocol endpoints that are served.
#[derive(Debug, Clone, Copy)]
enum Endpoint {
    InfoRefs,
    UploadPack,
}

impl Endpoint {
    fn path(self) -> &'static str {
        match self {
            Endpoint::InfoRefs => "info/refs",
            Endpoint::UploadPack => "git-upload-pack",
        }
    }
}

#[handler]
pub async fn info_refs(
    app: Data<&Arc<App>>,
    Path(repo): Path<String>,
    req: &Request,
) -> Result<Response> {
    run_backend(&app, &repo, Endpoint::InfoRefs, req, Body::empty()).await
}

#[handler]
pub async fn upload_pack(
    app: Data<&Arc<App>>,
    Path(repo): Path<String>,
    req: &Request,
    body: Body,
) -> Result<Response> {
    run_backend(&app, &repo, Endpoint::UploadPack, req, body).await
}

async fn run_backend(
    app: &App,
    repo: &str,
    endpoint: Endpoint,
    req: &Request,
    body: Body,
) -> Result<Response> {
    let name = crate::pages::repo_name(repo)?;
    let visible_name = name.clone();
    if !app
        .blocking(move |store| Ok(store.is_public(&visible_name)))
        .await?
    {
        return Err(NotFoundError.into());
    }
    serve_backend(app, &name, endpoint, req, body).await
}

async fn serve_backend(
    app: &App,
    name: &RepoName,
    endpoint: Endpoint,
    req: &Request,
    body: Body,
) -> Result<Response> {
    let project_root = app.git.root().to_owned();
    let path_info = format!("/{}/{}", name.dir_name(), endpoint.path());
    let query = req.uri().query().unwrap_or_default().to_owned();
    let method = req.method().as_str().to_owned();
    let remote_addr = req
        .remote_addr()
        .as_socket_addr()
        .map(|addr| addr.ip().to_string());
    let headers = cgi_headers(req);
    let mut backend = app
        .git
        .start("http-backend", Some(body), move |command| {
            command
                .arg("http-backend")
                .env("GIT_PROJECT_ROOT", project_root)
                .env("PATH_INFO", path_info)
                .env("QUERY_STRING", query)
                .env("REQUEST_METHOD", method)
                .envs(headers);
            if let Some(addr) = remote_addr {
                command.env("REMOTE_ADDR", addr);
            }
        })
        .await?;
    let mut response = cgi_response(&mut backend).await?;
    response.set_body(backend.into_body());
    Ok(response)
}

// The status and headers of a CGI response: header lines up to a blank
// one, after which the body follows.
async fn cgi_response(backend: &mut Process) -> anyhow::Result<Response> {
    let mut response = Response::default();
    loop {
        let line = backend
            .read_line()
            .await?
            .ok_or_else(|| anyhow!("git http-backend ended without a response"))?;
        if line.is_empty() {
            return Ok(response);
        }
        let (key, value) = line
            .split_once(':')
            .ok_or_else(|| anyhow!("malformed CGI header from git http-backend: {line:?}"))?;
        let value = value.trim();
        if key.eq_ignore_ascii_case("Status") {
            let code = value.split_whitespace().next().unwrap_or_default();
            response.set_status(code.parse().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
        } else {
            response.headers_mut().insert(
                HeaderName::from_bytes(key.as_bytes())?,
                HeaderValue::from_str(value)?,
            );
        }
    }
}

// The request headers as CGI meta-variables: `Content-Type` and
// `Content-Length` under their own names, every other header as
// `HTTP_<NAME>`, so that the backend sees whatever it looks for (the
// `Git-Protocol` header that selects protocol v2, for one). `Proxy` is
// the exception: as `HTTP_PROXY` it would configure an outbound proxy for
// anything the backend runs.
fn cgi_headers(req: &Request) -> Vec<(String, String)> {
    let headers = req.headers();
    headers
        .keys()
        .filter_map(|name| {
            let variable = if *name == header::CONTENT_TYPE {
                "CONTENT_TYPE".to_owned()
            } else if *name == header::CONTENT_LENGTH {
                "CONTENT_LENGTH".to_owned()
            } else if name.as_str() == "proxy" {
                return None;
            } else {
                format!(
                    "HTTP_{}",
                    name.as_str().to_ascii_uppercase().replace('-', "_")
                )
            };
            let values: Vec<&str> = headers
                .get_all(name)
                .iter()
                .filter_map(|value| value.to_str().ok())
                .collect();
            if values.is_empty() {
                return None;
            }
            Some((variable, values.join(", ")))
        })
        .collect()
}