a73x

src/server/http/git_http.rs

Ref:   Size: 5.1 KiB

use std::sync::Arc;

use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;

use super::AppState;

/// Max request body for git upload-pack negotiation (10 MiB).
/// Git smart HTTP negotiation payloads are typically small (a few KiB of want/have lines),
/// but we allow headroom for repos with many refs.
pub const UPLOAD_PACK_BODY_LIMIT: usize = 10 * 1024 * 1024;

#[derive(serde::Deserialize)]
pub struct InfoRefsQuery {
    pub service: Option<String>,
}

pub async fn info_refs(
    Path(repo_dot_git): Path<String>,
    Query(query): Query<InfoRefsQuery>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let service = match query.service.as_deref() {
        Some("git-upload-pack") => "git-upload-pack",
        _ => {
            return (StatusCode::FORBIDDEN, "Only git-upload-pack is supported").into_response();
        }
    };

    let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git);

    let entry = match crate::repos::resolve(&state.repos_dir, repo_name) {
        Some(e) => e,
        None => return (StatusCode::NOT_FOUND, format!("Repository '{}' not found.", repo_name)).into_response(),
    };

    let git_dir = if entry.bare {
        entry.path.clone()
    } else {
        entry.path.join(".git")
    };

    let output = match Command::new("git")
        .args(["upload-pack", "--stateless-rpc", "--advertise-refs"])
        .arg(&git_dir)
        .output()
        .await
    {
        Ok(o) => o,
        Err(e) => {
            tracing::error!("Failed to spawn git upload-pack: {}", e);
            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to run git upload-pack").into_response();
        }
    };

    if !output.status.success() {
        tracing::error!(
            "git upload-pack --advertise-refs exited with status {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr)
        );
        return (StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed").into_response();
    }

    // Prepend pkt-line service announcement:
    // "{4-hex-length}# service=git-upload-pack\n" + "0000"
    let service_line = format!("# service={}\n", service);
    let pkt_len = service_line.len() + 4; // +4 for the 4 hex digits themselves
    let pkt_prefix = format!("{:04x}{}", pkt_len, service_line);
    let flush = b"0000";

    let mut body = Vec::new();
    body.extend_from_slice(pkt_prefix.as_bytes());
    body.extend_from_slice(flush);
    body.extend_from_slice(&output.stdout);

    (
        StatusCode::OK,
        [
            ("Content-Type", "application/x-git-upload-pack-advertisement"),
            ("Cache-Control", "no-cache"),
        ],
        body,
    )
        .into_response()
}

pub async fn upload_pack(
    Path(repo_dot_git): Path<String>,
    State(state): State<Arc<AppState>>,
    body: Bytes,
) -> Response {
    let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git);

    let entry = match crate::repos::resolve(&state.repos_dir, repo_name) {
        Some(e) => e,
        None => return (StatusCode::NOT_FOUND, format!("Repository '{}' not found.", repo_name)).into_response(),
    };

    let git_dir = if entry.bare {
        entry.path.clone()
    } else {
        entry.path.join(".git")
    };

    let mut child = match Command::new("git")
        .args(["upload-pack", "--stateless-rpc"])
        .arg(&git_dir)
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            tracing::error!("Failed to spawn git upload-pack: {}", e);
            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to run git upload-pack").into_response();
        }
    };

    if let Some(mut stdin) = child.stdin.take() {
        if let Err(e) = stdin.write_all(&body).await {
            tracing::error!("Failed to write to git upload-pack stdin: {}", e);
            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to communicate with git upload-pack").into_response();
        }
    }

    let output = match child.wait_with_output().await {
        Ok(o) => o,
        Err(e) => {
            tracing::error!("Failed to wait for git upload-pack: {}", e);
            return (StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed").into_response();
        }
    };

    if !output.status.success() {
        tracing::error!(
            "git upload-pack exited with status {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr)
        );
        return (StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed").into_response();
    }

    let mut response = (
        StatusCode::OK,
        output.stdout,
    )
        .into_response();

    let headers = response.headers_mut();
    headers.insert(
        "Content-Type",
        HeaderValue::from_static("application/x-git-upload-pack-result"),
    );
    headers.insert(
        "Cache-Control",
        HeaderValue::from_static("no-cache"),
    );

    response
}