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