Parent [>]
1//! Smart HTTP git protocol handlers.
2//!
3//! Provides handlers for `info/refs` (ref advertisement) and `git-upload-pack`
4//! (pack negotiation and transport), enabling `git clone` and `git fetch` over
5//! HTTP without CGI.
6//!
7//! Both handlers shell out to `git upload-pack --stateless-rpc`, following the
8//! same streaming pattern as the archive handler.
9
10use std::io;
11use std::process::Stdio;
12
13use async_compression::tokio::bufread::GzipDecoder;
14use axum::body::Body;
15use axum::extract::{Query, State};
16use axum::http::HeaderMap;
17use axum::http::header;
18use axum::response::IntoResponse;
19use futures::StreamExt;
20use serde::Deserialize;
21use tokio::io::BufReader;
22use tokio::process::Command;
23use tokio_util::io::{ReaderStream, StreamReader};
24
25use crate::error::{Error, Result};
26use crate::git;
27use crate::routes::{AppState, RepoName};
28
29use super::archive::reaper;
30
31/// Query parameters for `info/refs`.
32#[derive(Deserialize)]
33pub(super) struct InfoRefsQuery {
34    service: Option<String>,
35}
36
37/// Encodes a string as a pkt-line (4-digit hex length + payload).
38fn pkt_line(s: &str) -> Vec<u8> {
39    let len = s.len() + 4;
40    format!("{len:04x}{s}").into_bytes()
41}
42
43/// Handles `GET /{repo}/info/refs?service=git-upload-pack`.
44///
45/// Returns a ref advertisement so clients can discover branches and tags before
46/// fetching.  The response is a pkt-line stream with a `# service=git-upload-pack`
47/// header, followed by the output of `git upload-pack --stateless-rpc --advertise-refs`.
48pub async fn info_refs(
49    State(state): State<AppState>,
50    RepoName(repo): RepoName,
51    headers: HeaderMap,
52    Query(query): Query<InfoRefsQuery>,
53) -> Result<impl IntoResponse> {
54    let _service = query
55        .service
56        .filter(|s| s == "git-upload-pack")
57        .ok_or_else(|| Error::BadRequest("unknown or missing service".into()))?;
58
59    let repo_path = state.root.join(&repo);
60    if !repo_path.exists() {
61        return Err(Error::RepoNotFound(repo));
62    }
63
64    let _git_repo = git::open_repo(&state.root, &repo)?;
65
66    let git_protocol = headers
67        .get("git-protocol")
68        .and_then(|v| v.to_str().ok())
69        .map(|s| s.to_string());
70
71    let mut cmd = Command::new("git");
72    cmd.arg("upload-pack")
73        .arg("--stateless-rpc")
74        .arg("--advertise-refs")
75        .arg(repo_path.as_os_str());
76    if let Some(protocol) = &git_protocol {
77        cmd.env("GIT_PROTOCOL", protocol);
78    }
79    let output = cmd
80        .stdout(Stdio::piped())
81        .stderr(Stdio::piped())
82        .output()
83        .await
84        .map_err(Error::Io)?;
85
86    if !output.status.success() {
87        let stderr = String::from_utf8_lossy(&output.stderr);
88        return Err(Error::GitCorrupt(format!(
89            "git upload-pack --advertise-refs failed: {stderr}"
90        )));
91    }
92
93    let mut buf = Vec::new();
94    buf.extend_from_slice(&pkt_line("# service=git-upload-pack\n"));
95    buf.extend_from_slice(b"0000");
96    buf.extend_from_slice(&output.stdout);
97
98    Ok((
99        [
100            (
101                header::CONTENT_TYPE,
102                "application/x-git-upload-pack-advertisement",
103            ),
104            (header::CACHE_CONTROL, "no-cache"),
105        ],
106        buf,
107    ))
108}
109
110/// Handles `POST /{repo}/git-upload-pack`.
111///
112/// Processes a pack-request body (wants / haves) and streams back the generated
113/// pack file.  The response is a pkt-line stream with status/progress messages
114/// in band 2 and pack data in band 1.
115///
116/// The request body is streamed through an optional gzip decoder directly into
117/// the child process's stdin — no single buffer holds the entire payload.
118pub async fn upload_pack(
119    State(state): State<AppState>,
120    RepoName(repo): RepoName,
121    headers: HeaderMap,
122    body: Body,
123) -> Result<impl IntoResponse> {
124    let content_type = headers
125        .get(header::CONTENT_TYPE)
126        .and_then(|v| v.to_str().ok())
127        .unwrap_or("");
128    if content_type != "application/x-git-upload-pack-request" {
129        return Err(Error::BadRequest(
130            "expected application/x-git-upload-pack-request".into(),
131        ));
132    }
133
134    let git_protocol = headers
135        .get("git-protocol")
136        .and_then(|v| v.to_str().ok())
137        .map(|s| s.to_string());
138
139    let content_encoding = headers
140        .get(header::CONTENT_ENCODING)
141        .and_then(|v| v.to_str().ok())
142        .map(|s| s.to_string());
143
144    // Reject unsupported encodings before spawning child/spawning tasks.
145    if let Some(ref enc) = content_encoding && enc != "gzip" {
146        return Err(Error::BadRequest(format!(
147            "unsupported content encoding: {enc}"
148        )));
149    }
150
151    let repo_path = state.root.join(&repo);
152    if !repo_path.exists() {
153        return Err(Error::RepoNotFound(repo));
154    }
155
156    let _git_repo = git::open_repo(&state.root, &repo)?;
157
158    let mut cmd = Command::new("git");
159    cmd.arg("upload-pack")
160        .arg("--stateless-rpc")
161        .arg(repo_path.as_os_str());
162    if let Some(protocol) = &git_protocol {
163        cmd.env("GIT_PROTOCOL", protocol);
164    }
165    let mut child = cmd
166        .stdin(Stdio::piped())
167        .stdout(Stdio::piped())
168        .stderr(Stdio::piped())
169        .kill_on_drop(true)
170        .spawn()
171        .map_err(Error::Io)?;
172
173    let mut stdin = child.stdin.take().expect("stdin configured");
174    let stdout = child.stdout.take().expect("stdout configured");
175    let stderr = child.stderr.take().expect("stderr configured");
176
177    // Pipe request body (optionally decompressed) into git's stdin.
178    tokio::spawn(async move {
179        use tokio::io::AsyncWriteExt;
180
181        let stream = body.into_data_stream();
182        let mapped = stream.map(|r| r.map_err(io::Error::other));
183        let mut body_reader = StreamReader::new(mapped);
184
185        let result = if content_encoding.as_deref() == Some("gzip") {
186            let mut decoder = GzipDecoder::new(BufReader::new(body_reader));
187            tokio::io::copy(&mut decoder, &mut stdin).await
188        } else {
189            tokio::io::copy(&mut body_reader, &mut stdin).await
190        };
191
192        if let Err(e) = result {
193            tracing::warn!(error = %e, "pipe body -> git stdin");
194        }
195        let _ = stdin.shutdown().await;
196    });
197
198    tokio::spawn(reaper(child, stderr, "git upload-pack"));
199
200    let stream = ReaderStream::new(stdout);
201    Ok((
202        [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")],
203        Body::from_stream(stream),
204    ))
205}