1use std::{io::ErrorKind, path::Path, process::Stdio, str::FromStr};
2
3use anyhow::{bail, Context};
4use axum::{
5 body::{boxed, Body},
6 extract::BodyStream,
7 headers::{ContentType, HeaderName, HeaderValue},
8 http::{Method, Uri},
9 response::Response,
10 Extension, TypedHeader,
11};
12use futures::TryStreamExt;
13use httparse::Status;
14use tokio_util::io::StreamReader;
15use tracing::warn;
16
17use crate::methods::repo::{Repository, RepositoryPath, Result};
18use crate::StatusCode;
19
20#[allow(clippy::unused_async)]
21pub async fn handle(
22 Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
23 Extension(Repository(repository)): Extension<Repository>,
24 method: Method,
25 uri: Uri,
26 content_type: Option<TypedHeader<ContentType>>,
27 body: BodyStream,
28) -> Result<Response> {
29 let path = extract_path(&uri, &repository)?;
30
31 let mut command = tokio::process::Command::new("git");
32
33 if let Some(content_type) = content_type {
34 command.env("CONTENT_TYPE", content_type.0.to_string());
35 }
36
37 let mut child = command
38 .arg("http-backend")
39 .env("REQUEST_METHOD", method.as_str())
40 .env("PATH_INFO", path)
41 .env("GIT_PROJECT_ROOT", repository_path)
42 .env("QUERY_STRING", uri.query().unwrap_or(""))
43 .stdin(Stdio::piped())
44 .stdout(Stdio::piped())
45 .stderr(Stdio::piped())
46 .spawn()
47 .context("Failed to spawn git http-backend")?;
48
49 {
50 let mut body =
51 StreamReader::new(body.map_err(|e| std::io::Error::new(ErrorKind::Other, e)));
52 let mut stdin = child.stdin.take().context("Stdin already taken")?;
53
54 tokio::io::copy(&mut body, &mut stdin)
55 .await
56 .context("Failed to copy bytes from request to command stdin")?;
57 }
58
59 let out = child
60 .wait_with_output()
61 .await
62 .context("Failed to read git http-backend response")?;
63 let resp = cgi_to_response(&out.stdout)?;
64
65 if !out.stderr.is_empty() {
66 warn!(
67 "Git returned an error: `{}`",
68 String::from_utf8_lossy(&out.stderr)
69 );
70 }
71
72 Ok(resp)
73}
74
75fn extract_path<'a>(uri: &'a Uri, repository: &Path) -> Result<&'a str> {
76 let path = uri.path();
77 let path = path.strip_prefix('/').unwrap_or(path);
78
79 if let Some(prefix) = repository.as_os_str().to_str() {
80 Ok(path.strip_prefix(prefix).unwrap_or(path))
81 } else {
82 Err(anyhow::Error::msg("Repository name contains invalid bytes").into())
83 }
84}
85
86// https://en.wikipedia.org/wiki/Common_Gateway_Interface
87pub fn cgi_to_response(buffer: &[u8]) -> Result<Response, anyhow::Error> {
88 let mut headers = [httparse::EMPTY_HEADER; 10];
89 let (body_offset, headers) = match httparse::parse_headers(buffer, &mut headers)? {
90 Status::Complete(v) => v,
91 Status::Partial => bail!("Git returned a partial response over CGI"),
92 };
93
94 let mut response = Response::new(boxed(Body::from(buffer[body_offset..].to_vec())));
95
96 // TODO: extract status header
97 for header in headers {
98 response.headers_mut().insert(
99 HeaderName::from_str(header.name)
100 .context("Failed to parse header name from Git over CGI")?,
101 HeaderValue::from_bytes(header.value)
102 .context("Failed to parse header value from Git over CGI")?,
103 );
104 }
105
106 if let Some(status) = response
107 .headers_mut()
108 .remove("Status")
109 .filter(|s| s.len() >= 3)
110 {
111 let status = &status.as_ref()[..3];
112
113 if let Ok(status) = StatusCode::from_bytes(status) {
114 *response.status_mut() = status;
115 }
116 }
117
118 Ok(response)
119}