Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / ferrit / src / githttp.rs
//! Git smart-HTTP transport.
//!
//! This bridges incoming HTTP requests to `git http-backend` (git's own CGI
//! program), which implements the upload-pack (clone/fetch) and receive-pack
//! (push) protocols. Push and access to private repositories require HTTP Basic
//! authentication against Ferrit's user store.

use crate::App;
use axum::body::{Body, Bytes};
use axum::extract::{Path, RawQuery, State};
use axum::http::{HeaderMap, Method, Response, StatusCode};
use base64::Engine;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;

fn unauthorized() -> Response<Body> {
    Response::builder()
        .status(StatusCode::UNAUTHORIZED)
        .header("WWW-Authenticate", "Basic realm=\"Ferrit\"")
        .header("content-type", "text/plain")
        .body(Body::from("Authentication required\n"))
        .unwrap()
}

fn status(code: StatusCode, msg: &str) -> Response<Body> {
    Response::builder()
        .status(code)
        .header("content-type", "text/plain")
        .body(Body::from(format!("{msg}\n")))
        .unwrap()
}

/// Parse an HTTP Basic `Authorization` header into `(user, pass)`.
fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
    let val = headers.get("authorization")?.to_str().ok()?;
    let b64 = val.strip_prefix("Basic ").or_else(|| val.strip_prefix("basic "))?;
    let decoded = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?;
    let text = String::from_utf8(decoded).ok()?;
    let (u, p) = text.split_once(':')?;
    Some((u.to_string(), p.to_string()))
}

/// `endpoint` is one of: "info/refs", "git-upload-pack", "git-receive-pack".
async fn serve(
    app: Arc<App>,
    owner: String,
    repo: String,
    endpoint: &str,
    method: Method,
    headers: HeaderMap,
    query: Option<String>,
    body: Bytes,
) -> Response<Body> {
    let name = repo.trim_end_matches(".git").to_string();
    let query = query.unwrap_or_default();

    let meta = match app.store.repo(&owner, &name) {
        Some(r) => r,
        None => return status(StatusCode::NOT_FOUND, "repository not found"),
    };

    let is_push = endpoint == "git-receive-pack"
        || (endpoint == "info/refs" && query.contains("service=git-receive-pack"));
    let need_auth = is_push || meta.private;

    let mut remote_user = String::new();
    if need_auth {
        match basic_auth(&headers) {
            Some((u, p)) => match app.store.authenticate(&u, &p) {
                Some(user) if user.username.eq_ignore_ascii_case(&owner) => {
                    remote_user = user.username;
                }
                Some(_) => return status(StatusCode::FORBIDDEN, "you do not have access to this repository"),
                None => return unauthorized(),
            },
            None => return unauthorized(),
        }
    }

    let repo_dir = gitio_repo_dir(&app, &owner, &name);
    if !repo_dir.exists() {
        return status(StatusCode::NOT_FOUND, "repository not found on disk");
    }
    let project_root = app.data_dir.join("repos").join(&owner);
    let path_info = format!("/{name}.git/{endpoint}");
    let content_type = headers
        .get("content-type")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();

    let mut cmd = tokio::process::Command::new("git");
    cmd.arg("http-backend")
        .env("GIT_PROJECT_ROOT", &project_root)
        .env("GIT_HTTP_EXPORT_ALL", "1")
        .env("PATH_INFO", &path_info)
        .env("QUERY_STRING", &query)
        .env("REQUEST_METHOD", method.as_str())
        .env("CONTENT_TYPE", &content_type)
        .env("CONTENT_LENGTH", body.len().to_string())
        .env("REMOTE_USER", &remote_user)
        .env("REMOTE_ADDR", "127.0.0.1")
        .env("SERVER_PROTOCOL", "HTTP/1.1")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());

    let mut child = match cmd.spawn() {
        Ok(c) => c,
        Err(e) => return status(StatusCode::INTERNAL_SERVER_ERROR, &format!("cannot start git: {e}")),
    };

    if let Some(mut stdin) = child.stdin.take() {
        let body = body.clone();
        tokio::spawn(async move {
            let _ = stdin.write_all(&body).await;
            let _ = stdin.shutdown().await;
        });
    }

    let out = match child.wait_with_output().await {
        Ok(o) => o,
        Err(e) => return status(StatusCode::INTERNAL_SERVER_ERROR, &format!("git failed: {e}")),
    };
    if !out.status.success() && out.stdout.is_empty() {
        return status(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("git http-backend error: {}", String::from_utf8_lossy(&out.stderr)),
        );
    }

    parse_cgi(out.stdout)
}

/// Reuse gitio's path logic without exposing internals.
fn gitio_repo_dir(app: &App, owner: &str, name: &str) -> std::path::PathBuf {
    crate::gitio::repo_path(&app.data_dir, owner, name)
}

/// Split a CGI response (`Header: value` lines, blank line, then body) into an
/// axum HTTP response.
fn parse_cgi(raw: Vec<u8>) -> Response<Body> {
    let split = find_header_boundary(&raw);
    let (head, body_start, _) = match split {
        Some((end, body_start)) => (&raw[..end], body_start, ()),
        None => {
            // No header section; treat the whole thing as a body.
            return Response::builder()
                .header("content-type", "application/octet-stream")
                .body(Body::from(raw))
                .unwrap();
        }
    };

    let head_str = String::from_utf8_lossy(head);
    let mut builder = Response::builder();
    let mut code = StatusCode::OK;
    for line in head_str.lines() {
        let (k, v) = match line.split_once(':') {
            Some(kv) => kv,
            None => continue,
        };
        let k = k.trim();
        let v = v.trim();
        if k.eq_ignore_ascii_case("status") {
            // e.g. "403 Forbidden"
            if let Some(num) = v.split_whitespace().next() {
                if let Ok(n) = num.parse::<u16>() {
                    code = StatusCode::from_u16(n).unwrap_or(StatusCode::OK);
                }
            }
        } else {
            builder = builder.header(k, v);
        }
    }

    builder
        .status(code)
        .body(Body::from(raw[body_start..].to_vec()))
        .unwrap()
}

/// Returns `(header_end, body_start)` for the first CRLFCRLF or LFLF.
fn find_header_boundary(raw: &[u8]) -> Option<(usize, usize)> {
    for i in 0..raw.len() {
        if raw[i..].starts_with(b"\r\n\r\n") {
            return Some((i, i + 4));
        }
        if raw[i..].starts_with(b"\n\n") {
            return Some((i, i + 2));
        }
    }
    None
}

// ---- axum handlers -------------------------------------------------------

pub async fn info_refs(
    State(app): State<Arc<App>>,
    Path((owner, repo)): Path<(String, String)>,
    method: Method,
    headers: HeaderMap,
    RawQuery(query): RawQuery,
    body: Bytes,
) -> Response<Body> {
    serve(app, owner, repo, "info/refs", method, headers, query, body).await
}

pub async fn upload_pack(
    State(app): State<Arc<App>>,
    Path((owner, repo)): Path<(String, String)>,
    method: Method,
    headers: HeaderMap,
    RawQuery(query): RawQuery,
    body: Bytes,
) -> Response<Body> {
    serve(app, owner, repo, "git-upload-pack", method, headers, query, body).await
}

pub async fn receive_pack(
    State(app): State<Arc<App>>,
    Path((owner, repo)): Path<(String, String)>,
    method: Method,
    headers: HeaderMap,
    RawQuery(query): RawQuery,
    body: Bytes,
) -> Response<Body> {
    serve(app, owner, repo, "git-receive-pack", method, headers, query, body).await
}