a73x

8b7dabce

add git smart HTTP for read-only HTTPS clone

a73x   2026-03-30 19:09

Implements info_refs and upload_pack handlers following the git smart
HTTP protocol, enabling anonymous read-only clone over HTTP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/src/server/http/git_http.rs b/src/server/http/git_http.rs
new file mode 100644
index 0000000..fba17f7
--- /dev/null
+++ b/src/server/http/git_http.rs
@@ -0,0 +1,161 @@
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;

#[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
}
diff --git a/src/server/http/mod.rs b/src/server/http/mod.rs
index 5629541..3db51a2 100644
--- a/src/server/http/mod.rs
+++ b/src/server/http/mod.rs
@@ -1,3 +1,4 @@
pub mod git_http;
pub mod repo_list;
pub mod repo;

@@ -25,5 +26,7 @@ pub fn router(state: AppState) -> Router {
        .route("/{repo_name}/patches/{id}", axum::routing::get(repo::patch_detail))
        .route("/{repo_name}/issues", axum::routing::get(repo::issues))
        .route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail))
        .route("/{repo_dot_git}/info/refs", axum::routing::get(git_http::info_refs))
        .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack))
        .with_state(shared)
}