Skip to main content

solid_pod_rs_git/
service.rs

1//! Binder-agnostic Git HTTP service — spawns the system
2//! `git http-backend` CGI and shuttles stdin/stdout between it and
3//! the HTTP layer.
4//!
5//! Mirrors JSS `src/handlers/git.js` lines 95-268 (`handleGit`) end
6//! to end. The key design choices, all pulled straight from JSS:
7//!
8//! * `GIT_PROJECT_ROOT = repo_root`, `PATH_INFO = request path`. The
9//!   CGI walks `GIT_PROJECT_ROOT + PATH_INFO` internally.
10//! * `GIT_HTTP_EXPORT_ALL` set (empty value, just defined) so all
11//!   repos under the root are read-exportable.
12//! * `GIT_HTTP_RECEIVE_PACK=true` so push is enabled (JSS line 157).
13//! * `GIT_CONFIG_PARAMETERS` injects `uploadpack.allowTipSHA1InWant`
14//!   to match JSS line 158.
15//! * For non-bare repos we set `GIT_DIR` to the `.git` child (JSS
16//!   lines 168-170).
17//! * We parse CGI headers from stdout, separate them from body on
18//!   `\r\n\r\n` (fall back to `\n\n`), and convert the first `Status:`
19//!   header into the HTTP response status.
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::process::Stdio;
24use std::sync::Arc;
25
26use bytes::Bytes;
27use tokio::io::{AsyncReadExt, AsyncWriteExt};
28use tokio::process::Command;
29use tokio::time::{sleep, Duration};
30
31use crate::auth::{AuthError, GitAuth};
32use crate::config::{apply_write_config, find_git_dir};
33use crate::error::GitError;
34use crate::guard::{extract_repo_slug, path_safe};
35use crate::push_guard::{check_push, refusal_response, repo_lock, PushPathGuard};
36
37/// Path to the CGI binary shipped with git. Configurable via
38/// `GIT_HTTP_BACKEND_PATH` env var at service-startup time (the
39/// default matches Debian/Ubuntu).
40pub const DEFAULT_GIT_HTTP_BACKEND: &str = "/usr/lib/git-core/git-http-backend";
41const CGI_STDOUT_CAP: usize = 256 * 1024 * 1024;
42const CGI_STDERR_CAP: usize = 64 * 1024;
43const CGI_EXECUTION_TIMEOUT: Duration = Duration::from_secs(120);
44
45async fn read_bounded<R: tokio::io::AsyncRead + Unpin>(
46    reader: &mut R,
47    cap: usize,
48) -> std::io::Result<Vec<u8>> {
49    let mut output = Vec::with_capacity(cap.min(64 * 1024));
50    let mut chunk = [0u8; 16 * 1024];
51    loop {
52        let read = reader.read(&mut chunk).await?;
53        if read == 0 {
54            return Ok(output);
55        }
56        if output.len().saturating_add(read) > cap {
57            return Err(std::io::Error::new(
58                std::io::ErrorKind::FileTooLarge,
59                "CGI stdout exceeded configured cap",
60            ));
61        }
62        output.extend_from_slice(&chunk[..read]);
63    }
64}
65
66async fn read_diagnostics_bounded<R: tokio::io::AsyncRead + Unpin>(
67    reader: &mut R,
68    cap: usize,
69) -> Vec<u8> {
70    let mut output = Vec::with_capacity(cap.min(8 * 1024));
71    let mut chunk = [0u8; 4096];
72    loop {
73        let Ok(read) = reader.read(&mut chunk).await else {
74            return output;
75        };
76        if read == 0 {
77            return output;
78        }
79        let remaining = cap.saturating_sub(output.len());
80        output.extend_from_slice(&chunk[..read.min(remaining)]);
81        // Continue draining after the diagnostic cap so the child cannot
82        // block on a full stderr pipe.
83    }
84}
85
86/// Opaque HTTP request shape consumed by the service.
87///
88/// The crate stays intentionally binder-agnostic — callers (axum,
89/// actix-web, hyper raw, …) translate their native request type into
90/// this struct before calling `handle`.
91#[derive(Debug, Clone)]
92pub struct GitRequest {
93    /// e.g. `"GET"`, `"POST"`, `"OPTIONS"`.
94    pub method: String,
95    /// The URL path (`"/alice/repo/info/refs"`), already
96    /// percent-decoded.
97    pub path: String,
98    /// The raw query string without the leading `?`.
99    pub query: String,
100    /// All request headers as `(name, value)` tuples. Name is
101    /// compared case-insensitively by the service.
102    pub headers: Vec<(String, String)>,
103    /// Request body (empty for GETs).
104    pub body: Bytes,
105    /// Scheme + host (`"https://pod.example.com"`) — used only to
106    /// reconstruct the URL that NIP-98 verification checks. If None,
107    /// we fall back to `http://localhost`.
108    pub host_url: Option<String>,
109}
110
111impl GitRequest {
112    /// Reconstruct the canonical URL that a NIP-98 `u` tag is
113    /// expected to point at.
114    pub fn auth_url(&self) -> String {
115        let base = self
116            .host_url
117            .clone()
118            .unwrap_or_else(|| "http://localhost".to_string());
119        if self.query.is_empty() {
120            format!("{base}{}", self.path)
121        } else {
122            format!("{base}{}?{}", self.path, self.query)
123        }
124    }
125
126    /// `true` if this request requires a successful auth check (push).
127    #[must_use]
128    pub fn is_write(&self) -> bool {
129        self.path.contains("/git-receive-pack") || self.query.contains("service=git-receive-pack")
130    }
131
132    /// `true` if this request fetches repository data (clone/fetch/ls).
133    ///
134    /// Smart-HTTP read traffic is the `git-upload-pack` service plus the
135    /// `info/refs` capability advertisement that precedes it, and the
136    /// dumb-HTTP object/pack paths under `objects/`. These previously
137    /// bypassed every auth check while `GIT_HTTP_EXPORT_ALL` exported
138    /// each repo verbatim, so a private pod's git history was world-
139    /// clonable (P1-3). The service now gates reads through the same
140    /// auth provider as writes.
141    #[must_use]
142    pub fn is_read(&self) -> bool {
143        if self.is_write() {
144            return false;
145        }
146        self.path.contains("/git-upload-pack")
147            || self.query.contains("service=git-upload-pack")
148            || self.path.contains("/info/refs")
149            || self.path.contains("/objects/")
150            || self.path.ends_with("/HEAD")
151    }
152}
153
154/// CGI response to return to the HTTP layer.
155#[derive(Debug, Clone)]
156pub struct GitResponse {
157    /// HTTP status (derived from the CGI `Status:` header, or 200 by
158    /// default).
159    pub status: u16,
160    /// All response headers emitted by the CGI plus CORS headers.
161    pub headers: Vec<(String, String)>,
162    /// Body bytes — already includes the CGI body payload.
163    pub body: Bytes,
164}
165
166impl GitResponse {
167    /// Build a simple error response (no CGI invocation).
168    #[must_use]
169    pub fn error(status: u16, msg: impl Into<String>) -> Self {
170        let msg = msg.into();
171        let body = Bytes::from(format!("{{\"error\":\"{msg}\"}}"));
172        let mut headers: Vec<(String, String)> =
173            vec![("content-type".into(), "application/json".into())];
174        headers.extend(git_cors_header_pairs());
175        Self {
176            status,
177            headers,
178            body,
179        }
180    }
181}
182
183/// CORS headers for git responses. Single source of truth — used by the
184/// OPTIONS preflight and http-backend success path here, by
185/// [`GitResponse::error`], and by the server's WAC-gate 401/402/403
186/// early-returns (JSS #548). Without these, browser-based git clients see
187/// a generic CORS/network error instead of the actual status and
188/// `WWW-Authenticate` challenge.
189pub const GIT_CORS_HEADERS: [(&str, &str); 3] = [
190    ("Access-Control-Allow-Origin", "*"),
191    ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
192    (
193        "Access-Control-Allow-Headers",
194        "Content-Type, Authorization, Git-Protocol",
195    ),
196];
197
198/// [`GIT_CORS_HEADERS`] as owned pairs, ready to extend a header `Vec`.
199#[must_use]
200pub fn git_cors_header_pairs() -> Vec<(String, String)> {
201    GIT_CORS_HEADERS
202        .iter()
203        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
204        .collect()
205}
206
207/// The Git HTTP service.
208#[derive(Clone)]
209pub struct GitHttpService {
210    repo_root: PathBuf,
211    auth: Option<Arc<dyn GitAuth>>,
212    backend_path: PathBuf,
213    push_guard: Option<PushPathGuard>,
214}
215
216impl std::fmt::Debug for GitHttpService {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("GitHttpService")
219            .field("repo_root", &self.repo_root)
220            .field("auth", &self.auth.is_some())
221            .field("backend_path", &self.backend_path)
222            .field("push_guard", &self.push_guard.is_some())
223            .finish()
224    }
225}
226
227impl GitHttpService {
228    /// Build a service rooted at `repo_root`. All repos served must
229    /// live under this directory.
230    #[must_use]
231    pub fn new(repo_root: PathBuf) -> Self {
232        let backend = std::env::var("GIT_HTTP_BACKEND_PATH")
233            .map(PathBuf::from)
234            .unwrap_or_else(|_| PathBuf::from(DEFAULT_GIT_HTTP_BACKEND));
235        Self {
236            repo_root,
237            auth: None,
238            backend_path: backend,
239            push_guard: None,
240        }
241    }
242
243    /// Install a pre-receive path guard (see [`crate::push_guard`]).
244    ///
245    /// `guard` receives each path a push would add, change or delete, as a
246    /// repository-relative path with a leading `/`, and returns `true` to
247    /// refuse it. With a guard installed every `git-receive-pack` request is
248    /// inspected before the CGI runs and the **whole push** is refused if any
249    /// path is refused, is a symlink or submodule entry, or the push exceeds the
250    /// documented size bounds. Without one, pushes are not inspected: use this
251    /// whenever the repository working tree is served or interpreted by
252    /// anything other than git.
253    #[must_use]
254    pub fn with_push_guard(mut self, guard: PushPathGuard) -> Self {
255        self.push_guard = Some(guard);
256        self
257    }
258
259    /// Override the default CGI binary path.
260    #[must_use]
261    pub fn with_backend_path(mut self, path: PathBuf) -> Self {
262        self.backend_path = path;
263        self
264    }
265
266    /// Plug in an authoriser. Without one, write requests still
267    /// succeed — the service becomes an anonymous-push setup, which
268    /// is the behaviour JSS uses when no `handleAuth` pre-hook fires.
269    #[must_use]
270    pub fn with_auth<A: GitAuth + 'static>(mut self, auth: A) -> Self {
271        self.auth = Some(Arc::new(auth));
272        self
273    }
274
275    /// Same as [`Self::with_auth`] but takes a pre-boxed Arc.
276    #[must_use]
277    pub fn with_auth_arc(mut self, auth: Arc<dyn GitAuth>) -> Self {
278        self.auth = Some(auth);
279        self
280    }
281
282    /// Handle an incoming Git HTTP request.
283    pub async fn handle(&self, req: GitRequest) -> Result<GitResponse, GitError> {
284        // CORS preflight — JSS lines 97-102.
285        if req.method.eq_ignore_ascii_case("OPTIONS") {
286            return Ok(GitResponse {
287                status: 200,
288                headers: git_cors_header_pairs(),
289                body: Bytes::new(),
290            });
291        }
292
293        // 1. Parse + guard the repo path.
294        let slug = extract_repo_slug(&req.path);
295        let repo_abs = if slug == "." {
296            self.repo_root.canonicalize()?
297        } else {
298            path_safe(&self.repo_root, &slug)?
299        };
300
301        // 2. Auth for writes (JSS: the route-level `preValidation`
302        //    hook on `/git-receive-pack` calls `handleAuth`; we fold
303        //    that into a single check here). P1-3: reads (clone/fetch)
304        //    are gated through the SAME provider when one is configured,
305        //    closing the world-readable git hole. When no provider is
306        //    plugged in the service stays anonymous (the documented
307        //    no-auth setup), matching JSS's behaviour with no
308        //    `handleAuth` pre-hook.
309        //
310        //    Auth runs BEFORE the git-dir resolution so an unauthenticated /
311        //    unauthorised push can never trigger the on-demand auto-init below
312        //    (defense-in-depth; the server's WAC gate also denies it first).
313        let mut remote_user = String::new();
314        let needs_auth = req.is_write() || (req.is_read() && self.auth.is_some());
315        if needs_auth {
316            let auth = self
317                .auth
318                .as_ref()
319                .ok_or_else(|| GitError::Unauthorised("no auth provider configured".into()))?;
320            match auth.authorise(&req).await {
321                Ok(id) => remote_user = id,
322                Err(AuthError::Missing) => {
323                    return Err(GitError::Unauthorised("missing Authorization".into()));
324                }
325                Err(e) => return Err(GitError::Auth(e)),
326            }
327        }
328
329        // 3. Resolve the git dir. A missing `.git` on a WRITE triggers
330        //    on-demand auto-init (JSS `git.js` `tryAutoInitRepo`, #466/#469/
331        //    #472): the first push to a not-yet-initialised pod repo
332        //    initialises it (`git init -b main` + `receive.denyCurrentBranch
333        //    updateInstead`) and then proceeds, REPLACING the previous
334        //    "404 NotARepository on first push" behaviour. Reads to a missing
335        //    repo still 404 — there is nothing to clone. This is the single
336        //    canonical write path; there is no parallel branch.
337        let git_dir = match find_git_dir(&repo_abs)? {
338            Some(g) => g,
339            None if req.is_write() => {
340                crate::init::GitAutoInit::new()
341                    .init_repo_at(&repo_abs)
342                    .await
343                    .map_err(|e| GitError::BackendFailed {
344                        exit_code: None,
345                        stderr: format!("auto-init {}: {e}", repo_abs.display()),
346                    })?;
347                // Re-resolve after init; the `.git` dir must now exist.
348                find_git_dir(&repo_abs)?.ok_or_else(|| GitError::NotARepository(slug.clone()))?
349            }
350            None => {
351                return Err(GitError::NotARepository(slug));
352            }
353        };
354
355        // 4. Apply the receive-pack config mutators on writes. Errors
356        //    are best-effort (JSS swallows them too).
357        if req.is_write() {
358            let _ = apply_write_config(&git_dir, &repo_abs).await;
359        }
360
361        // 5. Pre-receive path guard. The per-repository lock is held across the
362        //    check and the CGI run so `HEAD` cannot move in between.
363        let mut _push_lock = None;
364        if let Some(guard) = self.push_guard.as_ref().filter(|_| {
365            req.is_write()
366                && req.method.eq_ignore_ascii_case("POST")
367                && req.path.ends_with("/git-receive-pack")
368        }) {
369            _push_lock = Some(repo_lock(&git_dir.git_dir).lock_owned().await);
370            if let Err(reason) = check_push(&git_dir.git_dir, &req.body, guard).await {
371                return refusal_response(&req.body, &reason)
372                    .map_err(|r| GitError::PushRefused(r.0));
373            }
374        }
375
376        // 6. Spawn the CGI and shuttle request/response bytes.
377        spawn_cgi(
378            &self.backend_path,
379            &self.repo_root,
380            &git_dir,
381            &remote_user,
382            req,
383        )
384        .await
385    }
386}
387
388/// Core CGI driver — shared by all routes.
389async fn spawn_cgi(
390    backend: &Path,
391    repo_root: &Path,
392    git_dir: &crate::config::GitDir,
393    remote_user: &str,
394    req: GitRequest,
395) -> Result<GitResponse, GitError> {
396    // Assemble CGI env. We deliberately start from an empty env and
397    // only inherit PATH (to locate git subcommands the backend itself
398    // shells out to) — this matches the spirit of JSS which spreads
399    // `process.env` but we narrow it for defence-in-depth.
400    let mut env: HashMap<String, String> = HashMap::new();
401    if let Ok(path) = std::env::var("PATH") {
402        env.insert("PATH".into(), path);
403    }
404
405    env.insert(
406        "GIT_PROJECT_ROOT".into(),
407        repo_root
408            .canonicalize()
409            .unwrap_or_else(|_| repo_root.to_path_buf())
410            .to_string_lossy()
411            .into_owned(),
412    );
413    env.insert("GIT_HTTP_EXPORT_ALL".into(), String::new());
414    env.insert("GIT_HTTP_RECEIVE_PACK".into(), "true".into());
415    // `core.symlinks=false` (command-line precedence, so repo config cannot
416    // undo it): a pushed mode-120000 tree entry is checked out into the pod
417    // working tree as a plain file, never a symlink the storage layer could
418    // follow to a path the WAC guard does not name.
419    env.insert(
420        "GIT_CONFIG_PARAMETERS".into(),
421        "'uploadpack.allowTipSHA1InWant=true' 'core.symlinks=false'".into(),
422    );
423    env.insert("PATH_INFO".into(), req.path.clone());
424    env.insert("REQUEST_METHOD".into(), req.method.to_uppercase());
425    env.insert("QUERY_STRING".into(), req.query.clone());
426    env.insert("REMOTE_USER".into(), remote_user.to_string());
427
428    for (k, v) in &req.headers {
429        let kl = k.to_lowercase();
430        if kl == "content-type" {
431            env.insert("CONTENT_TYPE".into(), v.clone());
432        } else if kl == "content-length" {
433            env.insert("CONTENT_LENGTH".into(), v.clone());
434        }
435    }
436    env.entry("CONTENT_LENGTH".into())
437        .or_insert_with(|| req.body.len().to_string());
438    env.entry("CONTENT_TYPE".into()).or_default();
439
440    if git_dir.is_regular {
441        env.insert(
442            "GIT_DIR".into(),
443            git_dir.git_dir.to_string_lossy().into_owned(),
444        );
445    }
446
447    let mut cmd = Command::new(backend);
448    cmd.env_clear()
449        .envs(&env)
450        // Applied last so request-derived variables cannot weaken them.
451        .envs(crate::hardened::hardened_cgi_env())
452        .stdin(Stdio::piped())
453        .stdout(Stdio::piped())
454        .stderr(Stdio::piped());
455
456    let mut child = match cmd.spawn() {
457        Ok(c) => c,
458        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
459            return Err(GitError::BackendNotAvailable(format!(
460                "spawn {}: {}",
461                backend.display(),
462                e
463            )));
464        }
465        Err(e) => return Err(GitError::Io(e)),
466    };
467
468    // Write body → stdin.
469    if let Some(mut stdin) = child.stdin.take() {
470        if !req.body.is_empty() {
471            stdin.write_all(&req.body).await?;
472        }
473        drop(stdin); // close stdin so git-http-backend can exit.
474    }
475
476    // Collect stdout + stderr concurrently.
477    let mut stdout = child.stdout.take().expect("stdout piped");
478    let mut stderr = child.stderr.take().expect("stderr piped");
479
480    let mut stdout_task =
481        tokio::spawn(async move { read_bounded(&mut stdout, CGI_STDOUT_CAP).await });
482    let stderr_task =
483        tokio::spawn(async move { read_diagnostics_bounded(&mut stderr, CGI_STDERR_CAP).await });
484    let deadline = sleep(CGI_EXECUTION_TIMEOUT);
485    tokio::pin!(deadline);
486    let mut early_stdout = None;
487    let status = tokio::select! {
488        result = &mut stdout_task => {
489            match result {
490                Ok(Ok(bytes)) => early_stdout = Some(bytes),
491                Ok(Err(error)) => {
492                    let _ = child.kill().await;
493                    return Err(GitError::MalformedCgi(error.to_string()));
494                }
495                Err(error) => {
496                    let _ = child.kill().await;
497                    return Err(GitError::MalformedCgi(format!("stdout task: {error}")));
498                }
499            }
500            tokio::select! {
501                status = child.wait() => status?,
502                _ = &mut deadline => {
503                    let _ = child.kill().await;
504                    return Err(GitError::BackendFailed {
505                        exit_code: None,
506                        stderr: "git HTTP backend execution timed out".into(),
507                    });
508                }
509            }
510        }
511        status = child.wait() => status?,
512        _ = &mut deadline => {
513            let _ = child.kill().await;
514            return Err(GitError::BackendFailed {
515                exit_code: None,
516                stderr: "git HTTP backend execution timed out".into(),
517            });
518        }
519    };
520    let stdout_bytes = match early_stdout {
521        Some(bytes) => bytes,
522        None => stdout_task
523            .await
524            .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??,
525    };
526    let stderr_bytes = stderr_task.await.unwrap_or_default();
527
528    if !status.success() && stdout_bytes.is_empty() {
529        return Err(GitError::BackendFailed {
530            exit_code: status.code(),
531            stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
532        });
533    }
534
535    parse_cgi_output(&stdout_bytes)
536}
537
538/// Split CGI headers from body and translate into a `GitResponse`.
539fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
540    // Find the CGI header/body separator.
541    let (sep_idx, sep_len) = {
542        if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
543            (i, 4)
544        } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
545            (i, 2)
546        } else {
547            return Err(GitError::MalformedCgi("no header/body separator".into()));
548        }
549    };
550
551    let header_section = std::str::from_utf8(&stdout[..sep_idx])
552        .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
553    let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
554
555    let mut status: u16 = 200;
556    let mut headers: Vec<(String, String)> = Vec::new();
557
558    for line in header_section.split(['\n', '\r']) {
559        let line = line.trim();
560        if line.is_empty() {
561            continue;
562        }
563        let Some(colon) = line.find(':') else {
564            continue;
565        };
566        let key = line[..colon].trim().to_string();
567        let value = line[colon + 1..].trim().to_string();
568        if key.eq_ignore_ascii_case("status") {
569            status = value
570                .split_whitespace()
571                .next()
572                .and_then(|s| s.parse().ok())
573                .unwrap_or(200);
574        } else {
575            headers.push((key, value));
576        }
577    }
578
579    // CORS headers (JSS lines 218-220, single source of truth per #548).
580    headers.extend(git_cors_header_pairs());
581
582    Ok(GitResponse {
583        status,
584        headers,
585        body,
586    })
587}
588
589fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
590    haystack.windows(needle.len()).position(|w| w == needle)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::auth::{AuthError, GitAuth};
597    use tempfile::TempDir;
598
599    /// Permissive auth used only to exercise the write path in unit tests —
600    /// the real server gates writes through WAC before `handle` is reached.
601    #[derive(Debug)]
602    struct AllowAll;
603
604    #[async_trait::async_trait]
605    impl GitAuth for AllowAll {
606        async fn authorise(&self, _req: &GitRequest) -> Result<String, AuthError> {
607            Ok("tester".to_string())
608        }
609    }
610
611    fn git_available() -> bool {
612        std::process::Command::new("git")
613            .arg("--version")
614            .stdout(Stdio::null())
615            .stderr(Stdio::null())
616            .status()
617            .map(|s| s.success())
618            .unwrap_or(false)
619    }
620
621    fn receive_pack_req(repo: &str) -> GitRequest {
622        GitRequest {
623            method: "POST".into(),
624            path: format!("/{repo}/git-receive-pack"),
625            query: String::new(),
626            headers: vec![(
627                "content-type".into(),
628                "application/x-git-receive-pack-request".into(),
629            )],
630            body: Bytes::new(),
631            host_url: Some("https://pod.example.com".into()),
632        }
633    }
634
635    /// First push to a not-yet-initialised repo must auto-init it instead of
636    /// 404ing at the `find_git_dir` gate (REPLACES the old 404 NotARepository
637    /// behaviour; JSS `tryAutoInitRepo` #466/#469/#472). We assert the `.git`
638    /// dir is created and the result is never `NotARepository`.
639    #[tokio::test]
640    async fn first_push_to_missing_repo_auto_inits_not_404() {
641        if !git_available() {
642            return;
643        }
644        let root = TempDir::new().unwrap();
645        // The pod container dir exists (the server creates it on provision) but
646        // there is no `.git` yet — the find_git_dir gate would previously 404.
647        std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
648
649        let service = GitHttpService::new(root.path().to_path_buf()).with_auth(AllowAll);
650        let result = service.handle(receive_pack_req("myrepo")).await;
651
652        // Auto-init must have run: the `.git` directory now exists.
653        assert!(
654            root.path().join("myrepo").join(".git").is_dir(),
655            "first push must auto-init the repo (.git dir must exist)"
656        );
657
658        // The find_git_dir gate must NOT have produced a 404 NotARepository.
659        // The request proceeds to the CGI (which, given an empty body, may
660        // succeed or fail — either way it is past the gate, never 404-at-gate).
661        match result {
662            Ok(resp) => assert_ne!(
663                resp.status, 404,
664                "post-auto-init response must not be a 404 gate denial"
665            ),
666            Err(GitError::NotARepository(_)) => {
667                panic!("auto-init failed: still NotARepository after init")
668            }
669            // A CGI/backend error (e.g. the empty receive-pack body) is
670            // acceptable — it proves we got past the gate into the CGI.
671            Err(_) => {}
672        }
673    }
674
675    /// Reads to a missing repo still 404 — there is nothing to clone, and a
676    /// read must never trigger repo creation.
677    #[tokio::test]
678    async fn read_of_missing_repo_still_404s_and_does_not_init() {
679        if !git_available() {
680            return;
681        }
682        let root = TempDir::new().unwrap();
683        std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
684
685        // Anonymous read (no auth provider) — is_read with no auth configured
686        // does not require auth, so it reaches the git-dir gate.
687        let service = GitHttpService::new(root.path().to_path_buf());
688        let req = GitRequest {
689            method: "GET".into(),
690            path: "/myrepo/info/refs".into(),
691            query: "service=git-upload-pack".into(),
692            headers: vec![],
693            body: Bytes::new(),
694            host_url: None,
695        };
696        let result = service.handle(req).await;
697
698        assert!(
699            matches!(result, Err(GitError::NotARepository(_))),
700            "read of a missing repo must 404 (NotARepository)"
701        );
702        assert!(
703            !root.path().join("myrepo").join(".git").exists(),
704            "a read must never auto-init the repo"
705        );
706    }
707
708    #[test]
709    fn parse_cgi_basic() {
710        let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
711        let r = parse_cgi_output(raw).unwrap();
712        assert_eq!(r.status, 200);
713        assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
714        assert!(r
715            .headers
716            .iter()
717            .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
718    }
719
720    #[test]
721    fn parse_cgi_lf_only_separator() {
722        let raw = b"Content-Type: text/plain\n\nHELLO";
723        let r = parse_cgi_output(raw).unwrap();
724        assert_eq!(r.body, Bytes::from_static(b"HELLO"));
725    }
726
727    #[test]
728    fn parse_cgi_status_override() {
729        let raw = b"Status: 403 Forbidden\r\n\r\nNO";
730        let r = parse_cgi_output(raw).unwrap();
731        assert_eq!(r.status, 403);
732    }
733
734    #[test]
735    fn parse_cgi_no_separator_fails() {
736        let raw = b"Content-Type: text/plain\r\nonly-headers";
737        assert!(parse_cgi_output(raw).is_err());
738    }
739
740    #[test]
741    fn git_request_is_write_detects_receive_pack_path() {
742        let req = GitRequest {
743            method: "POST".into(),
744            path: "/repo/git-receive-pack".into(),
745            query: String::new(),
746            headers: vec![],
747            body: Bytes::new(),
748            host_url: None,
749        };
750        assert!(req.is_write());
751    }
752
753    #[test]
754    fn git_request_is_write_detects_receive_pack_query() {
755        let req = GitRequest {
756            method: "GET".into(),
757            path: "/repo/info/refs".into(),
758            query: "service=git-receive-pack".into(),
759            headers: vec![],
760            body: Bytes::new(),
761            host_url: None,
762        };
763        assert!(req.is_write());
764    }
765
766    #[test]
767    fn git_request_is_write_false_for_read() {
768        let req = GitRequest {
769            method: "GET".into(),
770            path: "/repo/info/refs".into(),
771            query: "service=git-upload-pack".into(),
772            headers: vec![],
773            body: Bytes::new(),
774            host_url: None,
775        };
776        assert!(!req.is_write());
777    }
778
779    #[test]
780    fn git_request_is_read_detects_upload_pack_and_info_refs() {
781        // info/refs advertisement for a clone.
782        let advert = GitRequest {
783            method: "GET".into(),
784            path: "/repo/info/refs".into(),
785            query: "service=git-upload-pack".into(),
786            headers: vec![],
787            body: Bytes::new(),
788            host_url: None,
789        };
790        assert!(advert.is_read());
791        assert!(!advert.is_write());
792
793        // The upload-pack POST itself.
794        let pack = GitRequest {
795            method: "POST".into(),
796            path: "/repo/git-upload-pack".into(),
797            query: String::new(),
798            headers: vec![],
799            body: Bytes::new(),
800            host_url: None,
801        };
802        assert!(pack.is_read());
803
804        // Dumb-HTTP object fetch.
805        let object = GitRequest {
806            method: "GET".into(),
807            path: "/repo/objects/info/packs".into(),
808            query: String::new(),
809            headers: vec![],
810            body: Bytes::new(),
811            host_url: None,
812        };
813        assert!(object.is_read());
814    }
815
816    #[test]
817    fn git_request_is_read_false_for_write() {
818        // A receive-pack advertisement is a write, never a read.
819        let req = GitRequest {
820            method: "GET".into(),
821            path: "/repo/info/refs".into(),
822            query: "service=git-receive-pack".into(),
823            headers: vec![],
824            body: Bytes::new(),
825            host_url: None,
826        };
827        assert!(req.is_write());
828        assert!(!req.is_read());
829    }
830
831    #[test]
832    fn git_request_auth_url_without_query() {
833        let req = GitRequest {
834            method: "GET".into(),
835            path: "/repo/info/refs".into(),
836            query: String::new(),
837            headers: vec![],
838            body: Bytes::new(),
839            host_url: Some("https://pod.example.com".into()),
840        };
841        assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
842    }
843
844    #[test]
845    fn git_request_auth_url_with_query() {
846        let req = GitRequest {
847            method: "GET".into(),
848            path: "/repo/info/refs".into(),
849            query: "service=git-upload-pack".into(),
850            headers: vec![],
851            body: Bytes::new(),
852            host_url: Some("https://pod.example.com".into()),
853        };
854        assert_eq!(
855            req.auth_url(),
856            "https://pod.example.com/repo/info/refs?service=git-upload-pack"
857        );
858    }
859
860    #[test]
861    fn git_response_error_helper() {
862        let r = GitResponse::error(404, "not found");
863        assert_eq!(r.status, 404);
864        assert!(!r.body.is_empty());
865    }
866
867    #[tokio::test]
868    async fn bounded_reader_rejects_output_above_cap() {
869        let (mut writer, mut reader) = tokio::io::duplex(64);
870        let write = tokio::spawn(async move {
871            writer.write_all(&[7u8; 33]).await.unwrap();
872        });
873        let result = read_bounded(&mut reader, 32).await;
874        write.await.unwrap();
875        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::FileTooLarge);
876    }
877}