zuka
zuka/src/git/transport.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/git/transport.rs
RStransport.rs17.5 KBDownload
1// Smart HTTP, delegated to `git http-backend` (SPEC §1.2).
2//
3// Not `upload-pack --stateless-rpc` directly: http-backend already implements
4// gzipped request bodies, GIT_PROTOCOL negotiation, pkt-line framing and the exact
5// content types git expects.
6//
7// Nothing here buffers a pack. The request body streams into the child's stdin
8// while its stdout streams out as the response body, so a 250 MB clone costs one
9// chunk of memory rather than two copies of the pack. Three properties this file
10// has to hold, each of which was measurably absent when the body was a `Vec<u8>`:
11//
12// * a byte cap enforced mid-stream, not after `collect()`;
13// * a semaphore, so N concurrent clones cannot exhaust the host;
14// * a process-GROUP kill on disconnect, because `upload-pack` and `pack-objects`
15// are grandchildren and `kill_on_drop` reaches neither.
16
17use crate::error::{Error, Result};
18use anyhow::Context;
19use bytes::{Bytes, BytesMut};
20use std::path::Path;
21use std::process::Stdio;
22use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader};
23use tokio::process::{Child, Command};
24use tokio::sync::{OwnedSemaphorePermit, Semaphore};
25
26/// Largest header block a CGI may emit. Bounds the scan for the blank line so a
27/// binary body is never searched end to end looking for a separator.
28const MAX_HEADER_BYTES: usize = 16 * 1024;
29
30/// A parsed `/{account}/{repo}.git/{service}` request.
31#[derive(Debug, PartialEq, Eq)]
32pub struct GitRoute {
33 pub account: String,
34 pub repo: String,
35 /// Path git sees, e.g. `/alice/site.git/info/refs`.
36 pub path_info: String,
37 /// The canonical query to hand the CGI. Rebuilt from the parsed service rather
38 /// than forwarded verbatim, so the CGI cannot see a different service than the
39 /// one authorization was decided on.
40 pub query: String,
41 /// True when the request can modify the repository.
42 pub writes: bool,
43}
44
45/// Recognise a git wire-protocol path.
46///
47/// Returns `None` for anything that is not one of the three smart-HTTP endpoints —
48/// the dumb protocol is not served, so no other path under `.git/` is routable.
49///
50/// A request carrying more than one `service` parameter is refused. `find_map` takes
51/// the first and http-backend's `string_list` takes the last, so forwarding a
52/// duplicated parameter lets the authorization decision and the CGI disagree about
53/// which service is running.
54pub fn parse_route(path: &str, query: &str) -> Option<GitRoute> {
55 let rest = path.strip_prefix('/')?;
56 let (account, rest) = rest.split_once('/')?;
57 let (repo_git, tail) = rest.split_once('/')?;
58 let repo = repo_git.strip_suffix(".git")?;
59
60 if account.is_empty() || repo.is_empty() {
61 return None;
62 }
63
64 let (writes, canonical_query) = match tail {
65 "info/refs" => {
66 let service = single_service(query)?;
67 match service.as_str() {
68 "git-receive-pack" => (true, format!("service={service}")),
69 "git-upload-pack" => (false, format!("service={service}")),
70 _ => return None,
71 }
72 }
73 "git-receive-pack" => (true, String::new()),
74 "git-upload-pack" => (false, String::new()),
75 _ => return None,
76 };
77
78 Some(GitRoute {
79 account: account.to_string(),
80 repo: repo.to_string(),
81 path_info: format!("/{account}/{repo}.git/{tail}"),
82 query: canonical_query,
83 writes,
84 })
85}
86
87/// Exactly one `service` parameter, percent-decoded. `None` if absent or repeated.
88fn single_service(query: &str) -> Option<String> {
89 let mut found = None;
90 for pair in query.split('&') {
91 let Some((key, value)) = pair.split_once('=') else {
92 continue;
93 };
94 if crate::http::request::percent_decode(key) != "service" {
95 continue;
96 }
97 if found.is_some() {
98 return None;
99 }
100 found = Some(crate::http::request::percent_decode(value));
101 }
102 found
103}
104
105/// Keeps a CGI child and its concurrency permit alive for exactly as long as the
106/// response body is being read, and kills the whole process group on drop.
107///
108/// `kill_on_drop` reaches only the direct child. `upload-pack` and `pack-objects`
109/// are grandchildren, and without the group kill they keep running — measured at
110/// 4.3 seconds past the client's departure — holding memory and a pipe nobody reads.
111pub struct CgiGuard {
112 /// The child is owned here, not in `spawn`. `kill_on_drop` fires when the
113 /// `Child` is dropped, so leaving it in the spawning scope killed the CGI the
114 /// moment the head was parsed — the response then carried only the bytes
115 /// already buffered, which for a ref advertisement is the service line and
116 /// nothing else.
117 child: Child,
118 pid: Option<u32>,
119 _permit: OwnedSemaphorePermit,
120}
121
122impl Drop for CgiGuard {
123 fn drop(&mut self) {
124 let _ = self.child.start_kill();
125 #[cfg(unix)]
126 if let Some(pid) = self.pid {
127 // SAFETY: the child was spawned with `process_group(0)`, so its pgid is
128 // its own pid and no unrelated process can share it.
129 unsafe {
130 libc::killpg(pid as libc::pid_t, libc::SIGKILL);
131 }
132 }
133 }
134}
135
136/// A running CGI: parsed head, plus the still-open stdout to stream as the body.
137pub struct CgiResponse {
138 pub status: u16,
139 pub headers: Vec<(String, String)>,
140 /// Bytes of the body already read while locating the header block.
141 pub prefix: Bytes,
142 pub stdout: BufReader<tokio::process::ChildStdout>,
143 pub guard: CgiGuard,
144}
145
146/// Inputs the CGI needs from the HTTP request.
147pub struct CgiRequest<'a> {
148 pub method: &'a str,
149 pub path_info: &'a str,
150 pub query: &'a str,
151 pub content_type: Option<&'a str>,
152 pub content_encoding: Option<&'a str>,
153 /// Value of the `Git-Protocol` header. Without forwarding this, every client
154 /// silently falls back to protocol v0.
155 pub git_protocol: Option<&'a str>,
156 pub remote_user: &'a str,
157 pub remote_addr: &'a str,
158 pub content_length: Option<u64>,
159 /// Configuration the installed git hooks need. See `Config::hook_env`.
160 pub hook_env: &'a [(String, String)],
161}
162
163/// Spawn `git http-backend` and return its head plus a readable stdout.
164///
165/// `body` is streamed into the child concurrently with reading its stdout. Doing
166/// the write first and reading afterwards deadlocks `receive-pack`, which emits
167/// sideband progress while still consuming the pack: the child blocks on a full
168/// stdout pipe while we block writing stdin.
169pub async fn spawn<R>(
170 git_root: &Path,
171 request: CgiRequest<'_>,
172 mut body: R,
173 limit: u64,
174 semaphore: std::sync::Arc<Semaphore>,
175) -> Result<CgiResponse>
176where
177 R: AsyncRead + Unpin + Send + 'static,
178{
179 let permit = semaphore
180 .acquire_owned()
181 .await
182 .map_err(|_| Error::Internal(anyhow::anyhow!("git concurrency semaphore closed")))?;
183
184 let backend = crate::git::exec::http_backend()?;
185
186 let mut command = Command::new(backend);
187 command
188 .env_clear()
189 .env(
190 "PATH",
191 std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
192 )
193 // Without this, /etc/gitconfig applies to every child, and
194 // `uploadpack.packObjectsHook` there changes wire behaviour invisibly.
195 .env("GIT_CONFIG_NOSYSTEM", "1")
196 .env("GIT_PROJECT_ROOT", git_root)
197 .env("GIT_HTTP_EXPORT_ALL", "1")
198 .env("PATH_INFO", request.path_info)
199 .env("REQUEST_METHOD", request.method)
200 .env("QUERY_STRING", request.query)
201 .env("REMOTE_USER", request.remote_user)
202 .env("REMOTE_ADDR", request.remote_addr)
203 .stdin(Stdio::piped())
204 .stdout(Stdio::piped())
205 .stderr(Stdio::piped())
206 .kill_on_drop(true);
207
208 #[cfg(unix)]
209 command.process_group(0);
210
211 for (key, value) in request.hook_env {
212 command.env(key, value);
213 }
214
215 if let Some(length) = request.content_length {
216 command.env("CONTENT_LENGTH", length.to_string());
217 }
218 if let Some(value) = request.content_type {
219 command.env("CONTENT_TYPE", value);
220 }
221 if let Some(value) = request.content_encoding {
222 command.env("HTTP_CONTENT_ENCODING", value);
223 }
224 if let Some(value) = request.git_protocol {
225 command.env("GIT_PROTOCOL", value);
226 }
227
228 let mut child = command.spawn().context("spawn git http-backend")?;
229 let pid = child.id();
230
231 let mut stdin = child.stdin.take().expect("stdin is piped");
232 let stderr = child.stderr.take().expect("stderr is piped");
233 let stdout = BufReader::new(child.stdout.take().expect("stdout is piped"));
234
235 let guard = CgiGuard {
236 child,
237 pid,
238 _permit: permit,
239 };
240
241 // Stream the request body in, aborting the moment the cap is passed rather than
242 // after the whole body has been accepted into memory.
243 tokio::spawn(async move {
244 let mut written: u64 = 0;
245 let mut buffer = vec![0u8; 64 * 1024];
246 loop {
247 let read = match body.read(&mut buffer).await {
248 Ok(0) => break,
249 Ok(n) => n,
250 Err(_) => break,
251 };
252 written += read as u64;
253 if written > limit {
254 break;
255 }
256 if stdin.write_all(&buffer[..read]).await.is_err() {
257 break;
258 }
259 }
260 let _ = stdin.shutdown().await;
261 });
262
263 // Drain stderr concurrently: it is a 64 KiB pipe, and a child that fills it
264 // while we are reading stdout blocks forever.
265 tokio::spawn(async move {
266 let mut stderr = stderr;
267 let mut text = String::new();
268 if stderr.read_to_string(&mut text).await.is_ok() && !text.trim().is_empty() {
269 eprintln!("[git] http-backend: {}", text.trim());
270 }
271 });
272
273 read_head(stdout, guard).await
274}
275
276/// Read just far enough to parse the CGI header block, leaving the rest streamable.
277async fn read_head(
278 mut stdout: BufReader<tokio::process::ChildStdout>,
279 guard: CgiGuard,
280) -> Result<CgiResponse> {
281 let mut buffer = BytesMut::with_capacity(8 * 1024);
282
283 let split = loop {
284 if let Some(found) = find_header_end(&buffer) {
285 break found;
286 }
287 if buffer.len() > MAX_HEADER_BYTES {
288 return Err(Error::Internal(anyhow::anyhow!(
289 "git http-backend header block exceeded {MAX_HEADER_BYTES} bytes"
290 )));
291 }
292 let mut chunk = [0u8; 4096];
293 let read = stdout
294 .read(&mut chunk)
295 .await
296 .context("read http-backend stdout")?;
297 if read == 0 {
298 return Err(Error::Internal(anyhow::anyhow!(
299 "git http-backend produced no header block"
300 )));
301 }
302 buffer.extend_from_slice(&chunk[..read]);
303 };
304
305 let head = buffer.split_to(split.0);
306 let _separator = buffer.split_to(split.1);
307 let prefix = buffer.freeze();
308
309 let (status, headers) = parse_head(&head);
310 Ok(CgiResponse {
311 status,
312 headers,
313 prefix,
314 stdout,
315 guard,
316 })
317}
318
319/// Split a CGI header block into status and headers.
320///
321/// CGI signals status with a `Status:` header rather than a response line; absent
322/// one, the response is 200.
323fn parse_head(head: &[u8]) -> (u16, Vec<(String, String)>) {
324 let text = String::from_utf8_lossy(head);
325 let mut status = 200u16;
326 let mut headers = Vec::new();
327
328 for line in text.lines() {
329 let Some((name, value)) = line.split_once(':') else {
330 continue;
331 };
332 let (name, value) = (name.trim(), value.trim());
333
334 if name.eq_ignore_ascii_case("status") {
335 status = value
336 .split_whitespace()
337 .next()
338 .and_then(|code| code.parse().ok())
339 .unwrap_or(200);
340 continue;
341 }
342 headers.push((name.to_string(), value.to_string()));
343 }
344 (status, headers)
345}
346
347/// Locate the blank line ending the header block, tolerating LF and CRLF.
348fn find_header_end(raw: &[u8]) -> Option<(usize, usize)> {
349 let scan = raw.len().min(MAX_HEADER_BYTES + 4);
350 let window = &raw[..scan];
351
352 let crlf = window.windows(4).position(|w| w == b"\r\n\r\n");
353 let lf = window.windows(2).position(|w| w == b"\n\n");
354
355 match (crlf, lf) {
356 (Some(c), Some(l)) if c <= l => Some((c, 4)),
357 (Some(_), Some(l)) => Some((l, 2)),
358 (Some(c), None) => Some((c, 4)),
359 (None, Some(l)) => Some((l, 2)),
360 (None, None) => None,
361 }
362}
363
364/// Headers the CGI must not be allowed to set on our response.
365///
366/// The cache directives are here because http-backend already emits them on
367/// `info/refs`; without filtering, `Builder::header` appends and the response
368/// carries each twice.
369pub fn is_suppressed(name: &str) -> bool {
370 const BLOCKED: &[&str] = &[
371 "connection",
372 "keep-alive",
373 "transfer-encoding",
374 "upgrade",
375 "proxy-authenticate",
376 "proxy-authorization",
377 "te",
378 "trailer",
379 "expires",
380 "pragma",
381 "cache-control",
382 ];
383 BLOCKED.iter().any(|b| name.eq_ignore_ascii_case(b))
384}
385
386/// Cache headers required on `info/refs`, which must never be cached.
387pub const NO_CACHE: &[(&str, &str)] = &[
388 ("expires", "Fri, 01 Jan 1980 00:00:00 GMT"),
389 ("pragma", "no-cache"),
390 ("cache-control", "no-cache, max-age=0, must-revalidate"),
391];
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn recognises_the_three_smart_http_endpoints() {
399 let refs = parse_route("/alice/site.git/info/refs", "service=git-upload-pack").unwrap();
400 assert_eq!(refs.account, "alice");
401 assert_eq!(refs.repo, "site");
402 assert_eq!(refs.path_info, "/alice/site.git/info/refs");
403 assert!(!refs.writes);
404
405 assert!(
406 !parse_route("/alice/site.git/git-upload-pack", "")
407 .unwrap()
408 .writes
409 );
410 assert!(
411 parse_route("/alice/site.git/git-receive-pack", "")
412 .unwrap()
413 .writes
414 );
415 }
416
417 #[test]
418 fn info_refs_for_receive_pack_requires_write_access() {
419 let route = parse_route("/alice/site.git/info/refs", "service=git-receive-pack").unwrap();
420 assert!(route.writes);
421 }
422
423 #[test]
424 fn a_duplicated_service_parameter_is_refused() {
425 // The CGI takes the last value and `find_map` would take the first, so a
426 // duplicate lets the authorization decision and the CGI disagree.
427 assert!(parse_route(
428 "/alice/site.git/info/refs",
429 "service=git-upload-pack&service=git-receive-pack"
430 )
431 .is_none());
432 }
433
434 #[test]
435 fn the_query_handed_to_the_cgi_is_rebuilt_not_forwarded() {
436 let route = parse_route(
437 "/alice/site.git/info/refs",
438 "service=git-upload-pack&extra=ignored",
439 )
440 .unwrap();
441 assert_eq!(route.query, "service=git-upload-pack");
442 }
443
444 #[test]
445 fn a_percent_encoded_service_is_decoded_before_the_decision() {
446 let route =
447 parse_route("/alice/site.git/info/refs", "service=git%2Dreceive%2Dpack").unwrap();
448 assert!(
449 route.writes,
450 "an encoded service name must not evade the write check"
451 );
452 }
453
454 #[test]
455 fn refuses_the_dumb_protocol_and_anything_else_under_the_git_dir() {
456 for (path, query) in [
457 ("/alice/site.git/objects/info/packs", ""),
458 ("/alice/site.git/HEAD", ""),
459 ("/alice/site.git/config", ""),
460 ("/alice/site.git/info/refs", ""),
461 ("/alice/site.git/info/refs", "service=git-evil"),
462 ("/alice/site.git/", ""),
463 ("/alice/site/info/refs", "service=git-upload-pack"),
464 ("/site.git/info/refs", "service=git-upload-pack"),
465 ] {
466 assert!(
467 parse_route(path, query).is_none(),
468 "{path:?}?{query:?} must not route"
469 );
470 }
471 }
472
473 #[test]
474 fn parses_a_cgi_head_with_a_status_directive() {
475 let (status, headers) = parse_head(b"Status: 404 Not Found\r\nContent-Type: text/plain");
476 assert_eq!(status, 404);
477 assert!(headers
478 .iter()
479 .any(|(n, v)| n == "Content-Type" && v == "text/plain"));
480 assert!(
481 !headers
482 .iter()
483 .any(|(n, _)| n.eq_ignore_ascii_case("status")),
484 "Status is a CGI directive, not a response header"
485 );
486 }
487
488 #[test]
489 fn defaults_to_200_when_the_cgi_sets_no_status() {
490 let (status, _) = parse_head(b"Content-Type: application/x-git-upload-pack-advertisement");
491 assert_eq!(status, 200);
492 }
493
494 #[test]
495 fn finds_the_header_terminator_in_both_line_endings() {
496 assert_eq!(find_header_end(b"A: b\r\n\r\nbody"), Some((4, 4)));
497 assert_eq!(find_header_end(b"A: b\n\nbody"), Some((4, 2)));
498 assert_eq!(find_header_end(b"A: b\r\nno terminator"), None);
499 }
500
501 #[test]
502 fn the_header_scan_is_bounded_so_a_binary_body_is_not_searched_end_to_end() {
503 let mut haystack = vec![b'x'; MAX_HEADER_BYTES * 4];
504 haystack.extend_from_slice(b"\r\n\r\n");
505 assert_eq!(
506 find_header_end(&haystack),
507 None,
508 "the scan must stop rather than walk a whole pack"
509 );
510 }
511
512 #[test]
513 fn cache_headers_from_the_cgi_are_suppressed_so_they_are_not_sent_twice() {
514 for name in ["Expires", "Pragma", "Cache-Control", "Transfer-Encoding"] {
515 assert!(is_suppressed(name), "{name} must not be forwarded");
516 }
517 assert!(!is_suppressed("Content-Type"));
518 }
519}