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