steid

@jamesgill /

1//! The `git` binary, behind the ports the application declares.
2//!
3//! One module owns *how* Steid invokes git — see [`run_git`] — so that isolation and
4//! error handling cannot drift between call sites. Milestone 4's protocol commands
5//! belong here too rather than growing a second recipe.
6
7use std::{
8 collections::HashSet,
9 ffi::OsStr,
10 io,
11 path::PathBuf,
12 process::{Output, Stdio},
13 sync::{Arc, Mutex},
14};
15
16use tokio::{
17 io::{AsyncBufReadExt, AsyncReadExt, BufReader},
18 process::{ChildStdout, Command},
19};
20
21use crate::{
22 application::port::{
23 GitMethod, GitProtocolError, GitProtocolServer, GitRequest, GitResponse, GitStorage,
24 GitStorageError,
25 },
26 domain::{OrgName, RepoName},
27};
28
29/// The most CGI headers `git http-backend` will ever emit, with room to spare.
30///
31/// A guard rather than a real expectation: the header block is read before anything is
32/// streamed, and an unbounded read of a subprocess's stdout is a hang waiting to happen.
33const MAX_CGI_HEADERS: usize = 64;
34
35/// Environment variables that redirect where git reads and writes data.
36///
37/// Steid's own environment must not reach into a repository's layout. These are set
38/// whenever a process is spawned from inside a git hook, which is exactly the shape
39/// Milestone 5 will have, and the failure is silent — objects land somewhere else and
40/// the repository looks empty.
41const REDIRECTING_VARS: &[&str] = &[
42 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
43 "GIT_DIR",
44 "GIT_INDEX_FILE",
45 "GIT_OBJECT_DIRECTORY",
46 "GIT_WORK_TREE",
47];
48
49/// Bare repositories on disk, laid out as `{data_dir}/{handle}/{name}.git`.
50#[derive(Debug, Clone)]
51pub struct DiskGitStorage {
52 data_dir: PathBuf,
53}
54
55impl DiskGitStorage {
56 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
57 Self {
58 data_dir: data_dir.into(),
59 }
60 }
61}
62
63impl GitStorage for DiskGitStorage {
64 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
65 let path = self.repo_path(handle, name);
66
67 // `git init` on an existing repository exits 0 and re-initialises in silence,
68 // so this check has to be ours. A directory with no matching record is an
69 // orphan from a create that died between the two writes; adopting it would
70 // resurface a private repository's objects under a fresh record.
71 if path.exists() {
72 return Err(GitStorageError::AlreadyExists);
73 }
74
75 // No `create_dir_all` for the parent: `git init` creates missing directories.
76 run_git([
77 OsStr::new("init"),
78 OsStr::new("--bare"),
79 OsStr::new("--quiet"),
80 // Skip the template directory, which otherwise seeds every repository with
81 // sixteen `.sample` hooks. Steid installs its own hooks later, and they
82 // would be noise to work around.
83 OsStr::new("--template="),
84 // Explicit, so the host's `init.defaultBranch` cannot decide what the
85 // default branch of a Steid repository is.
86 OsStr::new("--initial-branch=main"),
87 // `RepoName` already forbids a leading hyphen; this makes it impossible for
88 // a path to be read as a flag at the boundary where it costs nothing.
89 OsStr::new("--"),
90 path.as_os_str(),
91 ])
92 .await
93 .map(|_| ())
94 }
95
96 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
97 let path = self.repo_path(handle, name);
98
99 // `tokio::fs` rather than `std::fs`: removing a repository with real history
100 // walks every loose object, which is long enough to stall a runtime worker.
101 match tokio::fs::remove_dir_all(&path).await {
102 Ok(()) => Ok(()),
103 // Compensation must not fail because there was nothing left to undo.
104 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
105 Err(error) => Err(GitStorageError::backend(error)),
106 }
107 }
108
109 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
110 // Nothing is sanitised here. `OrgName` and `RepoName` already made traversal
111 // impossible, and re-checking at the call site is how that responsibility gets
112 // diffused until nobody owns it.
113 self.data_dir
114 .join(handle.as_str())
115 .join(format!("{name}.git"))
116 }
117}
118
119/// A `git` command isolated from the host.
120///
121/// The one place that decides what git inherits: no ambient configuration, no
122/// redirected object storage. Both the lifecycle commands and the protocol backend
123/// build on this, which is the point — 0006 exists because these flags are exactly what
124/// drifts silently between call sites.
125fn git_command() -> Command {
126 let mut command = Command::new("git");
127
128 // Host configuration must not leak into repositories Steid creates, for the same
129 // reason `--initial-branch` is passed explicitly.
130 command
131 .env("GIT_CONFIG_GLOBAL", "/dev/null")
132 .env("GIT_CONFIG_SYSTEM", "/dev/null");
133
134 for variable in REDIRECTING_VARS {
135 command.env_remove(variable);
136 }
137
138 command
139}
140
141/// Runs `git` and fails on a non-zero exit.
142///
143/// The single place that decides how Steid invokes git, so every call site gets the
144/// same isolation from the host: no ambient configuration, no redirected object
145/// storage, no inherited stdin. Never depends on the working directory — callers pass
146/// absolute paths.
147async fn run_git<I, S>(args: I) -> Result<Output, GitStorageError>
148where
149 I: IntoIterator<Item = S>,
150 S: AsRef<OsStr>,
151{
152 let mut command = git_command();
153 command.args(args).stdin(Stdio::null());
154
155 // `output()` pipes stdout and stderr and waits without blocking the runtime.
156 let output = command
157 .output()
158 .await
159 .map_err(|error| GitStorageError::backend(format!("could not run git: {error}")))?;
160
161 if !output.status.success() {
162 // Carry git's own words. "command failed" sends the next person to read this
163 // code instead of reading the error.
164 return Err(GitStorageError::backend(format!(
165 "git exited with {}: {}",
166 output.status,
167 String::from_utf8_lossy(&output.stderr).trim()
168 )));
169 }
170
171 Ok(output)
172}
173
174/// Bare repositories tracked in memory, for testing use cases without touching disk.
175///
176/// The counterpart to [`DiskGitStorage`], the way `StubHasher` is the counterpart to
177/// the real Argon2 hasher. It enforces the same `AlreadyExists` rule, because a use
178/// case that only passes against a permissive fake proves nothing about the real one.
179#[derive(Debug, Default, Clone)]
180pub struct InMemoryGitStorage {
181 created: Arc<Mutex<HashSet<PathBuf>>>,
182}
183
184impl InMemoryGitStorage {
185 pub fn new() -> Self {
186 Self::default()
187 }
188
189 /// Whether a repository exists, for assertions.
190 pub fn contains(&self, handle: &OrgName, name: &RepoName) -> bool {
191 self.created
192 .lock()
193 .expect("lock poisoned")
194 .contains(&self.repo_path(handle, name))
195 }
196
197 /// How many repositories exist, for asserting that nothing was created.
198 pub fn len(&self) -> usize {
199 self.created.lock().expect("lock poisoned").len()
200 }
201
202 pub fn is_empty(&self) -> bool {
203 self.len() == 0
204 }
205}
206
207impl GitStorage for InMemoryGitStorage {
208 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
209 let mut created = self.created.lock().expect("lock poisoned");
210
211 if !created.insert(self.repo_path(handle, name)) {
212 return Err(GitStorageError::AlreadyExists);
213 }
214
215 Ok(())
216 }
217
218 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
219 self.created
220 .lock()
221 .expect("lock poisoned")
222 .remove(&self.repo_path(handle, name));
223
224 Ok(())
225 }
226
227 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
228 PathBuf::from("/in-memory")
229 .join(handle.as_str())
230 .join(format!("{name}.git"))
231 }
232}
233
234/// The git smart-HTTP protocol, served by `git http-backend`.
235///
236/// The binary is a CGI: it takes an environment and a request body on stdin, and writes
237/// CRLF-terminated headers, a blank line, then the response body. Its contract was
238/// probed rather than assumed — see `plans/progress.md` under Milestone 4a.
239#[derive(Debug, Clone)]
240pub struct GitHttpBackend {
241 data_dir: PathBuf,
242}
243
244impl GitHttpBackend {
245 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
246 Self {
247 data_dir: data_dir.into(),
248 }
249 }
250}
251
252impl GitProtocolServer for GitHttpBackend {
253 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
254 let mut command = git_command();
255 command
256 .arg("http-backend")
257 .env("GIT_PROJECT_ROOT", &self.data_dir)
258 // Steid decides visibility from the `repositories` table, in the use case.
259 // Without this, git applies its own rule and refuses everything lacking a
260 // `git-daemon-export-ok` marker file — a second source of truth for the same
261 // question, free to drift from the first.
262 .env("GIT_HTTP_EXPORT_ALL", "1")
263 .env("PATH_INFO", &request.path_info)
264 .env("QUERY_STRING", &request.query)
265 .env("REQUEST_METHOD", request.method.as_str())
266 .stdin(Stdio::piped())
267 .stdout(Stdio::piped())
268 .stderr(Stdio::piped());
269
270 // CGI gives only Content-Type and Content-Length unprefixed names; every other
271 // request header arrives `HTTP_`-prefixed. Passing `CONTENT_ENCODING` instead of
272 // `HTTP_CONTENT_ENCODING` makes the backend hand a still-gzipped body to
273 // upload-pack, and the client reports `expected 'packfile'` with nothing naming
274 // the cause. Measured, not guessed.
275 for (variable, value) in [
276 ("CONTENT_TYPE", &request.content_type),
277 ("CONTENT_LENGTH", &request.content_length),
278 ("HTTP_CONTENT_ENCODING", &request.content_encoding),
279 ("HTTP_GIT_PROTOCOL", &request.git_protocol),
280 ] {
281 if let Some(value) = value {
282 command.env(variable, value);
283 }
284 }
285
286 let mut child = command
287 .spawn()
288 .map_err(|error| GitProtocolError::new(format!("could not run git: {error}")))?;
289
290 let mut stdin = child.stdin.take().expect("stdin was piped");
291 let stdout = child.stdout.take().expect("stdout was piped");
292 let mut stderr = child.stderr.take().expect("stderr was piped");
293 let mut body = request.body;
294
295 // The request body streams in while the response streams out; a push is far too
296 // large to buffer, and a fetch would otherwise wait for a body it already has.
297 // Dropping stdin closes the pipe, which is what tells the backend the request is
298 // complete — an error here is the client having gone away, which the backend
299 // then sees as EOF.
300 tokio::spawn(async move {
301 let _ = tokio::io::copy(&mut body, &mut stdin).await;
302 });
303
304 // Reaps the child and surfaces its complaint. This cannot gate the response: a
305 // protocol failure exits non-zero *after* a complete, successful-looking header
306 // block has already been written, so by the time the status is known it has been
307 // sent. Draining stderr is not optional either — an unread pipe fills and blocks
308 // the backend mid-transfer.
309 tokio::spawn(async move {
310 let mut complaint = String::new();
311 let _ = stderr.read_to_string(&mut complaint).await;
312
313 match child.wait().await {
314 Ok(status) if status.success() => {}
315 Ok(status) => eprintln!(
316 "steid: git http-backend exited with {status}: {}",
317 complaint.trim()
318 ),
319 Err(error) => eprintln!("steid: could not wait for git http-backend: {error}"),
320 }
321 });
322
323 // `BufReader` keeps whatever it read past the header block, and handing the
324 // reader itself back as the body is what makes that safe — the first bytes of
325 // the pack are already buffered inside it.
326 let mut reader = BufReader::new(stdout);
327 let (status, headers) = read_cgi_headers(&mut reader).await?;
328
329 Ok(GitResponse {
330 status,
331 headers,
332 body: Box::pin(reader),
333 })
334 }
335}
336
337/// Reads the CGI header block, stopping at the blank line that ends it.
338///
339/// `Status:` is git's way of reporting failure and appears only then, so its absence
340/// means 200. It is translated into the response status rather than forwarded as a
341/// header, which would be meaningless to a client.
342async fn read_cgi_headers(
343 reader: &mut BufReader<ChildStdout>,
344) -> Result<(u16, Vec<(String, String)>), GitProtocolError> {
345 let mut status = 200;
346 let mut headers = Vec::new();
347 let mut line = Vec::new();
348
349 loop {
350 line.clear();
351
352 let read = reader
353 .read_until(b'\n', &mut line)
354 .await
355 .map_err(|error| GitProtocolError::new(format!("reading git's headers: {error}")))?;
356
357 if read == 0 {
358 return Err(GitProtocolError::new(
359 "git http-backend produced no headers before closing",
360 ));
361 }
362
363 // Tolerates a bare LF as well as the CRLF actually observed: a header reader
364 // that hangs on an unexpected line ending is a bad way to find out.
365 let text = String::from_utf8_lossy(&line);
366 let text = text.trim_end_matches(['\r', '\n']);
367
368 if text.is_empty() {
369 return Ok((status, headers));
370 }
371
372 let Some((name, value)) = text.split_once(": ") else {
373 return Err(GitProtocolError::new(format!(
374 "git http-backend wrote an unparseable header: {text:?}"
375 )));
376 };
377
378 if name.eq_ignore_ascii_case("status") {
379 status = value
380 .split_whitespace()
381 .next()
382 .and_then(|code| code.parse().ok())
383 .ok_or_else(|| {
384 GitProtocolError::new(format!("git http-backend wrote a bad status: {value:?}"))
385 })?;
386 } else {
387 headers.push((name.to_owned(), value.to_owned()));
388 }
389
390 if headers.len() > MAX_CGI_HEADERS {
391 return Err(GitProtocolError::new(
392 "git http-backend wrote more headers than a CGI response can plausibly have",
393 ));
394 }
395 }
396}
397
398/// What a [`InMemoryGitProtocol`] was asked for, minus the body.
399///
400/// The body is a stream and comparing it would mean draining it; every rule worth
401/// asserting on lives in the metadata anyway.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct RecordedGitRequest {
404 pub method: GitMethod,
405 pub path_info: String,
406 pub query: String,
407 pub git_protocol: Option<String>,
408 pub content_encoding: Option<String>,
409}
410
411/// A git protocol that records what it was asked and never runs git.
412///
413/// The counterpart to [`GitHttpBackend`]. What it is really for is proving a negative:
414/// that a use case refused *before* reaching the protocol. `was_called` is how a test
415/// says "and no bytes flowed".
416#[derive(Debug, Default, Clone)]
417pub struct InMemoryGitProtocol {
418 requests: Arc<Mutex<Vec<RecordedGitRequest>>>,
419}
420
421impl InMemoryGitProtocol {
422 pub fn new() -> Self {
423 Self::default()
424 }
425
426 pub fn requests(&self) -> Vec<RecordedGitRequest> {
427 self.requests.lock().expect("lock poisoned").clone()
428 }
429
430 /// Whether the protocol was reached at all.
431 pub fn was_called(&self) -> bool {
432 !self.requests.lock().expect("lock poisoned").is_empty()
433 }
434}
435
436impl GitProtocolServer for InMemoryGitProtocol {
437 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
438 self.requests
439 .lock()
440 .expect("lock poisoned")
441 .push(RecordedGitRequest {
442 method: request.method,
443 path_info: request.path_info,
444 query: request.query,
445 git_protocol: request.git_protocol,
446 content_encoding: request.content_encoding,
447 });
448
449 Ok(GitResponse {
450 status: 200,
451 headers: vec![(
452 "Content-Type".to_owned(),
453 "application/x-git-upload-pack-advertisement".to_owned(),
454 )],
455 body: Box::pin(std::io::Cursor::new(b"0000".to_vec())),
456 })
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use std::path::Path;
463
464 use tempfile::TempDir;
465
466 use super::*;
467
468 /// The `TempDir` is returned alongside the storage because dropping it deletes the
469 /// data directory — binding it to `_` would remove the fixture mid-test.
470 fn storage() -> (TempDir, DiskGitStorage) {
471 let dir = TempDir::new().expect("temp dir");
472 let storage = DiskGitStorage::new(dir.path());
473 (dir, storage)
474 }
475
476 fn handle() -> OrgName {
477 OrgName::new("jamesgill").expect("valid handle")
478 }
479
480 fn repo_name(value: &str) -> RepoName {
481 RepoName::new(value).expect("valid repository name")
482 }
483
484 /// Asks git about a repository, so assertions test what git believes rather than
485 /// what the directory looks like.
486 fn git_says(path: &Path, args: &[&str]) -> String {
487 let output = std::process::Command::new("git")
488 .arg("-C")
489 .arg(path)
490 .args(args)
491 .output()
492 .expect("git should be on PATH");
493
494 assert!(
495 output.status.success(),
496 "git {args:?} failed: {}",
497 String::from_utf8_lossy(&output.stderr)
498 );
499
500 String::from_utf8_lossy(&output.stdout).trim().to_owned()
501 }
502
503 #[tokio::test]
504 async fn init_bare_creates_a_bare_repository() {
505 let (_dir, storage) = storage();
506
507 storage
508 .init_bare(&handle(), &repo_name("steid"))
509 .await
510 .expect("should create");
511
512 let path = storage.repo_path(&handle(), &repo_name("steid"));
513 assert!(path.is_dir(), "expected a repository at {path:?}");
514 assert_eq!(
515 git_says(&path, &["rev-parse", "--is-bare-repository"]),
516 "true"
517 );
518 }
519
520 #[tokio::test]
521 async fn a_new_repository_is_empty() {
522 // Empty, like GitHub: no initial commit and no branch yet.
523 let (_dir, storage) = storage();
524 storage
525 .init_bare(&handle(), &repo_name("steid"))
526 .await
527 .expect("should create");
528
529 let path = storage.repo_path(&handle(), &repo_name("steid"));
530
531 assert_eq!(git_says(&path, &["for-each-ref"]), "");
532 }
533
534 #[tokio::test]
535 async fn a_new_repository_defaults_to_main() {
536 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
537 // agrees on this machine, which is exactly why a drift would go unnoticed.
538 let (_dir, storage) = storage();
539 storage
540 .init_bare(&handle(), &repo_name("steid"))
541 .await
542 .expect("should create");
543
544 let path = storage.repo_path(&handle(), &repo_name("steid"));
545
546 assert_eq!(
547 git_says(&path, &["symbolic-ref", "HEAD"]),
548 "refs/heads/main"
549 );
550 }
551
552 #[tokio::test]
553 async fn no_sample_hooks_are_installed() {
554 // Pins `--template=`. A default init seeds sixteen `.sample` files.
555 let (_dir, storage) = storage();
556 storage
557 .init_bare(&handle(), &repo_name("steid"))
558 .await
559 .expect("should create");
560
561 let hooks = storage
562 .repo_path(&handle(), &repo_name("steid"))
563 .join("hooks");
564
565 let samples = std::fs::read_dir(&hooks)
566 .map(|entries| entries.count())
567 .unwrap_or(0);
568 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
569 }
570
571 #[tokio::test]
572 async fn init_bare_creates_the_handle_directory() {
573 let (dir, storage) = storage();
574 assert!(!dir.path().join("jamesgill").exists());
575
576 storage
577 .init_bare(&handle(), &repo_name("steid"))
578 .await
579 .expect("should create");
580
581 assert!(dir.path().join("jamesgill").is_dir());
582 }
583
584 #[tokio::test]
585 async fn one_handle_can_own_several_repositories() {
586 let (_dir, storage) = storage();
587
588 for name in ["steid", "foo.js", ".github"] {
589 storage
590 .init_bare(&handle(), &repo_name(name))
591 .await
592 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
593 }
594
595 for name in ["steid", "foo.js", ".github"] {
596 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
597 }
598 }
599
600 #[tokio::test]
601 async fn init_bare_refuses_a_repository_that_already_exists() {
602 let (_dir, storage) = storage();
603 storage
604 .init_bare(&handle(), &repo_name("steid"))
605 .await
606 .expect("should create");
607
608 let error = storage
609 .init_bare(&handle(), &repo_name("steid"))
610 .await
611 .expect_err("should refuse");
612
613 assert!(matches!(error, GitStorageError::AlreadyExists));
614 }
615
616 #[tokio::test]
617 async fn a_refused_init_leaves_the_existing_repository_alone() {
618 // git would happily re-initialise in place. The point of refusing is that
619 // whatever is already there is not touched.
620 let (_dir, storage) = storage();
621 storage
622 .init_bare(&handle(), &repo_name("steid"))
623 .await
624 .expect("should create");
625
626 let path = storage.repo_path(&handle(), &repo_name("steid"));
627 let marker = path.join("objects").join("marker");
628 std::fs::write(&marker, b"existing data").expect("write marker");
629
630 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
631
632 assert_eq!(
633 std::fs::read(&marker).expect("marker should survive"),
634 b"existing data"
635 );
636 }
637
638 #[tokio::test]
639 async fn repo_path_creates_nothing() {
640 let (dir, storage) = storage();
641
642 let path = storage.repo_path(&handle(), &repo_name("never-created"));
643
644 assert!(!path.exists());
645 assert_eq!(
646 std::fs::read_dir(dir.path())
647 .expect("data dir should exist")
648 .count(),
649 0,
650 "repo_path must be pure"
651 );
652 }
653
654 #[tokio::test]
655 async fn repo_path_lands_under_the_data_directory() {
656 let (dir, storage) = storage();
657
658 let path = storage.repo_path(&handle(), &repo_name("steid"));
659
660 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
661 }
662
663 #[tokio::test]
664 async fn remove_deletes_the_repository() {
665 let (_dir, storage) = storage();
666 storage
667 .init_bare(&handle(), &repo_name("steid"))
668 .await
669 .expect("should create");
670
671 storage
672 .remove(&handle(), &repo_name("steid"))
673 .await
674 .expect("should remove");
675
676 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
677 }
678
679 #[tokio::test]
680 async fn removing_what_is_not_there_succeeds() {
681 // Compensation runs when a create failed, which may be before anything landed.
682 let (_dir, storage) = storage();
683
684 storage
685 .remove(&handle(), &repo_name("never-created"))
686 .await
687 .expect("should succeed with nothing to do");
688 }
689
690 #[tokio::test]
691 async fn a_compensated_create_can_be_retried() {
692 // The whole point of `remove`: create, fail to record it, undo, try again.
693 let (_dir, storage) = storage();
694
695 storage
696 .init_bare(&handle(), &repo_name("steid"))
697 .await
698 .expect("should create");
699 storage
700 .remove(&handle(), &repo_name("steid"))
701 .await
702 .expect("should remove");
703 storage
704 .init_bare(&handle(), &repo_name("steid"))
705 .await
706 .expect("should create again");
707 }
708
709 #[tokio::test]
710 async fn removing_one_repository_leaves_its_neighbours() {
711 let (_dir, storage) = storage();
712 storage
713 .init_bare(&handle(), &repo_name("steid"))
714 .await
715 .expect("should create");
716 storage
717 .init_bare(&handle(), &repo_name("keeper"))
718 .await
719 .expect("should create");
720
721 storage
722 .remove(&handle(), &repo_name("steid"))
723 .await
724 .expect("should remove");
725
726 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
727 }
728
729 #[tokio::test]
730 async fn a_failing_git_invocation_carries_gits_own_message() {
731 let error = run_git(["not-a-real-subcommand"])
732 .await
733 .expect_err("should fail");
734
735 let message = error.to_string();
736 assert!(
737 message.contains("not-a-real-subcommand"),
738 "expected git's own words, got: {message}"
739 );
740 }
741
742 // --- GitHttpBackend --------------------------------------------------------
743
744 /// A data directory holding one bare repository at `acme/steid.git`.
745 async fn backend() -> (TempDir, GitHttpBackend) {
746 let dir = TempDir::new().expect("temp dir");
747 let storage = DiskGitStorage::new(dir.path());
748
749 let acme = OrgName::new("acme").expect("valid handle");
750 storage
751 .init_bare(&acme, &repo_name("steid"))
752 .await
753 .expect("init bare");
754
755 let backend = GitHttpBackend::new(dir.path());
756 (dir, backend)
757 }
758
759 fn advertisement(path_info: &str) -> GitRequest {
760 GitRequest {
761 method: GitMethod::Get,
762 path_info: path_info.to_owned(),
763 query: "service=git-upload-pack".to_owned(),
764 content_type: None,
765 content_encoding: None,
766 content_length: None,
767 git_protocol: None,
768 body: Box::pin(tokio::io::empty()),
769 }
770 }
771
772 async fn drain(response: GitResponse) -> Vec<u8> {
773 let mut body = response.body;
774 let mut bytes = Vec::new();
775 body.read_to_end(&mut bytes).await.expect("read body");
776 bytes
777 }
778
779 #[tokio::test]
780 async fn the_backend_advertises_refs() {
781 let (_dir, backend) = backend().await;
782
783 let response = backend
784 .serve(advertisement("/acme/steid.git/info/refs"))
785 .await
786 .expect("should serve");
787
788 assert_eq!(response.status, 200);
789 assert!(
790 response
791 .headers
792 .iter()
793 .any(|(name, value)| name == "Content-Type"
794 && value == "application/x-git-upload-pack-advertisement"),
795 "git sets its own content type and we forward it: {:?}",
796 response.headers
797 );
798
799 // The pkt-line the smart protocol opens with. Getting this from git rather than
800 // writing it is the whole reason the backend is a subprocess.
801 let body = drain(response).await;
802 assert!(
803 body.starts_with(b"001e# service=git-upload-pack\n"),
804 "unexpected advertisement: {:?}",
805 String::from_utf8_lossy(&body[..body.len().min(40)])
806 );
807 }
808
809 #[tokio::test]
810 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
811 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
812 // says 404 in a header. Keying off the exit code instead would answer 200.
813 let (_dir, backend) = backend().await;
814
815 let response = backend
816 .serve(advertisement("/acme/nothing-here.git/info/refs"))
817 .await
818 .expect("serving should not itself fail");
819
820 assert_eq!(response.status, 404);
821 }
822
823 #[tokio::test]
824 async fn the_status_header_is_translated_rather_than_forwarded() {
825 let (_dir, backend) = backend().await;
826
827 let response = backend
828 .serve(advertisement("/acme/nothing-here.git/info/refs"))
829 .await
830 .expect("should serve");
831
832 assert!(
833 !response
834 .headers
835 .iter()
836 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
837 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
838 response.headers
839 );
840 }
841
842 #[tokio::test]
843 async fn the_protocol_version_reaches_upload_pack() {
844 // Protocol v2 answers an advertisement with a capability list rather than refs.
845 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
846 // still works — so nothing fails, it just quietly gets worse.
847 let (_dir, backend) = backend().await;
848
849 let mut request = advertisement("/acme/steid.git/info/refs");
850 request.git_protocol = Some("version=2".to_owned());
851
852 let body = drain(backend.serve(request).await.expect("should serve")).await;
853
854 assert!(
855 String::from_utf8_lossy(&body).contains("version 2"),
856 "expected a v2 capability advertisement: {:?}",
857 String::from_utf8_lossy(&body[..body.len().min(80)])
858 );
859 }
860
861 #[tokio::test]
862 async fn the_in_memory_protocol_records_what_it_was_asked() {
863 let protocol = InMemoryGitProtocol::new();
864
865 protocol
866 .serve(advertisement("/acme/steid.git/info/refs"))
867 .await
868 .expect("should serve");
869
870 assert_eq!(
871 protocol.requests(),
872 vec![RecordedGitRequest {
873 method: GitMethod::Get,
874 path_info: "/acme/steid.git/info/refs".to_owned(),
875 query: "service=git-upload-pack".to_owned(),
876 git_protocol: None,
877 content_encoding: None,
878 }]
879 );
880 assert!(protocol.was_called());
881 }
882}