steid

@jamesgill /

Rust15.3 KBCode·Blame·History·Raw
1//! The git smart-HTTP protocol, served by `git http-backend`, and its in-memory double.
2
3use std::{
4 path::PathBuf,
5 process::Stdio,
6 sync::{Arc, Mutex},
7};
8
9use tokio::{
10 io::{AsyncBufReadExt, AsyncReadExt, BufReader},
11 process::ChildStdout,
12};
13
14use crate::application::port::{
15 GitMethod, GitProtocolError, GitProtocolServer, GitRequest, GitResponse,
16};
17
18use super::{count_spawn, git_command};
19
20/// The most CGI headers `git http-backend` will ever emit, with room to spare.
21///
22/// A guard rather than a real expectation: the header block is read before anything is
23/// streamed, and an unbounded read of a subprocess's stdout is a hang waiting to happen.
24const MAX_CGI_HEADERS: usize = 64;
25
26/// The git smart-HTTP protocol, served by `git http-backend`.
27///
28/// The binary is a CGI: it takes an environment and a request body on stdin, and writes
29/// CRLF-terminated headers, a blank line, then the response body. Its contract was
30/// probed rather than assumed — see `plans/progress.md` under Milestone 4a.
31#[derive(Debug, Clone)]
32pub struct GitHttpBackend {
33 data_dir: PathBuf,
34}
35
36impl GitHttpBackend {
37 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
38 Self {
39 data_dir: data_dir.into(),
40 }
41 }
42}
43
44impl GitProtocolServer for GitHttpBackend {
45 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
46 let mut command = git_command();
47
48 // Before the subcommand: `git -c … http-backend`. Pushes are refused by the
49 // backend unless this says otherwise, and it is set only for a request the use
50 // case already authorized — so a bug in Steid's rules meets git's refusal rather
51 // than an open door.
52 if request.allow_receive_pack {
53 command.arg("-c").arg("http.receivepack=true");
54 }
55
56 command
57 .arg("http-backend")
58 .env("GIT_PROJECT_ROOT", &self.data_dir)
59 // Steid decides visibility from the `repositories` table, in the use case.
60 // Without this, git applies its own rule and refuses everything lacking a
61 // `git-daemon-export-ok` marker file — a second source of truth for the same
62 // question, free to drift from the first.
63 .env("GIT_HTTP_EXPORT_ALL", "1")
64 .env("PATH_INFO", &request.path_info)
65 .env("QUERY_STRING", &request.query)
66 .env("REQUEST_METHOD", request.method.as_str())
67 .stdin(Stdio::piped())
68 .stdout(Stdio::piped())
69 .stderr(Stdio::piped());
70
71 count_spawn(&command);
72
73 // CGI gives only Content-Type and Content-Length unprefixed names; every other
74 // request header arrives `HTTP_`-prefixed. Passing `CONTENT_ENCODING` instead of
75 // `HTTP_CONTENT_ENCODING` makes the backend hand a still-gzipped body to
76 // upload-pack, and the client reports `expected 'packfile'` with nothing naming
77 // the cause. Measured, not guessed.
78 // `REMOTE_USER` is CGI's name for the authenticated caller, and git reads it to
79 // write `pusher <name>` into the reflog. Unprefixed like `CONTENT_TYPE`, because
80 // it is a CGI variable rather than a forwarded request header — and it comes
81 // from Steid's own authentication, never from anything the client sent.
82 for (variable, value) in [
83 ("CONTENT_TYPE", &request.content_type),
84 ("CONTENT_LENGTH", &request.content_length),
85 ("HTTP_CONTENT_ENCODING", &request.content_encoding),
86 ("HTTP_GIT_PROTOCOL", &request.git_protocol),
87 ("REMOTE_USER", &request.remote_user),
88 ] {
89 if let Some(value) = value {
90 command.env(variable, value);
91 }
92 }
93
94 let mut child = command
95 .spawn()
96 .map_err(|error| GitProtocolError::new(format!("could not run git: {error}")))?;
97
98 let mut stdin = child.stdin.take().expect("stdin was piped");
99 let stdout = child.stdout.take().expect("stdout was piped");
100 let mut stderr = child.stderr.take().expect("stderr was piped");
101 let mut body = request.body;
102
103 // The request body streams in while the response streams out; a push is far too
104 // large to buffer, and a fetch would otherwise wait for a body it already has.
105 // Dropping stdin closes the pipe, which is what tells the backend the request is
106 // complete — an error here is the client having gone away, which the backend
107 // then sees as EOF.
108 tokio::spawn(async move {
109 let _ = tokio::io::copy(&mut body, &mut stdin).await;
110 });
111
112 // Reaps the child and surfaces its complaint. This cannot gate the response: a
113 // protocol failure exits non-zero *after* a complete, successful-looking header
114 // block has already been written, so by the time the status is known it has been
115 // sent. Draining stderr is not optional either — an unread pipe fills and blocks
116 // the backend mid-transfer.
117 tokio::spawn(async move {
118 let mut complaint = String::new();
119 let _ = stderr.read_to_string(&mut complaint).await;
120
121 match child.wait().await {
122 Ok(status) if status.success() => {}
123 Ok(status) => eprintln!(
124 "steid: git http-backend exited with {status}: {}",
125 complaint.trim()
126 ),
127 Err(error) => eprintln!("steid: could not wait for git http-backend: {error}"),
128 }
129 });
130
131 // `BufReader` keeps whatever it read past the header block, and handing the
132 // reader itself back as the body is what makes that safe — the first bytes of
133 // the pack are already buffered inside it.
134 let mut reader = BufReader::new(stdout);
135 let (status, headers) = read_cgi_headers(&mut reader).await?;
136
137 Ok(GitResponse {
138 status,
139 headers,
140 body: Box::pin(reader),
141 })
142 }
143}
144
145/// Reads the CGI header block, stopping at the blank line that ends it.
146///
147/// `Status:` is git's way of reporting failure and appears only then, so its absence
148/// means 200. It is translated into the response status rather than forwarded as a
149/// header, which would be meaningless to a client.
150async fn read_cgi_headers(
151 reader: &mut BufReader<ChildStdout>,
152) -> Result<(u16, Vec<(String, String)>), GitProtocolError> {
153 let mut status = 200;
154 let mut headers = Vec::new();
155 let mut line = Vec::new();
156
157 loop {
158 line.clear();
159
160 let read = reader
161 .read_until(b'\n', &mut line)
162 .await
163 .map_err(|error| GitProtocolError::new(format!("reading git's headers: {error}")))?;
164
165 if read == 0 {
166 return Err(GitProtocolError::new(
167 "git http-backend produced no headers before closing",
168 ));
169 }
170
171 // Tolerates a bare LF as well as the CRLF actually observed: a header reader
172 // that hangs on an unexpected line ending is a bad way to find out.
173 let text = String::from_utf8_lossy(&line);
174 let text = text.trim_end_matches(['\r', '\n']);
175
176 if text.is_empty() {
177 return Ok((status, headers));
178 }
179
180 let Some((name, value)) = text.split_once(": ") else {
181 return Err(GitProtocolError::new(format!(
182 "git http-backend wrote an unparseable header: {text:?}"
183 )));
184 };
185
186 if name.eq_ignore_ascii_case("status") {
187 status = value
188 .split_whitespace()
189 .next()
190 .and_then(|code| code.parse().ok())
191 .ok_or_else(|| {
192 GitProtocolError::new(format!("git http-backend wrote a bad status: {value:?}"))
193 })?;
194 } else {
195 headers.push((name.to_owned(), value.to_owned()));
196 }
197
198 if headers.len() > MAX_CGI_HEADERS {
199 return Err(GitProtocolError::new(
200 "git http-backend wrote more headers than a CGI response can plausibly have",
201 ));
202 }
203 }
204}
205
206/// What a [`InMemoryGitProtocol`] was asked for, minus the body.
207///
208/// The body is a stream and comparing it would mean draining it; every rule worth
209/// asserting on lives in the metadata anyway.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct RecordedGitRequest {
212 pub method: GitMethod,
213 pub path_info: String,
214 pub query: String,
215 pub git_protocol: Option<String>,
216 pub content_encoding: Option<String>,
217 pub allow_receive_pack: bool,
218 pub remote_user: Option<String>,
219}
220
221/// A git protocol that records what it was asked and never runs git.
222///
223/// The counterpart to [`GitHttpBackend`]. What it is really for is proving a negative:
224/// that a use case refused *before* reaching the protocol. `was_called` is how a test
225/// says "and no bytes flowed".
226#[derive(Debug, Default, Clone)]
227pub struct InMemoryGitProtocol {
228 requests: Arc<Mutex<Vec<RecordedGitRequest>>>,
229}
230
231impl InMemoryGitProtocol {
232 pub fn new() -> Self {
233 Self::default()
234 }
235
236 pub fn requests(&self) -> Vec<RecordedGitRequest> {
237 self.requests.lock().expect("lock poisoned").clone()
238 }
239
240 /// Whether the protocol was reached at all.
241 pub fn was_called(&self) -> bool {
242 !self.requests.lock().expect("lock poisoned").is_empty()
243 }
244}
245
246impl GitProtocolServer for InMemoryGitProtocol {
247 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
248 self.requests
249 .lock()
250 .expect("lock poisoned")
251 .push(RecordedGitRequest {
252 method: request.method,
253 path_info: request.path_info,
254 query: request.query,
255 git_protocol: request.git_protocol,
256 content_encoding: request.content_encoding,
257 allow_receive_pack: request.allow_receive_pack,
258 remote_user: request.remote_user,
259 });
260
261 Ok(GitResponse {
262 status: 200,
263 headers: vec![(
264 "Content-Type".to_owned(),
265 "application/x-git-upload-pack-advertisement".to_owned(),
266 )],
267 body: Box::pin(std::io::Cursor::new(b"0000".to_vec())),
268 })
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use tempfile::TempDir;
275
276 use super::*;
277 use crate::application::port::GitStorage;
278 use crate::domain::OrgName;
279 use crate::infrastructure::git::{fixture::repo_name, storage::DiskGitStorage};
280
281 // --- GitHttpBackend --------------------------------------------------------
282
283 /// A data directory holding one bare repository at `acme/steid.git`.
284 async fn backend() -> (TempDir, GitHttpBackend) {
285 let dir = TempDir::new().expect("temp dir");
286 let storage = DiskGitStorage::new(dir.path());
287
288 let acme = OrgName::new("acme").expect("valid handle");
289 storage
290 .init_bare(&acme, &repo_name("steid"))
291 .await
292 .expect("init bare");
293
294 let backend = GitHttpBackend::new(dir.path());
295 (dir, backend)
296 }
297
298 fn advertisement(path_info: &str) -> GitRequest {
299 GitRequest {
300 method: GitMethod::Get,
301 path_info: path_info.to_owned(),
302 query: "service=git-upload-pack".to_owned(),
303 content_type: None,
304 content_encoding: None,
305 content_length: None,
306 git_protocol: None,
307 allow_receive_pack: false,
308 remote_user: None,
309 body: Box::pin(tokio::io::empty()),
310 }
311 }
312
313 async fn drain(response: GitResponse) -> Vec<u8> {
314 let mut body = response.body;
315 let mut bytes = Vec::new();
316 body.read_to_end(&mut bytes).await.expect("read body");
317 bytes
318 }
319
320 #[tokio::test]
321 async fn the_backend_advertises_refs() {
322 let (_dir, backend) = backend().await;
323
324 let response = backend
325 .serve(advertisement("/acme/steid.git/info/refs"))
326 .await
327 .expect("should serve");
328
329 assert_eq!(response.status, 200);
330 assert!(
331 response
332 .headers
333 .iter()
334 .any(|(name, value)| name == "Content-Type"
335 && value == "application/x-git-upload-pack-advertisement"),
336 "git sets its own content type and we forward it: {:?}",
337 response.headers
338 );
339
340 // The pkt-line the smart protocol opens with. Getting this from git rather than
341 // writing it is the whole reason the backend is a subprocess.
342 let body = drain(response).await;
343 assert!(
344 body.starts_with(b"001e# service=git-upload-pack\n"),
345 "unexpected advertisement: {:?}",
346 String::from_utf8_lossy(&body[..body.len().min(40)])
347 );
348 }
349
350 #[tokio::test]
351 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
352 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
353 // says 404 in a header. Keying off the exit code instead would answer 200.
354 let (_dir, backend) = backend().await;
355
356 let response = backend
357 .serve(advertisement("/acme/nothing-here.git/info/refs"))
358 .await
359 .expect("serving should not itself fail");
360
361 assert_eq!(response.status, 404);
362 }
363
364 #[tokio::test]
365 async fn the_status_header_is_translated_rather_than_forwarded() {
366 let (_dir, backend) = backend().await;
367
368 let response = backend
369 .serve(advertisement("/acme/nothing-here.git/info/refs"))
370 .await
371 .expect("should serve");
372
373 assert!(
374 !response
375 .headers
376 .iter()
377 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
378 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
379 response.headers
380 );
381 }
382
383 #[tokio::test]
384 async fn the_protocol_version_reaches_upload_pack() {
385 // Protocol v2 answers an advertisement with a capability list rather than refs.
386 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
387 // still works — so nothing fails, it just quietly gets worse.
388 let (_dir, backend) = backend().await;
389
390 let mut request = advertisement("/acme/steid.git/info/refs");
391 request.git_protocol = Some("version=2".to_owned());
392
393 let body = drain(backend.serve(request).await.expect("should serve")).await;
394
395 assert!(
396 String::from_utf8_lossy(&body).contains("version 2"),
397 "expected a v2 capability advertisement: {:?}",
398 String::from_utf8_lossy(&body[..body.len().min(80)])
399 );
400 }
401
402 #[tokio::test]
403 async fn the_in_memory_protocol_records_what_it_was_asked() {
404 let protocol = InMemoryGitProtocol::new();
405
406 protocol
407 .serve(advertisement("/acme/steid.git/info/refs"))
408 .await
409 .expect("should serve");
410
411 assert_eq!(
412 protocol.requests(),
413 vec![RecordedGitRequest {
414 method: GitMethod::Get,
415 path_info: "/acme/steid.git/info/refs".to_owned(),
416 query: "service=git-upload-pack".to_owned(),
417 git_protocol: None,
418 content_encoding: None,
419 allow_receive_pack: false,
420 remote_user: None,
421 }]
422 );
423 assert!(protocol.was_called());
424 }
425}