1use std::{io, io::ErrorKind, path::Path, process::Stdio, str::FromStr};
2
3use anyhow::{anyhow, Context};
4use axum::{
5 body::Body,
6 http::{
7 header::{HeaderMap, HeaderName, HeaderValue},
8 Method, Uri,
9 },
10 response::{IntoResponse, Response},
11 Extension,
12};
13use bytes::{Buf, Bytes, BytesMut};
14use futures_util::TryStreamExt;
15use httparse::Status;
16use tokio::{
17 io::AsyncReadExt,
18 process::{Child, ChildStderr, ChildStdout, Command},
19 sync::mpsc,
20};
21use tokio_stream::wrappers::ReceiverStream;
22use tokio_util::io::StreamReader;
23use tracing::{debug, error, info_span, warn, Instrument};
24
25use crate::{
26 methods::repo::{Repository, RepositoryPath, Result},
27 StatusCode,
28};
29
30#[allow(clippy::unused_async)]
31pub async fn handle(
32 Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
33 Extension(Repository(repository)): Extension<Repository>,
34 method: Method,
35 uri: Uri,
36 headers: HeaderMap,
37 body: Body,
38) -> Result<impl IntoResponse> {
39 let path = extract_path(&uri, &repository)?;
40
41 let mut command = Command::new("git");
42
43 for (header, env) in [
44 ("Content-Type", "CONTENT_TYPE"),
45 ("Content-Length", "CONTENT_LENGTH"),
46 ("Git-Protocol", "GIT_PROTOCOL"),
47 ("Content-Encoding", "HTTP_CONTENT_ENCODING"),
48 ] {
49 extract_header(&headers, &mut command, header, env)?;
50 }
51
52 let mut child = command
53 .arg("http-backend")
54 .env("REQUEST_METHOD", method.as_str())
55 .env("PATH_INFO", path)
56 .env("GIT_PROJECT_ROOT", repository_path)
57 .env("QUERY_STRING", uri.query().unwrap_or(""))
58 .stdin(Stdio::piped())
59 .stdout(Stdio::piped())
60 .stderr(Stdio::piped())
61 .kill_on_drop(true)
62 .spawn()
63 .context("Failed to spawn git http-backend")?;
64
65 let mut stdout = child.stdout.take().context("Stdout already taken")?;
66 let mut stderr = child.stderr.take().context("Stderr already taken")?;
67 let mut stdin = child.stdin.take().context("Stdin already taken")?;
68
69 // read request body and forward to stdin
70 let mut body = StreamReader::new(
71 body.into_data_stream()
72 .map_err(|e| std::io::Error::new(ErrorKind::Other, e)),
73 );
74 tokio::io::copy_buf(&mut body, &mut stdin)
75 .await
76 .context("Failed to copy bytes from request to command stdin")?;
77
78 // wait for the headers back from git http-backend
79 let mut out_buf = BytesMut::with_capacity(1024);
80 let headers = loop {
81 let n = stdout
82 .read_buf(&mut out_buf)
83 .await
84 .context("Failed to read headers")?;
85 if n == 0 {
86 break None;
87 }
88
89 if let Some((body_offset, headers)) = parse_cgi_headers(&out_buf)? {
90 out_buf.advance(body_offset);
91 break Some(headers);
92 }
93 };
94
95 // if the `headers` loop broke with `None`, the `git http-backend` didn't return any parseable
96 // headers so there's no reason for us to continue. there may be something in stderr for us
97 // though.
98 let Some(headers) = headers else {
99 print_status(&mut child, &mut stderr).await;
100 return Err(anyhow!("Received incomplete response from git http-backend").into());
101 };
102
103 // stream the response back to the client
104 let (body_send, body_recv) = mpsc::channel(8);
105 tokio::spawn(
106 forward_response_to_client(out_buf, body_send, stdout, stderr, child)
107 .instrument(info_span!("git http-backend reader")),
108 );
109
110 Ok((headers, Body::from_stream(ReceiverStream::new(body_recv))))
111}
112
113/// Forwards the entirety of `stdout` to `body_send`, printing subprocess stderr and status on
114/// completion.
115async fn forward_response_to_client(
116 mut out_buf: BytesMut,
117 body_send: mpsc::Sender<Result<Bytes, io::Error>>,
118 mut stdout: ChildStdout,
119 mut stderr: ChildStderr,
120 mut child: Child,
121) {
122 loop {
123 let (out, mut end) = match stdout.read_buf(&mut out_buf).await {
124 Ok(0) => (Ok(out_buf.split().freeze()), true),
125 Ok(n) => (Ok(out_buf.split_to(n).freeze()), false),
126 Err(e) => (Err(e), true),
127 };
128
129 if body_send.send(out).await.is_err() {
130 warn!("Receiver went away during git http-backend call");
131 end = true;
132 }
133
134 if end {
135 break;
136 }
137 }
138
139 print_status(&mut child, &mut stderr).await;
140}
141
142/// Prints the exit status of the `git` subprocess.
143async fn print_status(child: &mut Child, stderr: &mut ChildStderr) {
144 match tokio::try_join!(child.wait(), read_stderr(stderr)) {
145 Ok((status, stderr)) if status.success() => {
146 debug!(stderr, "git http-backend successfully shutdown");
147 }
148 Ok((status, stderr)) => error!(stderr, "git http-backend exited with status code {status}"),
149 Err(e) => error!("Failed to wait on git http-backend shutdown: {e}"),
150 }
151}
152
153/// Reads the entirety of stderr for the given handle.
154async fn read_stderr(stderr: &mut ChildStderr) -> io::Result<String> {
155 let mut stderr_out = Vec::new();
156 stderr.read_to_end(&mut stderr_out).await?;
157 Ok(String::from_utf8_lossy(&stderr_out).into_owned())
158}
159
160/// Extracts a single header (`header`) from the `input` and passes it as `env` to
161/// `output`.
162fn extract_header(input: &HeaderMap, output: &mut Command, header: &str, env: &str) -> Result<()> {
163 if let Some(value) = input.get(header) {
164 output.env(env, value.to_str().context("Invalid header")?);
165 }
166
167 Ok(())
168}
169
170/// Extract the path from the URL to determine the repository path.
171fn extract_path<'a>(uri: &'a Uri, repository: &Path) -> Result<&'a str> {
172 let path = uri.path();
173 let path = path.strip_prefix('/').unwrap_or(path);
174
175 if let Some(prefix) = repository.as_os_str().to_str() {
176 Ok(path.strip_prefix(prefix).unwrap_or(path))
177 } else {
178 Err(anyhow::Error::msg("Repository name contains invalid bytes").into())
179 }
180}
181
182// Intercept headers from the spawned `git http-backend` CGI and rewrite them to
183// an `axum::Response`.
184pub fn parse_cgi_headers(buffer: &[u8]) -> Result<Option<(usize, Response<()>)>, anyhow::Error> {
185 let mut headers = [httparse::EMPTY_HEADER; 10];
186 let (body_offset, headers) = match httparse::parse_headers(buffer, &mut headers)? {
187 Status::Complete(v) => v,
188 Status::Partial => return Ok(None),
189 };
190
191 let mut response = Response::new(());
192
193 for header in headers {
194 response.headers_mut().insert(
195 HeaderName::from_str(header.name)
196 .context("Failed to parse header name from Git over CGI")?,
197 HeaderValue::from_bytes(header.value)
198 .context("Failed to parse header value from Git over CGI")?,
199 );
200 }
201
202 if let Some(status) = response
203 .headers_mut()
204 .remove("Status")
205 .filter(|s| s.len() >= 3)
206 {
207 let status = &status.as_ref()[..3];
208
209 if let Ok(status) = StatusCode::from_bytes(status) {
210 *response.status_mut() = status;
211 }
212 }
213
214 Ok(Some((body_offset, response)))
215}