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::{HashMap, 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 Blob, GitMethod, GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest,
24 GitResponse, GitStorage, GitStorageError,
25 },
26 domain::{CommitSummary, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry},
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.
125pub(crate) fn 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
256 // Before the subcommand: `git -c … http-backend`. Pushes are refused by the
257 // backend unless this says otherwise, and it is set only for a request the use
258 // case already authorized — so a bug in Steid's rules meets git's refusal rather
259 // than an open door.
260 if request.allow_receive_pack {
261 command.arg("-c").arg("http.receivepack=true");
262 }
263
264 command
265 .arg("http-backend")
266 .env("GIT_PROJECT_ROOT", &self.data_dir)
267 // Steid decides visibility from the `repositories` table, in the use case.
268 // Without this, git applies its own rule and refuses everything lacking a
269 // `git-daemon-export-ok` marker file — a second source of truth for the same
270 // question, free to drift from the first.
271 .env("GIT_HTTP_EXPORT_ALL", "1")
272 .env("PATH_INFO", &request.path_info)
273 .env("QUERY_STRING", &request.query)
274 .env("REQUEST_METHOD", request.method.as_str())
275 .stdin(Stdio::piped())
276 .stdout(Stdio::piped())
277 .stderr(Stdio::piped());
278
279 // CGI gives only Content-Type and Content-Length unprefixed names; every other
280 // request header arrives `HTTP_`-prefixed. Passing `CONTENT_ENCODING` instead of
281 // `HTTP_CONTENT_ENCODING` makes the backend hand a still-gzipped body to
282 // upload-pack, and the client reports `expected 'packfile'` with nothing naming
283 // the cause. Measured, not guessed.
284 for (variable, value) in [
285 ("CONTENT_TYPE", &request.content_type),
286 ("CONTENT_LENGTH", &request.content_length),
287 ("HTTP_CONTENT_ENCODING", &request.content_encoding),
288 ("HTTP_GIT_PROTOCOL", &request.git_protocol),
289 ] {
290 if let Some(value) = value {
291 command.env(variable, value);
292 }
293 }
294
295 let mut child = command
296 .spawn()
297 .map_err(|error| GitProtocolError::new(format!("could not run git: {error}")))?;
298
299 let mut stdin = child.stdin.take().expect("stdin was piped");
300 let stdout = child.stdout.take().expect("stdout was piped");
301 let mut stderr = child.stderr.take().expect("stderr was piped");
302 let mut body = request.body;
303
304 // The request body streams in while the response streams out; a push is far too
305 // large to buffer, and a fetch would otherwise wait for a body it already has.
306 // Dropping stdin closes the pipe, which is what tells the backend the request is
307 // complete — an error here is the client having gone away, which the backend
308 // then sees as EOF.
309 tokio::spawn(async move {
310 let _ = tokio::io::copy(&mut body, &mut stdin).await;
311 });
312
313 // Reaps the child and surfaces its complaint. This cannot gate the response: a
314 // protocol failure exits non-zero *after* a complete, successful-looking header
315 // block has already been written, so by the time the status is known it has been
316 // sent. Draining stderr is not optional either — an unread pipe fills and blocks
317 // the backend mid-transfer.
318 tokio::spawn(async move {
319 let mut complaint = String::new();
320 let _ = stderr.read_to_string(&mut complaint).await;
321
322 match child.wait().await {
323 Ok(status) if status.success() => {}
324 Ok(status) => eprintln!(
325 "steid: git http-backend exited with {status}: {}",
326 complaint.trim()
327 ),
328 Err(error) => eprintln!("steid: could not wait for git http-backend: {error}"),
329 }
330 });
331
332 // `BufReader` keeps whatever it read past the header block, and handing the
333 // reader itself back as the body is what makes that safe — the first bytes of
334 // the pack are already buffered inside it.
335 let mut reader = BufReader::new(stdout);
336 let (status, headers) = read_cgi_headers(&mut reader).await?;
337
338 Ok(GitResponse {
339 status,
340 headers,
341 body: Box::pin(reader),
342 })
343 }
344}
345
346/// Reads the CGI header block, stopping at the blank line that ends it.
347///
348/// `Status:` is git's way of reporting failure and appears only then, so its absence
349/// means 200. It is translated into the response status rather than forwarded as a
350/// header, which would be meaningless to a client.
351async fn read_cgi_headers(
352 reader: &mut BufReader<ChildStdout>,
353) -> Result<(u16, Vec<(String, String)>), GitProtocolError> {
354 let mut status = 200;
355 let mut headers = Vec::new();
356 let mut line = Vec::new();
357
358 loop {
359 line.clear();
360
361 let read = reader
362 .read_until(b'\n', &mut line)
363 .await
364 .map_err(|error| GitProtocolError::new(format!("reading git's headers: {error}")))?;
365
366 if read == 0 {
367 return Err(GitProtocolError::new(
368 "git http-backend produced no headers before closing",
369 ));
370 }
371
372 // Tolerates a bare LF as well as the CRLF actually observed: a header reader
373 // that hangs on an unexpected line ending is a bad way to find out.
374 let text = String::from_utf8_lossy(&line);
375 let text = text.trim_end_matches(['\r', '\n']);
376
377 if text.is_empty() {
378 return Ok((status, headers));
379 }
380
381 let Some((name, value)) = text.split_once(": ") else {
382 return Err(GitProtocolError::new(format!(
383 "git http-backend wrote an unparseable header: {text:?}"
384 )));
385 };
386
387 if name.eq_ignore_ascii_case("status") {
388 status = value
389 .split_whitespace()
390 .next()
391 .and_then(|code| code.parse().ok())
392 .ok_or_else(|| {
393 GitProtocolError::new(format!("git http-backend wrote a bad status: {value:?}"))
394 })?;
395 } else {
396 headers.push((name.to_owned(), value.to_owned()));
397 }
398
399 if headers.len() > MAX_CGI_HEADERS {
400 return Err(GitProtocolError::new(
401 "git http-backend wrote more headers than a CGI response can plausibly have",
402 ));
403 }
404 }
405}
406
407/// What a [`InMemoryGitProtocol`] was asked for, minus the body.
408///
409/// The body is a stream and comparing it would mean draining it; every rule worth
410/// asserting on lives in the metadata anyway.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct RecordedGitRequest {
413 pub method: GitMethod,
414 pub path_info: String,
415 pub query: String,
416 pub git_protocol: Option<String>,
417 pub content_encoding: Option<String>,
418 pub allow_receive_pack: bool,
419}
420
421/// A git protocol that records what it was asked and never runs git.
422///
423/// The counterpart to [`GitHttpBackend`]. What it is really for is proving a negative:
424/// that a use case refused *before* reaching the protocol. `was_called` is how a test
425/// says "and no bytes flowed".
426#[derive(Debug, Default, Clone)]
427pub struct InMemoryGitProtocol {
428 requests: Arc<Mutex<Vec<RecordedGitRequest>>>,
429}
430
431impl InMemoryGitProtocol {
432 pub fn new() -> Self {
433 Self::default()
434 }
435
436 pub fn requests(&self) -> Vec<RecordedGitRequest> {
437 self.requests.lock().expect("lock poisoned").clone()
438 }
439
440 /// Whether the protocol was reached at all.
441 pub fn was_called(&self) -> bool {
442 !self.requests.lock().expect("lock poisoned").is_empty()
443 }
444}
445
446impl GitProtocolServer for InMemoryGitProtocol {
447 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
448 self.requests
449 .lock()
450 .expect("lock poisoned")
451 .push(RecordedGitRequest {
452 method: request.method,
453 path_info: request.path_info,
454 query: request.query,
455 git_protocol: request.git_protocol,
456 content_encoding: request.content_encoding,
457 allow_receive_pack: request.allow_receive_pack,
458 });
459
460 Ok(GitResponse {
461 status: 200,
462 headers: vec![(
463 "Content-Type".to_owned(),
464 "application/x-git-upload-pack-advertisement".to_owned(),
465 )],
466 body: Box::pin(std::io::Cursor::new(b"0000".to_vec())),
467 })
468 }
469}
470
471/// A repository's contents, held in memory, for testing use cases and pages.
472///
473/// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than
474/// pretending to be a git implementation: it answers the questions the port asks and
475/// knows nothing about how a real repository stores them.
476#[derive(Debug, Default, Clone)]
477pub struct InMemoryGitQuery {
478 default_branch: Option<RefName>,
479 /// Keyed `rev\0path`, because a tree only means anything at a revision.
480 trees: HashMap<String, Vec<TreeEntry>>,
481 blobs: HashMap<String, Vec<u8>>,
482 commits: Vec<CommitSummary>,
483}
484
485impl InMemoryGitQuery {
486 /// An empty repository: no default branch, so nothing has been pushed.
487 pub fn empty() -> Self {
488 Self::default()
489 }
490
491 /// A repository whose default branch is `main`.
492 pub fn new() -> Self {
493 Self {
494 default_branch: Some(RefName::from_trusted("main")),
495 ..Self::default()
496 }
497 }
498
499 fn key(rev: &RefName, path: &RepoPath) -> String {
500 format!("{}\0{}", rev.as_str(), path.as_str())
501 }
502
503 pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
504 self.trees.insert(
505 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
506 entries,
507 );
508 self
509 }
510
511 pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
512 self.blobs.insert(
513 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
514 content.into(),
515 );
516 self
517 }
518
519 pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
520 self.commits = commits;
521 self
522 }
523}
524
525impl GitQuery for InMemoryGitQuery {
526 async fn default_branch(
527 &self,
528 _handle: &OrgName,
529 _name: &RepoName,
530 ) -> Result<Option<RefName>, GitQueryError> {
531 Ok(self.default_branch.clone())
532 }
533
534 async fn resolve(
535 &self,
536 _handle: &OrgName,
537 _name: &RepoName,
538 _rev: &RefName,
539 ) -> Result<Option<ObjectId>, GitQueryError> {
540 Ok(self
541 .default_branch
542 .as_ref()
543 .map(|_| ObjectId::from_trusted("0".repeat(40))))
544 }
545
546 async fn list_tree(
547 &self,
548 _handle: &OrgName,
549 _name: &RepoName,
550 rev: &RefName,
551 path: &RepoPath,
552 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
553 Ok(self.trees.get(&Self::key(rev, path)).cloned())
554 }
555
556 async fn read_blob(
557 &self,
558 _handle: &OrgName,
559 _name: &RepoName,
560 rev: &RefName,
561 path: &RepoPath,
562 max_bytes: u64,
563 ) -> Result<Option<Blob>, GitQueryError> {
564 Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
565 let size = content.len() as u64;
566
567 Blob {
568 id: ObjectId::from_trusted("1".repeat(40)),
569 size,
570 // The same cap the real adapter applies, so a test can exercise the
571 // too-large path without a megabyte of fixture.
572 content: (size <= max_bytes).then(|| content.clone()),
573 }
574 }))
575 }
576
577 async fn log(
578 &self,
579 _handle: &OrgName,
580 _name: &RepoName,
581 _rev: &RefName,
582 limit: usize,
583 ) -> Result<Vec<CommitSummary>, GitQueryError> {
584 Ok(self.commits.iter().take(limit).cloned().collect())
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use std::path::Path;
591
592 use tempfile::TempDir;
593
594 use super::*;
595
596 /// The `TempDir` is returned alongside the storage because dropping it deletes the
597 /// data directory — binding it to `_` would remove the fixture mid-test.
598 fn storage() -> (TempDir, DiskGitStorage) {
599 let dir = TempDir::new().expect("temp dir");
600 let storage = DiskGitStorage::new(dir.path());
601 (dir, storage)
602 }
603
604 fn handle() -> OrgName {
605 OrgName::new("jamesgill").expect("valid handle")
606 }
607
608 fn repo_name(value: &str) -> RepoName {
609 RepoName::new(value).expect("valid repository name")
610 }
611
612 /// Asks git about a repository, so assertions test what git believes rather than
613 /// what the directory looks like.
614 fn git_says(path: &Path, args: &[&str]) -> String {
615 let output = std::process::Command::new("git")
616 .arg("-C")
617 .arg(path)
618 .args(args)
619 .output()
620 .expect("git should be on PATH");
621
622 assert!(
623 output.status.success(),
624 "git {args:?} failed: {}",
625 String::from_utf8_lossy(&output.stderr)
626 );
627
628 String::from_utf8_lossy(&output.stdout).trim().to_owned()
629 }
630
631 #[tokio::test]
632 async fn init_bare_creates_a_bare_repository() {
633 let (_dir, storage) = storage();
634
635 storage
636 .init_bare(&handle(), &repo_name("steid"))
637 .await
638 .expect("should create");
639
640 let path = storage.repo_path(&handle(), &repo_name("steid"));
641 assert!(path.is_dir(), "expected a repository at {path:?}");
642 assert_eq!(
643 git_says(&path, &["rev-parse", "--is-bare-repository"]),
644 "true"
645 );
646 }
647
648 #[tokio::test]
649 async fn a_new_repository_is_empty() {
650 // Empty, like GitHub: no initial commit and no branch yet.
651 let (_dir, storage) = storage();
652 storage
653 .init_bare(&handle(), &repo_name("steid"))
654 .await
655 .expect("should create");
656
657 let path = storage.repo_path(&handle(), &repo_name("steid"));
658
659 assert_eq!(git_says(&path, &["for-each-ref"]), "");
660 }
661
662 #[tokio::test]
663 async fn a_new_repository_defaults_to_main() {
664 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
665 // agrees on this machine, which is exactly why a drift would go unnoticed.
666 let (_dir, storage) = storage();
667 storage
668 .init_bare(&handle(), &repo_name("steid"))
669 .await
670 .expect("should create");
671
672 let path = storage.repo_path(&handle(), &repo_name("steid"));
673
674 assert_eq!(
675 git_says(&path, &["symbolic-ref", "HEAD"]),
676 "refs/heads/main"
677 );
678 }
679
680 #[tokio::test]
681 async fn no_sample_hooks_are_installed() {
682 // Pins `--template=`. A default init seeds sixteen `.sample` files.
683 let (_dir, storage) = storage();
684 storage
685 .init_bare(&handle(), &repo_name("steid"))
686 .await
687 .expect("should create");
688
689 let hooks = storage
690 .repo_path(&handle(), &repo_name("steid"))
691 .join("hooks");
692
693 let samples = std::fs::read_dir(&hooks)
694 .map(|entries| entries.count())
695 .unwrap_or(0);
696 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
697 }
698
699 #[tokio::test]
700 async fn init_bare_creates_the_handle_directory() {
701 let (dir, storage) = storage();
702 assert!(!dir.path().join("jamesgill").exists());
703
704 storage
705 .init_bare(&handle(), &repo_name("steid"))
706 .await
707 .expect("should create");
708
709 assert!(dir.path().join("jamesgill").is_dir());
710 }
711
712 #[tokio::test]
713 async fn one_handle_can_own_several_repositories() {
714 let (_dir, storage) = storage();
715
716 for name in ["steid", "foo.js", ".github"] {
717 storage
718 .init_bare(&handle(), &repo_name(name))
719 .await
720 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
721 }
722
723 for name in ["steid", "foo.js", ".github"] {
724 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
725 }
726 }
727
728 #[tokio::test]
729 async fn init_bare_refuses_a_repository_that_already_exists() {
730 let (_dir, storage) = storage();
731 storage
732 .init_bare(&handle(), &repo_name("steid"))
733 .await
734 .expect("should create");
735
736 let error = storage
737 .init_bare(&handle(), &repo_name("steid"))
738 .await
739 .expect_err("should refuse");
740
741 assert!(matches!(error, GitStorageError::AlreadyExists));
742 }
743
744 #[tokio::test]
745 async fn a_refused_init_leaves_the_existing_repository_alone() {
746 // git would happily re-initialise in place. The point of refusing is that
747 // whatever is already there is not touched.
748 let (_dir, storage) = storage();
749 storage
750 .init_bare(&handle(), &repo_name("steid"))
751 .await
752 .expect("should create");
753
754 let path = storage.repo_path(&handle(), &repo_name("steid"));
755 let marker = path.join("objects").join("marker");
756 std::fs::write(&marker, b"existing data").expect("write marker");
757
758 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
759
760 assert_eq!(
761 std::fs::read(&marker).expect("marker should survive"),
762 b"existing data"
763 );
764 }
765
766 #[tokio::test]
767 async fn repo_path_creates_nothing() {
768 let (dir, storage) = storage();
769
770 let path = storage.repo_path(&handle(), &repo_name("never-created"));
771
772 assert!(!path.exists());
773 assert_eq!(
774 std::fs::read_dir(dir.path())
775 .expect("data dir should exist")
776 .count(),
777 0,
778 "repo_path must be pure"
779 );
780 }
781
782 #[tokio::test]
783 async fn repo_path_lands_under_the_data_directory() {
784 let (dir, storage) = storage();
785
786 let path = storage.repo_path(&handle(), &repo_name("steid"));
787
788 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
789 }
790
791 #[tokio::test]
792 async fn remove_deletes_the_repository() {
793 let (_dir, storage) = storage();
794 storage
795 .init_bare(&handle(), &repo_name("steid"))
796 .await
797 .expect("should create");
798
799 storage
800 .remove(&handle(), &repo_name("steid"))
801 .await
802 .expect("should remove");
803
804 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
805 }
806
807 #[tokio::test]
808 async fn removing_what_is_not_there_succeeds() {
809 // Compensation runs when a create failed, which may be before anything landed.
810 let (_dir, storage) = storage();
811
812 storage
813 .remove(&handle(), &repo_name("never-created"))
814 .await
815 .expect("should succeed with nothing to do");
816 }
817
818 #[tokio::test]
819 async fn a_compensated_create_can_be_retried() {
820 // The whole point of `remove`: create, fail to record it, undo, try again.
821 let (_dir, storage) = storage();
822
823 storage
824 .init_bare(&handle(), &repo_name("steid"))
825 .await
826 .expect("should create");
827 storage
828 .remove(&handle(), &repo_name("steid"))
829 .await
830 .expect("should remove");
831 storage
832 .init_bare(&handle(), &repo_name("steid"))
833 .await
834 .expect("should create again");
835 }
836
837 #[tokio::test]
838 async fn removing_one_repository_leaves_its_neighbours() {
839 let (_dir, storage) = storage();
840 storage
841 .init_bare(&handle(), &repo_name("steid"))
842 .await
843 .expect("should create");
844 storage
845 .init_bare(&handle(), &repo_name("keeper"))
846 .await
847 .expect("should create");
848
849 storage
850 .remove(&handle(), &repo_name("steid"))
851 .await
852 .expect("should remove");
853
854 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
855 }
856
857 #[tokio::test]
858 async fn a_failing_git_invocation_carries_gits_own_message() {
859 let error = run_git(["not-a-real-subcommand"])
860 .await
861 .expect_err("should fail");
862
863 let message = error.to_string();
864 assert!(
865 message.contains("not-a-real-subcommand"),
866 "expected git's own words, got: {message}"
867 );
868 }
869
870 // --- GitHttpBackend --------------------------------------------------------
871
872 /// A data directory holding one bare repository at `acme/steid.git`.
873 async fn backend() -> (TempDir, GitHttpBackend) {
874 let dir = TempDir::new().expect("temp dir");
875 let storage = DiskGitStorage::new(dir.path());
876
877 let acme = OrgName::new("acme").expect("valid handle");
878 storage
879 .init_bare(&acme, &repo_name("steid"))
880 .await
881 .expect("init bare");
882
883 let backend = GitHttpBackend::new(dir.path());
884 (dir, backend)
885 }
886
887 fn advertisement(path_info: &str) -> GitRequest {
888 GitRequest {
889 method: GitMethod::Get,
890 path_info: path_info.to_owned(),
891 query: "service=git-upload-pack".to_owned(),
892 content_type: None,
893 content_encoding: None,
894 content_length: None,
895 git_protocol: None,
896 allow_receive_pack: false,
897 body: Box::pin(tokio::io::empty()),
898 }
899 }
900
901 async fn drain(response: GitResponse) -> Vec<u8> {
902 let mut body = response.body;
903 let mut bytes = Vec::new();
904 body.read_to_end(&mut bytes).await.expect("read body");
905 bytes
906 }
907
908 #[tokio::test]
909 async fn the_backend_advertises_refs() {
910 let (_dir, backend) = backend().await;
911
912 let response = backend
913 .serve(advertisement("/acme/steid.git/info/refs"))
914 .await
915 .expect("should serve");
916
917 assert_eq!(response.status, 200);
918 assert!(
919 response
920 .headers
921 .iter()
922 .any(|(name, value)| name == "Content-Type"
923 && value == "application/x-git-upload-pack-advertisement"),
924 "git sets its own content type and we forward it: {:?}",
925 response.headers
926 );
927
928 // The pkt-line the smart protocol opens with. Getting this from git rather than
929 // writing it is the whole reason the backend is a subprocess.
930 let body = drain(response).await;
931 assert!(
932 body.starts_with(b"001e# service=git-upload-pack\n"),
933 "unexpected advertisement: {:?}",
934 String::from_utf8_lossy(&body[..body.len().min(40)])
935 );
936 }
937
938 #[tokio::test]
939 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
940 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
941 // says 404 in a header. Keying off the exit code instead would answer 200.
942 let (_dir, backend) = backend().await;
943
944 let response = backend
945 .serve(advertisement("/acme/nothing-here.git/info/refs"))
946 .await
947 .expect("serving should not itself fail");
948
949 assert_eq!(response.status, 404);
950 }
951
952 #[tokio::test]
953 async fn the_status_header_is_translated_rather_than_forwarded() {
954 let (_dir, backend) = backend().await;
955
956 let response = backend
957 .serve(advertisement("/acme/nothing-here.git/info/refs"))
958 .await
959 .expect("should serve");
960
961 assert!(
962 !response
963 .headers
964 .iter()
965 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
966 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
967 response.headers
968 );
969 }
970
971 #[tokio::test]
972 async fn the_protocol_version_reaches_upload_pack() {
973 // Protocol v2 answers an advertisement with a capability list rather than refs.
974 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
975 // still works — so nothing fails, it just quietly gets worse.
976 let (_dir, backend) = backend().await;
977
978 let mut request = advertisement("/acme/steid.git/info/refs");
979 request.git_protocol = Some("version=2".to_owned());
980
981 let body = drain(backend.serve(request).await.expect("should serve")).await;
982
983 assert!(
984 String::from_utf8_lossy(&body).contains("version 2"),
985 "expected a v2 capability advertisement: {:?}",
986 String::from_utf8_lossy(&body[..body.len().min(80)])
987 );
988 }
989
990 #[tokio::test]
991 async fn the_in_memory_protocol_records_what_it_was_asked() {
992 let protocol = InMemoryGitProtocol::new();
993
994 protocol
995 .serve(advertisement("/acme/steid.git/info/refs"))
996 .await
997 .expect("should serve");
998
999 assert_eq!(
1000 protocol.requests(),
1001 vec![RecordedGitRequest {
1002 method: GitMethod::Get,
1003 path_info: "/acme/steid.git/info/refs".to_owned(),
1004 query: "service=git-upload-pack".to_owned(),
1005 git_protocol: None,
1006 content_encoding: None,
1007 allow_receive_pack: false,
1008 }]
1009 );
1010 assert!(protocol.was_called());
1011 }
1012}