A minimal self-hosted git browser on Rust axum: bare repos rendered as a website with commits, diffs, syntax-highlighted blobs, atom feeds, and clone over HTTPS.
axumdockergitgit-browsergitoxidegixrustself-hosted
1//! Smart-HTTP clone via a `git http-backend` subprocess.
2//!
3//! `git http-backend` is the CGI program that ships with git. It expects
4//! request data in CGI env vars + stdin, and writes CGI-style output to
5//! stdout: a small block of `Key: Value` headers, a blank line, then the
6//! response body.
7//!
8//! We spawn it per request, pump the request body to its stdin, parse the
9//! CGI headers off stdout, and stream the rest as the response.
10
11use anyhow::{anyhow, Context, Result};
12use axum::{
13 body::Body,
14 http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri},
15 response::Response,
16};
17use bytes::Bytes;
18use futures_util::StreamExt;
19use std::path::Path;
20use std::process::Stdio;
21use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
22use tokio::process::Command;
23
24pub struct CgiRequest<'a> {
25 pub repo_root: &'a Path,
26 pub method: Method,
27 pub path_info: String,
28 pub query: String,
29 pub content_type: Option<String>,
30 pub remote_addr: String,
31 pub headers: HeaderMap,
32}
33
34pub async fn serve(req: CgiRequest<'_>, body: Body) -> Result<Response> {
35 let mut cmd = Command::new("git");
36 cmd.arg("http-backend")
37 .env_clear()
38 .env("PATH", std::env::var("PATH").unwrap_or_default())
39 .env("GIT_PROJECT_ROOT", req.repo_root)
40 .env("GIT_HTTP_EXPORT_ALL", "1")
41 .env("REQUEST_METHOD", req.method.as_str())
42 .env("PATH_INFO", &req.path_info)
43 .env("QUERY_STRING", &req.query)
44 .env("REMOTE_ADDR", &req.remote_addr)
45 .env("HTTP_HOST", host_header(&req.headers).unwrap_or(""))
46 .env("SERVER_PROTOCOL", "HTTP/1.1");
47
48 if let Some(ct) = req.content_type {
49 cmd.env("CONTENT_TYPE", ct);
50 }
51 if let Some(ce) = req.headers.get("content-encoding").and_then(|v| v.to_str().ok()) {
52 cmd.env("HTTP_CONTENT_ENCODING", ce);
53 }
54 if let Some(ua) = req.headers.get("user-agent").and_then(|v| v.to_str().ok()) {
55 cmd.env("HTTP_USER_AGENT", ua);
56 }
57 if let Some(acc) = req.headers.get("accept").and_then(|v| v.to_str().ok()) {
58 cmd.env("HTTP_ACCEPT", acc);
59 }
60 if let Some(gp) = req
61 .headers
62 .get("git-protocol")
63 .and_then(|v| v.to_str().ok())
64 {
65 // protocol v2 negotiation header; git http-backend needs this in env
66 // form to switch protocols.
67 cmd.env("HTTP_GIT_PROTOCOL", gp);
68 }
69
70 cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
71 // If the client disconnects mid-clone the response stream (and the
72 // child handle in it) is dropped; without this the orphaned
73 // http-backend/upload-pack would keep packing to a dead pipe.
74 cmd.kill_on_drop(true);
75
76 let mut child = cmd.spawn().context("spawn git http-backend")?;
77 let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
78 let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
79 let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
80
81 // Pump the request body into the child. POST upload-pack bodies can be
82 // megabytes; stream chunk by chunk.
83 let writer = tokio::spawn(async move {
84 let mut stream = body.into_data_stream();
85 while let Some(chunk) = stream.next().await {
86 match chunk {
87 Ok(bytes) => {
88 if let Err(e) = stdin.write_all(&bytes).await {
89 tracing::warn!("write to git http-backend stdin: {e}");
90 break;
91 }
92 }
93 Err(e) => {
94 tracing::warn!("client body error: {e}");
95 break;
96 }
97 }
98 }
99 let _ = stdin.shutdown().await;
100 });
101
102 tokio::spawn(async move {
103 let mut buf = String::new();
104 if stderr.read_to_string(&mut buf).await.is_ok() && !buf.is_empty() {
105 tracing::warn!("git http-backend stderr: {}", buf.trim());
106 }
107 });
108
109 let mut reader = BufReader::new(stdout);
110 let (status, headers) = parse_cgi_headers(&mut reader).await?;
111
112 // Whatever's left in `reader` after the blank line is the response body.
113 // Stream it back as a byte stream rather than buffering — pack files can
114 // be large.
115 let stream = async_stream::stream! {
116 let mut buf = vec![0u8; 32 * 1024];
117 let mut reader = reader;
118 loop {
119 match reader.read(&mut buf).await {
120 Ok(0) => break,
121 Ok(n) => yield Ok::<_, std::io::Error>(Bytes::copy_from_slice(&buf[..n])),
122 Err(e) => {
123 yield Err(e);
124 break;
125 }
126 }
127 }
128 // Reap the child so we don't leak zombies.
129 let _ = child.wait().await;
130 let _ = writer.await;
131 };
132
133 let mut resp = Response::builder().status(status);
134 for (k, v) in &headers {
135 resp = resp.header(k, v);
136 }
137 Ok(resp.body(Body::from_stream(stream))?)
138}
139
140fn host_header(h: &HeaderMap) -> Option<&str> {
141 h.get("host").and_then(|v| v.to_str().ok())
142}
143
144/// Read CGI-style "Key: Value\r\n" header lines until a blank line. The first
145/// `Status: NNN reason` line, if present, becomes the HTTP status.
146async fn parse_cgi_headers<R: tokio::io::AsyncBufRead + Unpin>(
147 r: &mut R,
148) -> Result<(StatusCode, Vec<(HeaderName, HeaderValue)>)> {
149 let mut status = StatusCode::OK;
150 let mut headers = Vec::new();
151 let mut line = String::new();
152 loop {
153 line.clear();
154 let n = r.read_line(&mut line).await?;
155 if n == 0 {
156 break;
157 }
158 let trimmed = line.trim_end_matches(['\r', '\n']);
159 if trimmed.is_empty() {
160 break;
161 }
162 let Some((k, v)) = trimmed.split_once(':') else { continue };
163 let key = k.trim();
164 let val = v.trim();
165 if key.eq_ignore_ascii_case("Status") {
166 // Form: `Status: 404 Not Found` or `Status: 200`.
167 let code: u16 = val.split_whitespace().next().and_then(|s| s.parse().ok()).unwrap_or(200);
168 status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);
169 continue;
170 }
171 let Ok(name) = HeaderName::try_from(key) else { continue };
172 let Ok(value) = HeaderValue::try_from(val) else { continue };
173 headers.push((name, value));
174 }
175 Ok((status, headers))
176}
177
178pub fn extract_query(uri: &Uri) -> String {
179 uri.query().unwrap_or_default().to_string()
180}