steid

@jamesgill /

feat: serve the git protocol through http-backend, behind a port

The port is CGI-shaped because the backend is a CGI, per the amendment to 0006.
What matters is what it does *not* take: path_info and query are built by the
caller from an already-validated handle and repository name, so a transport
cannot ask for a path nothing authorized. The port itself decides nothing about
who may do this.

Bodies stream both ways. A pack is arbitrarily large and buffering would bound
a clone by RAM rather than disk, which is what 0001 meant when it called
streaming the thing that makes this transport viable. Only tokio's io-util
feature is added; the crates the web layer will need to bridge Topcoat's Body
are already in the lock file as transitive dependencies.

Three details in the adapter are there because the probe found them, and each
fails silently otherwise: HTTP_CONTENT_ENCODING rather than CONTENT_ENCODING,
GIT_HTTP_EXPORT_ALL so that git stops applying a visibility rule Steid already
owns, and a Status: header translated rather than forwarded. The child's exit
code deliberately does not gate the response — a protocol failure exits non-zero
after a successful-looking header block is already sent, so it goes to the log.

run_git grew a sibling rather than a copy: both call sites now start from one
git_command builder, since the isolation flags are exactly what 0006 exists to
stop drifting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparent8ed37b0Browse files47db238f72b5c7c80d1b52efe0eea0f5dc337d61

5 files changed+543 −24

