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| {
265                        GitError::BackendFailed {
266                            exit_code: None,
267                            stderr: format!("auto-init {}: {e}", repo_abs.display()),
268                        }
269                    })?;
270                // Re-resolve after init; the `.git` dir must now exist.
271                find_git_dir(&repo_abs)?
272                    .ok_or_else(|| GitError::NotARepository(slug.clone()))?
273            }
274            None => {
275                return Err(GitError::NotARepository(slug));
276            }
277        };
278
279        // 4. Apply the receive-pack config mutators on writes. Errors
280        //    are best-effort (JSS swallows them too).
281        if req.is_write() {
282            let _ = apply_write_config(&git_dir, &repo_abs).await;
283        }
284
285        // 5. Spawn the CGI and shuttle request/response bytes.
286        spawn_cgi(
287            &self.backend_path,
288            &self.repo_root,
289            &git_dir,
290            &remote_user,
291            req,
292        )
293        .await
294    }
295}
296
297/// Core CGI driver — shared by all routes.
298async fn spawn_cgi(
299    backend: &Path,
300    repo_root: &Path,
301    git_dir: &crate::config::GitDir,
302    remote_user: &str,
303    req: GitRequest,
304) -> Result<GitResponse, GitError> {
305    // Assemble CGI env. We deliberately start from an empty env and
306    // only inherit PATH (to locate git subcommands the backend itself
307    // shells out to) — this matches the spirit of JSS which spreads
308    // `process.env` but we narrow it for defence-in-depth.
309    let mut env: HashMap<String, String> = HashMap::new();
310    if let Ok(path) = std::env::var("PATH") {
311        env.insert("PATH".into(), path);
312    }
313
314    env.insert(
315        "GIT_PROJECT_ROOT".into(),
316        repo_root
317            .canonicalize()
318            .unwrap_or_else(|_| repo_root.to_path_buf())
319            .to_string_lossy()
320            .into_owned(),
321    );
322    env.insert("GIT_HTTP_EXPORT_ALL".into(), String::new());
323    env.insert("GIT_HTTP_RECEIVE_PACK".into(), "true".into());
324    env.insert(
325        "GIT_CONFIG_PARAMETERS".into(),
326        "'uploadpack.allowTipSHA1InWant=true'".into(),
327    );
328    env.insert("PATH_INFO".into(), req.path.clone());
329    env.insert("REQUEST_METHOD".into(), req.method.to_uppercase());
330    env.insert("QUERY_STRING".into(), req.query.clone());
331    env.insert("REMOTE_USER".into(), remote_user.to_string());
332
333    for (k, v) in &req.headers {
334        let kl = k.to_lowercase();
335        if kl == "content-type" {
336            env.insert("CONTENT_TYPE".into(), v.clone());
337        } else if kl == "content-length" {
338            env.insert("CONTENT_LENGTH".into(), v.clone());
339        }
340    }
341    env.entry("CONTENT_LENGTH".into())
342        .or_insert_with(|| req.body.len().to_string());
343    env.entry("CONTENT_TYPE".into()).or_default();
344
345    if git_dir.is_regular {
346        env.insert(
347            "GIT_DIR".into(),
348            git_dir.git_dir.to_string_lossy().into_owned(),
349        );
350    }
351
352    let mut cmd = Command::new(backend);
353    cmd.env_clear()
354        .envs(&env)
355        .stdin(Stdio::piped())
356        .stdout(Stdio::piped())
357        .stderr(Stdio::piped());
358
359    let mut child = match cmd.spawn() {
360        Ok(c) => c,
361        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
362            return Err(GitError::BackendNotAvailable(format!(
363                "spawn {}: {}",
364                backend.display(),
365                e
366            )));
367        }
368        Err(e) => return Err(GitError::Io(e)),
369    };
370
371    // Write body → stdin.
372    if let Some(mut stdin) = child.stdin.take() {
373        if !req.body.is_empty() {
374            stdin.write_all(&req.body).await?;
375        }
376        drop(stdin); // close stdin so git-http-backend can exit.
377    }
378
379    // Collect stdout + stderr concurrently.
380    let mut stdout = child.stdout.take().expect("stdout piped");
381    let mut stderr = child.stderr.take().expect("stderr piped");
382
383    let stdout_task = tokio::spawn(async move {
384        let mut buf = Vec::new();
385        stdout.read_to_end(&mut buf).await.map(|_| buf)
386    });
387    let stderr_task = tokio::spawn(async move {
388        let mut buf = Vec::new();
389        let _ = stderr.read_to_end(&mut buf).await;
390        buf
391    });
392
393    let status = child.wait().await?;
394    let stdout_bytes = stdout_task
395        .await
396        .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??;
397    let stderr_bytes = stderr_task.await.unwrap_or_default();
398
399    if !status.success() && stdout_bytes.is_empty() {
400        return Err(GitError::BackendFailed {
401            exit_code: status.code(),
402            stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
403        });
404    }
405
406    parse_cgi_output(&stdout_bytes)
407}
408
409/// Split CGI headers from body and translate into a `GitResponse`.
410fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
411    // Find the CGI header/body separator.
412    let (sep_idx, sep_len) = {
413        if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
414            (i, 4)
415        } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
416            (i, 2)
417        } else {
418            return Err(GitError::MalformedCgi("no header/body separator".into()));
419        }
420    };
421
422    let header_section = std::str::from_utf8(&stdout[..sep_idx])
423        .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
424    let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
425
426    let mut status: u16 = 200;
427    let mut headers: Vec<(String, String)> = Vec::new();
428
429    for line in header_section.split(['\n', '\r']) {
430        let line = line.trim();
431        if line.is_empty() {
432            continue;
433        }
434        let Some(colon) = line.find(':') else {
435            continue;
436        };
437        let key = line[..colon].trim().to_string();
438        let value = line[colon + 1..].trim().to_string();
439        if key.eq_ignore_ascii_case("status") {
440            status = value
441                .split_whitespace()
442                .next()
443                .and_then(|s| s.parse().ok())
444                .unwrap_or(200);
445        } else {
446            headers.push((key, value));
447        }
448    }
449
450    // CORS headers (JSS lines 218-220).
451    headers.push(("Access-Control-Allow-Origin".into(), "*".into()));
452    headers.push((
453        "Access-Control-Allow-Methods".into(),
454        "GET, POST, OPTIONS".into(),
455    ));
456    headers.push((
457        "Access-Control-Allow-Headers".into(),
458        "Content-Type, Authorization".into(),
459    ));
460
461    Ok(GitResponse {
462        status,
463        headers,
464        body,
465    })
466}
467
468fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
469    haystack.windows(needle.len()).position(|w| w == needle)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::auth::{AuthError, GitAuth};
476    use tempfile::TempDir;
477
478    /// Permissive auth used only to exercise the write path in unit tests —
479    /// the real server gates writes through WAC before `handle` is reached.
480    #[derive(Debug)]
481    struct AllowAll;
482
483    #[async_trait::async_trait]
484    impl GitAuth for AllowAll {
485        async fn authorise(&self, _req: &GitRequest) -> Result<String, AuthError> {
486            Ok("tester".to_string())
487        }
488    }
489
490    fn git_available() -> bool {
491        std::process::Command::new("git")
492            .arg("--version")
493            .stdout(Stdio::null())
494            .stderr(Stdio::null())
495            .status()
496            .map(|s| s.success())
497            .unwrap_or(false)
498    }
499
500    fn receive_pack_req(repo: &str) -> GitRequest {
501        GitRequest {
502            method: "POST".into(),
503            path: format!("/{repo}/git-receive-pack"),
504            query: String::new(),
505            headers: vec![(
506                "content-type".into(),
507                "application/x-git-receive-pack-request".into(),
508            )],
509            body: Bytes::new(),
510            host_url: Some("https://pod.example.com".into()),
511        }
512    }
513
514    /// First push to a not-yet-initialised repo must auto-init it instead of
515    /// 404ing at the `find_git_dir` gate (REPLACES the old 404 NotARepository
516    /// behaviour; JSS `tryAutoInitRepo` #466/#469/#472). We assert the `.git`
517    /// dir is created and the result is never `NotARepository`.
518    #[tokio::test]
519    async fn first_push_to_missing_repo_auto_inits_not_404() {
520        if !git_available() {
521            return;
522        }
523        let root = TempDir::new().unwrap();
524        // The pod container dir exists (the server creates it on provision) but
525        // there is no `.git` yet — the find_git_dir gate would previously 404.
526        std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
527
528        let service = GitHttpService::new(root.path().to_path_buf()).with_auth(AllowAll);
529        let result = service.handle(receive_pack_req("myrepo")).await;
530
531        // Auto-init must have run: the `.git` directory now exists.
532        assert!(
533            root.path().join("myrepo").join(".git").is_dir(),
534            "first push must auto-init the repo (.git dir must exist)"
535        );
536
537        // The find_git_dir gate must NOT have produced a 404 NotARepository.
538        // The request proceeds to the CGI (which, given an empty body, may
539        // succeed or fail — either way it is past the gate, never 404-at-gate).
540        match result {
541            Ok(resp) => assert_ne!(
542                resp.status, 404,
543                "post-auto-init response must not be a 404 gate denial"
544            ),
545            Err(GitError::NotARepository(_)) => {
546                panic!("auto-init failed: still NotARepository after init")
547            }
548            // A CGI/backend error (e.g. the empty receive-pack body) is
549            // acceptable — it proves we got past the gate into the CGI.
550            Err(_) => {}
551        }
552    }
553
554    /// Reads to a missing repo still 404 — there is nothing to clone, and a
555    /// read must never trigger repo creation.
556    #[tokio::test]
557    async fn read_of_missing_repo_still_404s_and_does_not_init() {
558        if !git_available() {
559            return;
560        }
561        let root = TempDir::new().unwrap();
562        std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
563
564        // Anonymous read (no auth provider) — is_read with no auth configured
565        // does not require auth, so it reaches the git-dir gate.
566        let service = GitHttpService::new(root.path().to_path_buf());
567        let req = GitRequest {
568            method: "GET".into(),
569            path: "/myrepo/info/refs".into(),
570            query: "service=git-upload-pack".into(),
571            headers: vec![],
572            body: Bytes::new(),
573            host_url: None,
574        };
575        let result = service.handle(req).await;
576
577        assert!(
578            matches!(result, Err(GitError::NotARepository(_))),
579            "read of a missing repo must 404 (NotARepository)"
580        );
581        assert!(
582            !root.path().join("myrepo").join(".git").exists(),
583            "a read must never auto-init the repo"
584        );
585    }
586
587    #[test]
588    fn parse_cgi_basic() {
589        let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
590        let r = parse_cgi_output(raw).unwrap();
591        assert_eq!(r.status, 200);
592        assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
593        assert!(r
594            .headers
595            .iter()
596            .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
597    }
598
599    #[test]
600    fn parse_cgi_lf_only_separator() {
601        let raw = b"Content-Type: text/plain\n\nHELLO";
602        let r = parse_cgi_output(raw).unwrap();
603        assert_eq!(r.body, Bytes::from_static(b"HELLO"));
604    }
605
606    #[test]
607    fn parse_cgi_status_override() {
608        let raw = b"Status: 403 Forbidden\r\n\r\nNO";
609        let r = parse_cgi_output(raw).unwrap();
610        assert_eq!(r.status, 403);
611    }
612
613    #[test]
614    fn parse_cgi_no_separator_fails() {
615        let raw = b"Content-Type: text/plain\r\nonly-headers";
616        assert!(parse_cgi_output(raw).is_err());
617    }
618
619    #[test]
620    fn git_request_is_write_detects_receive_pack_path() {
621        let req = GitRequest {
622            method: "POST".into(),
623            path: "/repo/git-receive-pack".into(),
624            query: String::new(),
625            headers: vec![],
626            body: Bytes::new(),
627            host_url: None,
628        };
629        assert!(req.is_write());
630    }
631
632    #[test]
633    fn git_request_is_write_detects_receive_pack_query() {
634        let req = GitRequest {
635            method: "GET".into(),
636            path: "/repo/info/refs".into(),
637            query: "service=git-receive-pack".into(),
638            headers: vec![],
639            body: Bytes::new(),
640            host_url: None,
641        };
642        assert!(req.is_write());
643    }
644
645    #[test]
646    fn git_request_is_write_false_for_read() {
647        let req = GitRequest {
648            method: "GET".into(),
649            path: "/repo/info/refs".into(),
650            query: "service=git-upload-pack".into(),
651            headers: vec![],
652            body: Bytes::new(),
653            host_url: None,
654        };
655        assert!(!req.is_write());
656    }
657
658    #[test]
659    fn git_request_is_read_detects_upload_pack_and_info_refs() {
660        // info/refs advertisement for a clone.
661        let advert = GitRequest {
662            method: "GET".into(),
663            path: "/repo/info/refs".into(),
664            query: "service=git-upload-pack".into(),
665            headers: vec![],
666            body: Bytes::new(),
667            host_url: None,
668        };
669        assert!(advert.is_read());
670        assert!(!advert.is_write());
671
672        // The upload-pack POST itself.
673        let pack = GitRequest {
674            method: "POST".into(),
675            path: "/repo/git-upload-pack".into(),
676            query: String::new(),
677            headers: vec![],
678            body: Bytes::new(),
679            host_url: None,
680        };
681        assert!(pack.is_read());
682
683        // Dumb-HTTP object fetch.
684        let object = GitRequest {
685            method: "GET".into(),
686            path: "/repo/objects/info/packs".into(),
687            query: String::new(),
688            headers: vec![],
689            body: Bytes::new(),
690            host_url: None,
691        };
692        assert!(object.is_read());
693    }
694
695    #[test]
696    fn git_request_is_read_false_for_write() {
697        // A receive-pack advertisement is a write, never a read.
698        let req = GitRequest {
699            method: "GET".into(),
700            path: "/repo/info/refs".into(),
701            query: "service=git-receive-pack".into(),
702            headers: vec![],
703            body: Bytes::new(),
704            host_url: None,
705        };
706        assert!(req.is_write());
707        assert!(!req.is_read());
708    }
709
710    #[test]
711    fn git_request_auth_url_without_query() {
712        let req = GitRequest {
713            method: "GET".into(),
714            path: "/repo/info/refs".into(),
715            query: String::new(),
716            headers: vec![],
717            body: Bytes::new(),
718            host_url: Some("https://pod.example.com".into()),
719        };
720        assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
721    }
722
723    #[test]
724    fn git_request_auth_url_with_query() {
725        let req = GitRequest {
726            method: "GET".into(),
727            path: "/repo/info/refs".into(),
728            query: "service=git-upload-pack".into(),
729            headers: vec![],
730            body: Bytes::new(),
731            host_url: Some("https://pod.example.com".into()),
732        };
733        assert_eq!(
734            req.auth_url(),
735            "https://pod.example.com/repo/info/refs?service=git-upload-pack"
736        );
737    }
738
739    #[test]
740    fn git_response_error_helper() {
741        let r = GitResponse::error(404, "not found");
742        assert_eq!(r.status, 404);
743        assert!(!r.body.is_empty());
744    }
745}