//! `git clone` over HTTP, using git's "smart" protocol. //! //! A clone is two HTTP requests: //! //! 1. `GET /{repo}/info/refs?service=git-upload-pack` //! The server lists its branches/tags ("ref advertisement"). //! 2. `POST /{repo}/git-upload-pack` //! The client says which commits it wants; the server replies with a //! packfile containing them. //! //! Rather than re-implementing the protocol we run the real //! `git upload-pack --stateless-rpc` (exactly what git's own //! `git-http-backend` CGI does) and pipe the HTTP bodies through it. //! upload-pack is the *read* side of git, so this can never modify a repo. //! Pushing (`git-receive-pack`) is refused. use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use std::io::Read; use std::process::Stdio; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio_util::io::ReaderStream; use crate::web::Shared; /// Largest request body (the client's "want/have" list) we accept, before and /// after gzip decompression. pub const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024; #[derive(Deserialize)] pub struct ServiceQuery { service: Option, } fn plain(status: StatusCode, msg: &str) -> Response { (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{msg}\n")).into_response() } /// Encode one line in git's "pkt-line" format: 4 hex digits of length /// (including the 4 digits themselves) followed by the data. pub fn pkt_line(data: &str) -> String { format!("{:04x}{data}", data.len() + 4) } /// The client tells us which protocol version it speaks via the `Git-Protocol` /// header (e.g. "version=2"); upload-pack reads it from the GIT_PROTOCOL env var. fn git_protocol(headers: &HeaderMap) -> Option { let v = headers.get("git-protocol")?.to_str().ok()?; let ok = v.len() < 256 && v.chars().all(|c| c.is_ascii_alphanumeric() || "=:._-".contains(c)); ok.then(|| v.to_string()) } /// Step 1: `GET /{repo}/info/refs?service=git-upload-pack` pub async fn info_refs( State(st): State, Path(repo): Path, Query(q): Query, headers: HeaderMap, ) -> Response { let Ok(rc) = st.repo(&repo) else { return plain(StatusCode::NOT_FOUND, "repository not found"); }; match q.service.as_deref() { Some("git-upload-pack") => {} Some("git-receive-pack") => return plain(StatusCode::FORBIDDEN, "push is not supported"), _ => { return plain( StatusCode::FORBIDDEN, "only the smart HTTP protocol is supported (use git >= 1.6.6)", ) } } let protocol = git_protocol(&headers); let mut cmd = Command::new("git"); cmd.args(["upload-pack", "--stateless-rpc", "--advertise-refs"]) .arg(&rc.path) .stdin(Stdio::null()) .kill_on_drop(true); if let Some(p) = &protocol { cmd.env("GIT_PROTOCOL", p); } let out = match cmd.output().await { Ok(o) if o.status.success() => o, Ok(o) => { eprintln!("upload-pack --advertise-refs failed: {}", String::from_utf8_lossy(&o.stderr)); return plain(StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed"); } Err(e) => { eprintln!("could not run git: {e}"); return plain(StatusCode::INTERNAL_SERVER_ERROR, "could not run git"); } }; // Protocol v0/v1 responses start with a "# service=..." banner and a flush // packet; protocol v2 responses don't (same as git-http-backend). let mut body = Vec::new(); let is_v2 = protocol.as_deref().is_some_and(|p| p.contains("version=2")); if !is_v2 { body.extend_from_slice(pkt_line("# service=git-upload-pack\n").as_bytes()); body.extend_from_slice(b"0000"); } body.extend_from_slice(&out.stdout); let mut res = (StatusCode::OK, body).into_response(); let h = res.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-git-upload-pack-advertisement")); h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); res } /// Step 2: `POST /{repo}/git-upload-pack` – stream a packfile back. pub async fn upload_pack( State(st): State, Path(repo): Path, headers: HeaderMap, body: Bytes, ) -> Response { let Ok(rc) = st.repo(&repo) else { return plain(StatusCode::NOT_FOUND, "repository not found"); }; let ctype = headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()); if ctype != Some("application/x-git-upload-pack-request") { return plain(StatusCode::UNSUPPORTED_MEDIA_TYPE, "expected application/x-git-upload-pack-request"); } // git gzips large requests. let gzipped = headers .get(header::CONTENT_ENCODING) .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.eq_ignore_ascii_case("gzip") || v.eq_ignore_ascii_case("x-gzip")); let input: Vec = if gzipped { let mut out = Vec::new(); let decoder = flate2::read::GzDecoder::new(&body[..]); if decoder.take(MAX_REQUEST_BYTES as u64 + 1).read_to_end(&mut out).is_err() || out.len() > MAX_REQUEST_BYTES { return plain(StatusCode::BAD_REQUEST, "bad or oversized gzip body"); } out } else { body.to_vec() }; let mut cmd = Command::new("git"); cmd.args(["upload-pack", "--stateless-rpc"]) .arg(&rc.path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) // If the client disconnects, the response body (and with it the // child's stdout) is dropped; kill_on_drop makes sure git exits too. .kill_on_drop(true); if let Some(p) = git_protocol(&headers) { cmd.env("GIT_PROTOCOL", p); } let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { eprintln!("could not run git: {e}"); return plain(StatusCode::INTERNAL_SERVER_ERROR, "could not run git"); } }; let mut stdin = child.stdin.take().expect("stdin was piped"); let stdout = child.stdout.take().expect("stdout was piped"); // Feed the request to git in the background, then wait for it to exit, // while we stream its output to the client. tokio::spawn(async move { let _ = stdin.write_all(&input).await; drop(stdin); // closing stdin tells git the request is complete let _ = child.wait().await; }); let mut res = Response::new(Body::from_stream(ReaderStream::new(stdout))); let h = res.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-git-upload-pack-result")); h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); res } /// `POST /{repo}/git-receive-pack` – pushing is not supported in v0.1. pub async fn receive_pack_refused() -> Response { plain(StatusCode::FORBIDDEN, "push is not supported") } #[cfg(test)] mod tests { use super::*; #[test] fn pkt_line_format() { assert_eq!(pkt_line("# service=git-upload-pack\n"), "001e# service=git-upload-pack\n"); assert_eq!(pkt_line(""), "0004"); } #[test] fn protocol_header_is_filtered() { let mut h = HeaderMap::new(); h.insert("git-protocol", HeaderValue::from_static("version=2")); assert_eq!(git_protocol(&h).as_deref(), Some("version=2")); h.insert("git-protocol", HeaderValue::from_static("version=2; rm -rf /")); assert_eq!(git_protocol(&h), None); } }