Krolyxon
Commit 18ca21c

ferreniumsrcserverclone.rs

264 lines · 9.3 KB · Rust

1//! Read-only smart-HTTP clones through `git http-backend`.
2//!
3//! Only `info/refs?service=git-upload-pack` and `POST git-upload-pack` are
4//! served; anything that would push is refused before git runs. The CGI
5//! environment is built from the index (never from the raw URL), and both
6//! bodies are streamed.
7
8use std::process::Stdio;
9use std::sync::Arc;
10
11use axum::body::Body;
12use axum::extract::Request;
13use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
14use axum::response::{IntoResponse, Response};
15use futures_util::{StreamExt, TryStreamExt};
16use tokio::io::AsyncReadExt;
17use tokio::process::Command;
18use tokio_util::io::{ReaderStream, StreamReader};
19
20use crate::handlers::error;
21use crate::index::Repo;
22use crate::state::AppState;
23
24/// CGI response headers larger than this are an error.
25const MAX_HEADER_BYTES: usize = 64 * 1024;
26
27/// Whether `rest` (the path after the repository) is a clone endpoint.
28pub fn is_clone_path(rest: &[Vec<u8>]) -> bool {
29 matches!(
30 rest,
31 [a, b] if a == b"info" && b == b"refs"
32 ) || matches!(rest, [a] if a == b"git-upload-pack" || a == b"git-receive-pack")
33}
34
35pub async fn serve(
36 state: Arc<AppState>,
37 repo: Arc<Repo>,
38 rest: Vec<Vec<u8>>,
39 req: Request,
40) -> Response {
41 let config = state.config.load_full();
42 if !config.repos.https_clone {
43 return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
44 }
45 let query = req.uri().query().unwrap_or_default().to_string();
46 let service = query
47 .split('&')
48 .find_map(|kv| kv.strip_prefix("service="))
49 .unwrap_or_default();
50 let endpoint = match (req.method(), rest.last().map(Vec::as_slice)) {
51 (&Method::GET | &Method::HEAD, Some(b"refs")) => match service {
52 "git-upload-pack" => "info/refs",
53 "git-receive-pack" => return forbidden(),
54 _ => {
55 return (
56 StatusCode::FORBIDDEN,
57 "Only the smart HTTP protocol is supported.\n",
58 )
59 .into_response();
60 }
61 },
62 (&Method::POST, Some(b"git-upload-pack")) => "git-upload-pack",
63 (_, Some(b"git-receive-pack")) => return forbidden(),
64 _ => return StatusCode::METHOD_NOT_ALLOWED.into_response(),
65 };
66
67 let Ok(permit) = state.heavy_permits.clone().acquire_owned().await else {
68 return StatusCode::SERVICE_UNAVAILABLE.into_response();
69 };
70 let (Some(root), Some(dir)) = (repo.git_dir.parent(), repo.git_dir.file_name()) else {
71 return StatusCode::NOT_FOUND.into_response();
72 };
73 let path_info = format!("/{}/{endpoint}", dir.to_string_lossy());
74
75 let headers = req.headers();
76 let header = |name: HeaderName| {
77 headers
78 .get(name)
79 .and_then(|v| v.to_str().ok())
80 .map(str::to_string)
81 };
82 let mut cmd = Command::new("git");
83 cmd.arg("http-backend")
84 .env_clear()
85 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
86 .env("GIT_PROJECT_ROOT", root)
87 .env("PATH_INFO", path_info)
88 .env("REQUEST_METHOD", req.method().as_str())
89 .env("QUERY_STRING", &query)
90 .env("GIT_HTTP_EXPORT_ALL", "1")
91 // Only the repository's own config, plus ours.
92 .env("GIT_CONFIG_NOSYSTEM", "1")
93 .env("GIT_CONFIG_GLOBAL", "/dev/null")
94 .env("HOME", "/nonexistent")
95 .env("GIT_CONFIG_COUNT", "2")
96 .env("GIT_CONFIG_KEY_0", "http.receivepack")
97 .env("GIT_CONFIG_VALUE_0", "false")
98 // Visibility is decided by the index; ownership is not our concern.
99 .env("GIT_CONFIG_KEY_1", "safe.directory")
100 .env("GIT_CONFIG_VALUE_1", "*")
101 .stdin(Stdio::piped())
102 .stdout(Stdio::piped())
103 .stderr(Stdio::piped())
104 .kill_on_drop(true);
105 for (var, name) in [
106 ("CONTENT_TYPE", header::CONTENT_TYPE),
107 ("CONTENT_LENGTH", header::CONTENT_LENGTH),
108 ("HTTP_CONTENT_ENCODING", header::CONTENT_ENCODING),
109 ] {
110 if let Some(value) = header(name) {
111 cmd.env(var, value);
112 }
113 }
114 if let Some(protocol) = header(HeaderName::from_static("git-protocol")) {
115 cmd.env("HTTP_GIT_PROTOCOL", protocol);
116 }
117
118 let mut child = match cmd.spawn() {
119 Ok(child) => child,
120 Err(e) => {
121 tracing::error!("cannot run git http-backend: {e}");
122 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
123 }
124 };
125 let (Some(mut stdin), Some(mut stdout), Some(mut stderr)) =
126 (child.stdin.take(), child.stdout.take(), child.stderr.take())
127 else {
128 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
129 };
130
131 // Request body → stdin, then EOF.
132 let body = req
133 .into_body()
134 .into_data_stream()
135 .map_err(std::io::Error::other);
136 tokio::spawn(async move {
137 let mut reader = StreamReader::new(body);
138 if let Err(e) = tokio::io::copy(&mut reader, &mut stdin).await {
139 tracing::debug!("clone request body: {e}");
140 }
141 });
142 let name = repo.name.clone();
143 tokio::spawn(async move {
144 let mut text = String::new();
145 let _ = stderr.read_to_string(&mut text).await;
146 for line in text.lines().filter(|l| !l.trim().is_empty()) {
147 tracing::warn!("{name}: git http-backend: {line}");
148 }
149 });
150
151 // CGI headers, then the rest of stdout as the body.
152 let mut buf = Vec::with_capacity(4096);
153 let (status, cgi_headers, body_start) = loop {
154 if let Some(parsed) = parse_cgi_headers(&buf) {
155 break parsed;
156 }
157 if buf.len() > MAX_HEADER_BYTES {
158 tracing::error!("git http-backend sent oversized headers");
159 return StatusCode::BAD_GATEWAY.into_response();
160 }
161 match stdout.read_buf(&mut buf).await {
162 Ok(0) | Err(_) => {
163 tracing::error!("git http-backend ended before its headers");
164 return StatusCode::BAD_GATEWAY.into_response();
165 }
166 Ok(_) => {}
167 }
168 };
169 let head = buf.split_off(body_start);
170 let stream = futures_util::stream::once(async move { Ok(bytes::Bytes::from(head)) })
171 .chain(ReaderStream::new(stdout));
172 // Reap git and release the permit when it exits.
173 tokio::spawn(async move {
174 let _permit = permit;
175 let _ = child.wait().await;
176 });
177
178 let mut res = Body::from_stream(stream).into_response();
179 *res.status_mut() = status;
180 for (name, value) in cgi_headers {
181 if let (Ok(name), Ok(value)) = (
182 HeaderName::from_bytes(name.as_bytes()),
183 HeaderValue::from_str(&value),
184 ) {
185 res.headers_mut().insert(name, value);
186 }
187 }
188 res
189}
190
191fn forbidden() -> Response {
192 (StatusCode::FORBIDDEN, "This server is read-only.\n").into_response()
193}
194
195/// A parsed CGI response head: status, headers, and where the body starts.
196pub type CgiHead = (StatusCode, Vec<(String, String)>, usize);
197
198/// Parse a CGI response head, or `None` while the blank line hasn't
199/// arrived yet.
200pub fn parse_cgi_headers(buf: &[u8]) -> Option<CgiHead> {
201 let (end, sep) = [&b"\r\n\r\n"[..], b"\n\n"]
202 .iter()
203 .filter_map(|sep| {
204 buf.windows(sep.len())
205 .position(|w| w == *sep)
206 .map(|i| (i, sep.len()))
207 })
208 .min()?;
209 let head = String::from_utf8_lossy(&buf[..end]);
210 let mut status = StatusCode::OK;
211 let mut headers = Vec::new();
212 for line in head.lines() {
213 let Some((name, value)) = line.split_once(':') else {
214 continue;
215 };
216 let value = value.trim().to_string();
217 if name.eq_ignore_ascii_case("status") {
218 if let Some(code) = value
219 .split_whitespace()
220 .next()
221 .and_then(|c| c.parse::<u16>().ok())
222 .and_then(|c| StatusCode::from_u16(c).ok())
223 {
224 status = code;
225 }
226 } else {
227 headers.push((name.trim().to_string(), value));
228 }
229 }
230 Some((status, headers, end + sep))
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn cgi_headers() {
239 let raw = b"Expires: Fri, 01 Jan 1980 00:00:00 GMT\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\n001e# service";
240 let (status, headers, start) = parse_cgi_headers(raw).unwrap();
241 assert_eq!(status, StatusCode::OK);
242 assert_eq!(headers.len(), 2);
243 assert_eq!(headers[1].1, "application/x-git-upload-pack-advertisement");
244 assert_eq!(&raw[start..], b"001e# service");
245
246 let raw = b"Status: 403 Forbidden\nContent-Type: text/plain\n\nno";
247 let (status, headers, start) = parse_cgi_headers(raw).unwrap();
248 assert_eq!(status, StatusCode::FORBIDDEN);
249 assert_eq!(headers, vec![("Content-Type".into(), "text/plain".into())]);
250 assert_eq!(&raw[start..], b"no");
251
252 assert!(parse_cgi_headers(b"Content-Type: x\r\n").is_none());
253 }
254
255 #[test]
256 fn clone_paths() {
257 let p = |s: &[&str]| s.iter().map(|x| x.as_bytes().to_vec()).collect::<Vec<_>>();
258 assert!(is_clone_path(&p(&["info", "refs"])));
259 assert!(is_clone_path(&p(&["git-upload-pack"])));
260 assert!(is_clone_path(&p(&["git-receive-pack"])));
261 assert!(!is_clone_path(&p(&["info"])));
262 assert!(!is_clone_path(&p(&["tree", "main"])));
263 }
264}