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