repos
/ repos-rust master

repos-rust

mirror archived upstream

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

6.4 KB · 181 lines · Rust Raw History
  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}