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