Cargo.toml+1 −1View file
@@ -11,7 +11,7 @@ rand = "0.10.2"
1111 serde = { version = "1.0.229", features = ["derive"] }
1212 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1313 subtle = "2.6.1"
14tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs"] }
14+tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs", "io-util"] }
1515 topcoat = { version = "0.5.0", features = ["icon-iconify", "tailwind", "ui"] }
1616 uuid = { version = "1.24.0", features = ["v4"] }
1717
plans/current.md+22 −7View file
@@ -19,14 +19,13 @@ statistic the clone path could tempt us into computing.
1919 - [ ] Probe `git http-backend`'s actual contract — which CGI variables it reads, how it
2020 reports failure, what it does with an unauthorised path. Findings to
2121 `progress.md`; no application code in this step.
22- [ ] Application: `GitProtocolServer` port — a CGI-shaped request/response pair, plus
22+- [x] Application: `GitProtocolServer` port — a CGI-shaped request/response pair, plus
2323 `GitOperation` (Read/Write) as the thing authorization is decided on
24- [ ] Infrastructure: `GitHttpBackend` adapter, spawning through the existing `run_git`
24+- [x] Infrastructure: `GitHttpBackend` adapter, spawning through the existing `run_git`
2525 invoker; streams stdin in and stdout out, parsing CGI headers off the front
2626 - [ ] Application: `serve_git` use case — resolves the repository, enforces visibility,
2727 refuses writes outright, and only then delegates
28- [ ] Web: the three git routes under `/{handle}/repos/{name}.git/`, with `body_limit`
29 raised
28+- [ ] Web: the three git routes under `/{handle}/repos/{name}.git/`
3029 - [ ] Verify with a real `git clone` of a repo with enough refs to trigger a gzipped
3130 request body
3231
@@ -57,6 +56,22 @@ the origin. A private repository is not clonable by anyone yet — not even its
5756 - **Authorization is decided before the subprocess is spawned**, from the service name
5857 in the request, not from anything `http-backend` reports back. By the time git is
5958 running it is too late to refuse.
59+- **Bodies stream in both directions, rather than buffering.** A pack is arbitrarily
60+ large, and buffering would bound a clone by RAM instead of by disk;
61+ [0001](decisions/0001-git-over-http-not-ssh.md) named streaming as the thing that made
62+ this transport viable in the first place. The port therefore carries
63+ `Pin<Box<dyn AsyncRead + Send>>` in both directions. **No new crate**: this needs only
64+ tokio's `io-util` feature, and the `bytes` / `http-body-util` / `tokio-util` the web
65+ layer will use to bridge Topcoat's `Body` are already in `Cargo.lock` as transitive
66+ dependencies, so nothing new enters the build.
67+- **`run_git` was split into `git_command`.** The old invoker buffers with `output()`,
68+ which the protocol cannot use. Rather than a second recipe — the thing
69+ [0006](decisions/0006-git-binary-behind-narrow-ports.md) exists to prevent — the
70+ isolation moved into a shared builder both call sites start from.
71+- **`body_limit` turns out not to exist** in `topcoat-router` 0.5.0. Bodies are read by
72+ the handler via `to_bytes(body, limit)` with a caller-chosen limit, so there is no
73+ layer to raise — and the git routes take the body unbuffered anyway. The warning
74+ carried from [0001](decisions/0001-git-over-http-not-ssh.md) is stale.
6075 - **Milestone 4 was split.** See [ROADMAP.md](ROADMAP.md#why-this-order).
6176
6277 ### Open
@@ -70,12 +85,12 @@ the origin. A private repository is not clonable by anyone yet — not even its
7085
7186 ### Watch for
7287
73- **`body_limit` will reject pushes and large fetches.** `topcoat-router` caps request
74 bodies; the git routes need it raised. Expect a confusing failure rather than a clear
75 one — noted since [0001](decisions/0001-git-over-http-not-ssh.md).
7688 - **CGI header parsing sits in front of a stream.** `http-backend` writes headers, a
7789 blank line, then the body. Reading the headers must not buffer the body — that is the
7890 whole reason this transport was judged viable on `Body::into_data_stream`.
91+- **A client that disappears mid-request leaves the body-copy task waiting.** The copy
92+ into git's stdin runs in its own task; nothing cancels it if the connection drops.
93+ Bounded by the backend exiting and closing the pipe, but not by anything deliberate.
7994 - **A subprocess per request**, unlike Milestone 3's once-per-creation. Fork/exec cost
8095 now sits on a hot path; measure before assuming it is fine.
8196 - **`http-backend` reports failure through CGI status lines**, not exit codes alone. A
src/application/error.rs+11 −1View file
@@ -1,6 +1,6 @@
11 use crate::domain::{DomainError, repository::RepositoryError};
22
3use super::port::{GitStorageError, PasswordError};
3+use super::port::{GitProtocolError, GitStorageError, PasswordError};
44
55 /// What a use case can fail with.
66 ///
@@ -18,6 +18,8 @@ pub enum Error {
1818 Password(PasswordError),
1919 /// A bare repository could not be created or removed on disk.
2020 GitStorage(GitStorageError),
21+ /// The git protocol could not be served.
22+ GitProtocol(GitProtocolError),
2123 }
2224
2325 impl From<DomainError> for Error {
@@ -44,6 +46,12 @@ impl From<GitStorageError> for Error {
4446 }
4547 }
4648
49+impl From<GitProtocolError> for Error {
50+ fn from(error: GitProtocolError) -> Self {
51+ Self::GitProtocol(error)
52+ }
53+}
54+
4755 impl std::fmt::Display for Error {
4856 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4957 match self {
@@ -51,6 +59,7 @@ impl std::fmt::Display for Error {
5159 Self::Repository(error) => write!(f, "{error}"),
5260 Self::Password(error) => write!(f, "{error}"),
5361 Self::GitStorage(error) => write!(f, "{error}"),
62+ Self::GitProtocol(error) => write!(f, "{error}"),
5463 }
5564 }
5665 }
@@ -62,6 +71,7 @@ impl std::error::Error for Error {
6271 Self::Repository(error) => Some(error),
6372 Self::Password(error) => Some(error),
6473 Self::GitStorage(error) => Some(error),
74+ Self::GitProtocol(error) => Some(error),
6575 }
6676 }
6777 }
src/application/port.rs+104 −1View file
@@ -3,7 +3,9 @@
33 //! Repository ports live in `domain::repository`; these are the non-persistence
44 //! collaborators. Adapters live in `infrastructure`.
55
6use std::path::PathBuf;
6+use std::{path::PathBuf, pin::Pin};
7+
8+use tokio::io::AsyncRead;
79
810 use crate::domain::{OrgName, PasswordHash, RepoName};
911
@@ -121,3 +123,104 @@ impl std::error::Error for GitStorageError {
121123 }
122124 }
123125 }
126+
127+/// A stream of bytes, in either direction.
128+///
129+/// Pack data is arbitrarily large and must never be collected into memory — a clone of
130+/// a large repository would otherwise be bounded by RAM rather than by disk. Boxed
131+/// rather than generic so the port stays object-safe in shape and the adapter can hand
132+/// back a subprocess's stdout directly.
133+pub type ByteStream = Pin<Box<dyn AsyncRead + Send>>;
134+
135+/// Which HTTP method a git request uses.
136+///
137+/// Only these two exist in the smart protocol: the advertisement is a `GET`, the
138+/// negotiation and pack transfer are `POST`s.
139+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140+pub enum GitMethod {
141+ Get,
142+ Post,
143+}
144+
145+impl GitMethod {
146+ pub fn as_str(self) -> &'static str {
147+ match self {
148+ Self::Get => "GET",
149+ Self::Post => "POST",
150+ }
151+ }
152+}
153+
154+/// A request to the git protocol, in the shape `git http-backend` wants it.
155+///
156+/// CGI-shaped rather than one method per operation, because the backend is a CGI —
157+/// see the amendment to
158+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). **Every field
159+/// here is constructed by the use case**, not forwarded from a URL: `path_info` and
160+/// `query` in particular are built from an already-validated handle and repository
161+/// name, so the transport cannot ask for a path the use case did not authorize.
162+pub struct GitRequest {
163+ pub method: GitMethod,
164+ /// The repository path relative to the project root, e.g. `/acme/steid.git/info/refs`.
165+ pub path_info: String,
166+ /// The CGI query string, e.g. `service=git-upload-pack`. Empty for an RPC POST.
167+ pub query: String,
168+ pub content_type: Option<String>,
169+ /// The client's `Content-Encoding`. Real clients gzip this body once a repository
170+ /// has more than a handful of refs, and the backend inflates it for us — but only
171+ /// when it arrives as `HTTP_CONTENT_ENCODING`, which is the adapter's job.
172+ pub content_encoding: Option<String>,
173+ pub content_length: Option<String>,
174+ /// The client's `Git-Protocol`, carrying `version=2` for any modern client.
175+ /// Dropping it silently downgrades the exchange to v0 rather than failing.
176+ pub git_protocol: Option<String>,
177+ pub body: ByteStream,
178+}
179+
180+/// What the git protocol answered.
181+///
182+/// The status is CGI's, not the framework's: `git http-backend` reports failure with a
183+/// `Status:` header and no status at all when it succeeded, so absence means 200 and
184+/// the adapter translates it.
185+pub struct GitResponse {
186+ pub status: u16,
187+ pub headers: Vec<(String, String)>,
188+ pub body: ByteStream,
189+}
190+
191+/// Serves the git smart-HTTP protocol for one repository.
192+///
193+/// Says nothing about who may do this. Authorization is settled before a request ever
194+/// reaches here — by the time the backend is running, refusing is no longer possible.
195+pub trait GitProtocolServer: Send + Sync {
196+ fn serve(
197+ &self,
198+ request: GitRequest,
199+ ) -> impl Future<Output = Result<GitResponse, GitProtocolError>> + Send;
200+}
201+
202+/// The git protocol could not be served.
203+///
204+/// Deliberately without a `NotFound`: a missing repository is something the backend
205+/// reports in its own response, and the use case has already refused anything the
206+/// viewer may not see.
207+#[derive(Debug)]
208+pub struct GitProtocolError(Box<dyn std::error::Error + Send + Sync>);
209+
210+impl GitProtocolError {
211+ pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
212+ Self(error.into())
213+ }
214+}
215+
216+impl std::fmt::Display for GitProtocolError {
217+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218+ write!(f, "git protocol failure: {}", self.0)
219+ }
220+}
221+
222+impl std::error::Error for GitProtocolError {
223+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
224+ Some(&*self.0)
225+ }
226+}
src/infrastructure/git.rs+405 −14View file
@@ -13,13 +13,25 @@ use std::{
1313 sync::{Arc, Mutex},
1414 };
1515
16use tokio::process::Command;
16+use tokio::{
17+ io::{AsyncBufReadExt, AsyncReadExt, BufReader},
18+ process::{ChildStdout, Command},
19+};
1720
1821 use crate::{
19 application::port::{GitStorage, GitStorageError},
22+ application::port::{
23+ GitMethod, GitProtocolError, GitProtocolServer, GitRequest, GitResponse, GitStorage,
24+ GitStorageError,
25+ },
2026 domain::{OrgName, RepoName},
2127 };
2228
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.
33+const MAX_CGI_HEADERS: usize = 64;
34+
2335 /// Environment variables that redirect where git reads and writes data.
2436 ///
2537 /// Steid's own environment must not reach into a repository's layout. These are set
@@ -104,6 +116,28 @@ impl GitStorage for DiskGitStorage {
104116 }
105117 }
106118
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.
125+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+
107141 /// Runs `git` and fails on a non-zero exit.
108142 ///
109143 /// The single place that decides how Steid invokes git, so every call site gets the
@@ -115,18 +149,8 @@ where
115149 I: IntoIterator<Item = S>,
116150 S: AsRef<OsStr>,
117151 {
118 let mut command = Command::new("git");
119 command
120 .args(args)
121 // Host configuration must not leak into repositories Steid creates, for the
122 // same reason `--initial-branch` is passed explicitly.
123 .env("GIT_CONFIG_GLOBAL", "/dev/null")
124 .env("GIT_CONFIG_SYSTEM", "/dev/null")
125 .stdin(Stdio::null());
126
127 for variable in REDIRECTING_VARS {
128 command.env_remove(variable);
129 }
152+ let mut command = git_command();
153+ command.args(args).stdin(Stdio::null());
130154
131155 // `output()` pipes stdout and stderr and waits without blocking the runtime.
132156 let output = command
@@ -207,6 +231,232 @@ impl GitStorage for InMemoryGitStorage {
207231 }
208232 }
209233
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)]
240+pub struct GitHttpBackend {
241+ data_dir: PathBuf,
242+}
243+
244+impl GitHttpBackend {
245+ pub fn new(data_dir: impl Into<PathBuf>) -> Self {
246+ Self {
247+ data_dir: data_dir.into(),
248+ }
249+ }
250+}
251+
252+impl 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.
342+async 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)]
403+pub 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)]
417+pub struct InMemoryGitProtocol {
418+ requests: Arc<Mutex<Vec<RecordedGitRequest>>>,
419+}
420+
421+impl 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+
436+impl 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+
210460 #[cfg(test)]
211461 mod tests {
212462 use std::path::Path;
@@ -488,4 +738,145 @@ mod tests {
488738 "expected git's own words, got: {message}"
489739 );
490740 }
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+ }
491882 }