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
87/// CGI response to return to the HTTP layer.
88#[derive(Debug, Clone)]
89pub struct GitResponse {
90    /// HTTP status (derived from the CGI `Status:` header, or 200 by
91    /// default).
92    pub status: u16,
93    /// All response headers emitted by the CGI plus CORS headers.
94    pub headers: Vec<(String, String)>,
95    /// Body bytes — already includes the CGI body payload.
96    pub body: Bytes,
97}
98
99impl GitResponse {
100    /// Build a simple error response (no CGI invocation).
101    #[must_use]
102    pub fn error(status: u16, msg: impl Into<String>) -> Self {
103        let msg = msg.into();
104        let body = Bytes::from(format!("{{\"error\":\"{msg}\"}}"));
105        Self {
106            status,
107            headers: vec![
108                ("content-type".into(), "application/json".into()),
109                ("access-control-allow-origin".into(), "*".into()),
110            ],
111            body,
112        }
113    }
114}
115
116/// The Git HTTP service.
117#[derive(Clone)]
118pub struct GitHttpService {
119    repo_root: PathBuf,
120    auth: Option<Arc<dyn GitAuth>>,
121    backend_path: PathBuf,
122}
123
124impl std::fmt::Debug for GitHttpService {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("GitHttpService")
127            .field("repo_root", &self.repo_root)
128            .field("auth", &self.auth.is_some())
129            .field("backend_path", &self.backend_path)
130            .finish()
131    }
132}
133
134impl GitHttpService {
135    /// Build a service rooted at `repo_root`. All repos served must
136    /// live under this directory.
137    #[must_use]
138    pub fn new(repo_root: PathBuf) -> Self {
139        let backend = std::env::var("GIT_HTTP_BACKEND_PATH")
140            .map(PathBuf::from)
141            .unwrap_or_else(|_| PathBuf::from(DEFAULT_GIT_HTTP_BACKEND));
142        Self {
143            repo_root,
144            auth: None,
145            backend_path: backend,
146        }
147    }
148
149    /// Override the default CGI binary path.
150    #[must_use]
151    pub fn with_backend_path(mut self, path: PathBuf) -> Self {
152        self.backend_path = path;
153        self
154    }
155
156    /// Plug in an authoriser. Without one, write requests still
157    /// succeed — the service becomes an anonymous-push setup, which
158    /// is the behaviour JSS uses when no `handleAuth` pre-hook fires.
159    #[must_use]
160    pub fn with_auth<A: GitAuth + 'static>(mut self, auth: A) -> Self {
161        self.auth = Some(Arc::new(auth));
162        self
163    }
164
165    /// Same as [`with_auth`] but takes a pre-boxed Arc.
166    #[must_use]
167    pub fn with_auth_arc(mut self, auth: Arc<dyn GitAuth>) -> Self {
168        self.auth = Some(auth);
169        self
170    }
171
172    /// Handle an incoming Git HTTP request.
173    pub async fn handle(&self, req: GitRequest) -> Result<GitResponse, GitError> {
174        // CORS preflight — JSS lines 97-102.
175        if req.method.eq_ignore_ascii_case("OPTIONS") {
176            return Ok(GitResponse {
177                status: 200,
178                headers: vec![
179                    ("access-control-allow-origin".into(), "*".into()),
180                    (
181                        "access-control-allow-methods".into(),
182                        "GET, POST, OPTIONS".into(),
183                    ),
184                    (
185                        "access-control-allow-headers".into(),
186                        "Content-Type, Authorization".into(),
187                    ),
188                ],
189                body: Bytes::new(),
190            });
191        }
192
193        // 1. Parse + guard the repo path.
194        let slug = extract_repo_slug(&req.path);
195        let repo_abs = if slug == "." {
196            self.repo_root.canonicalize()?
197        } else {
198            path_safe(&self.repo_root, &slug)?
199        };
200
201        // 2. Find the git dir. Missing => 404.
202        let git_dir = match find_git_dir(&repo_abs)? {
203            Some(g) => g,
204            None => {
205                return Err(GitError::NotARepository(slug));
206            }
207        };
208
209        // 3. Auth for writes (JSS: the route-level `preValidation`
210        //    hook on `/git-receive-pack` calls `handleAuth`; we fold
211        //    that into a single check here).
212        let mut remote_user = String::new();
213        if req.is_write() {
214            let auth = self
215                .auth
216                .as_ref()
217                .ok_or_else(|| GitError::Unauthorised("no auth provider configured".into()))?;
218            match auth.authorise(&req).await {
219                Ok(id) => remote_user = id,
220                Err(AuthError::Missing) => {
221                    return Err(GitError::Unauthorised("missing Authorization".into()));
222                }
223                Err(e) => return Err(GitError::Auth(e)),
224            }
225        }
226
227        // 4. Apply the receive-pack config mutators on writes. Errors
228        //    are best-effort (JSS swallows them too).
229        if req.is_write() {
230            let _ = apply_write_config(&git_dir, &repo_abs).await;
231        }
232
233        // 5. Spawn the CGI and shuttle request/response bytes.
234        spawn_cgi(
235            &self.backend_path,
236            &self.repo_root,
237            &git_dir,
238            &remote_user,
239            req,
240        )
241        .await
242    }
243}
244
245/// Core CGI driver — shared by all routes.
246async fn spawn_cgi(
247    backend: &Path,
248    repo_root: &Path,
249    git_dir: &crate::config::GitDir,
250    remote_user: &str,
251    req: GitRequest,
252) -> Result<GitResponse, GitError> {
253    // Assemble CGI env. We deliberately start from an empty env and
254    // only inherit PATH (to locate git subcommands the backend itself
255    // shells out to) — this matches the spirit of JSS which spreads
256    // `process.env` but we narrow it for defence-in-depth.
257    let mut env: HashMap<String, String> = HashMap::new();
258    if let Ok(path) = std::env::var("PATH") {
259        env.insert("PATH".into(), path);
260    }
261
262    env.insert(
263        "GIT_PROJECT_ROOT".into(),
264        repo_root
265            .canonicalize()
266            .unwrap_or_else(|_| repo_root.to_path_buf())
267            .to_string_lossy()
268            .into_owned(),
269    );
270    env.insert("GIT_HTTP_EXPORT_ALL".into(), String::new());
271    env.insert("GIT_HTTP_RECEIVE_PACK".into(), "true".into());
272    env.insert(
273        "GIT_CONFIG_PARAMETERS".into(),
274        "'uploadpack.allowTipSHA1InWant=true'".into(),
275    );
276    env.insert("PATH_INFO".into(), req.path.clone());
277    env.insert("REQUEST_METHOD".into(), req.method.to_uppercase());
278    env.insert("QUERY_STRING".into(), req.query.clone());
279    env.insert("REMOTE_USER".into(), remote_user.to_string());
280
281    for (k, v) in &req.headers {
282        let kl = k.to_lowercase();
283        if kl == "content-type" {
284            env.insert("CONTENT_TYPE".into(), v.clone());
285        } else if kl == "content-length" {
286            env.insert("CONTENT_LENGTH".into(), v.clone());
287        }
288    }
289    env.entry("CONTENT_LENGTH".into())
290        .or_insert_with(|| req.body.len().to_string());
291    env.entry("CONTENT_TYPE".into()).or_default();
292
293    if git_dir.is_regular {
294        env.insert(
295            "GIT_DIR".into(),
296            git_dir.git_dir.to_string_lossy().into_owned(),
297        );
298    }
299
300    let mut cmd = Command::new(backend);
301    cmd.env_clear()
302        .envs(&env)
303        .stdin(Stdio::piped())
304        .stdout(Stdio::piped())
305        .stderr(Stdio::piped());
306
307    let mut child = match cmd.spawn() {
308        Ok(c) => c,
309        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
310            return Err(GitError::BackendNotAvailable(format!(
311                "spawn {}: {}",
312                backend.display(),
313                e
314            )));
315        }
316        Err(e) => return Err(GitError::Io(e)),
317    };
318
319    // Write body → stdin.
320    if let Some(mut stdin) = child.stdin.take() {
321        if !req.body.is_empty() {
322            stdin.write_all(&req.body).await?;
323        }
324        drop(stdin); // close stdin so git-http-backend can exit.
325    }
326
327    // Collect stdout + stderr concurrently.
328    let mut stdout = child.stdout.take().expect("stdout piped");
329    let mut stderr = child.stderr.take().expect("stderr piped");
330
331    let stdout_task = tokio::spawn(async move {
332        let mut buf = Vec::new();
333        stdout.read_to_end(&mut buf).await.map(|_| buf)
334    });
335    let stderr_task = tokio::spawn(async move {
336        let mut buf = Vec::new();
337        let _ = stderr.read_to_end(&mut buf).await;
338        buf
339    });
340
341    let status = child.wait().await?;
342    let stdout_bytes = stdout_task
343        .await
344        .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??;
345    let stderr_bytes = stderr_task.await.unwrap_or_default();
346
347    if !status.success() && stdout_bytes.is_empty() {
348        return Err(GitError::BackendFailed {
349            exit_code: status.code(),
350            stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
351        });
352    }
353
354    parse_cgi_output(&stdout_bytes)
355}
356
357/// Split CGI headers from body and translate into a `GitResponse`.
358fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
359    // Find the CGI header/body separator.
360    let (sep_idx, sep_len) = {
361        if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
362            (i, 4)
363        } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
364            (i, 2)
365        } else {
366            return Err(GitError::MalformedCgi("no header/body separator".into()));
367        }
368    };
369
370    let header_section = std::str::from_utf8(&stdout[..sep_idx])
371        .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
372    let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
373
374    let mut status: u16 = 200;
375    let mut headers: Vec<(String, String)> = Vec::new();
376
377    for line in header_section.split(['\n', '\r']) {
378        let line = line.trim();
379        if line.is_empty() {
380            continue;
381        }
382        let Some(colon) = line.find(':') else {
383            continue;
384        };
385        let key = line[..colon].trim().to_string();
386        let value = line[colon + 1..].trim().to_string();
387        if key.eq_ignore_ascii_case("status") {
388            status = value
389                .split_whitespace()
390                .next()
391                .and_then(|s| s.parse().ok())
392                .unwrap_or(200);
393        } else {
394            headers.push((key, value));
395        }
396    }
397
398    // CORS headers (JSS lines 218-220).
399    headers.push(("Access-Control-Allow-Origin".into(), "*".into()));
400    headers.push((
401        "Access-Control-Allow-Methods".into(),
402        "GET, POST, OPTIONS".into(),
403    ));
404    headers.push((
405        "Access-Control-Allow-Headers".into(),
406        "Content-Type, Authorization".into(),
407    ));
408
409    Ok(GitResponse {
410        status,
411        headers,
412        body,
413    })
414}
415
416fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
417    haystack.windows(needle.len()).position(|w| w == needle)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn parse_cgi_basic() {
426        let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
427        let r = parse_cgi_output(raw).unwrap();
428        assert_eq!(r.status, 200);
429        assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
430        assert!(r
431            .headers
432            .iter()
433            .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
434    }
435
436    #[test]
437    fn parse_cgi_lf_only_separator() {
438        let raw = b"Content-Type: text/plain\n\nHELLO";
439        let r = parse_cgi_output(raw).unwrap();
440        assert_eq!(r.body, Bytes::from_static(b"HELLO"));
441    }
442
443    #[test]
444    fn parse_cgi_status_override() {
445        let raw = b"Status: 403 Forbidden\r\n\r\nNO";
446        let r = parse_cgi_output(raw).unwrap();
447        assert_eq!(r.status, 403);
448    }
449
450    #[test]
451    fn parse_cgi_no_separator_fails() {
452        let raw = b"Content-Type: text/plain\r\nonly-headers";
453        assert!(parse_cgi_output(raw).is_err());
454    }
455
456    #[test]
457    fn git_request_is_write_detects_receive_pack_path() {
458        let req = GitRequest {
459            method: "POST".into(),
460            path: "/repo/git-receive-pack".into(),
461            query: String::new(),
462            headers: vec![],
463            body: Bytes::new(),
464            host_url: None,
465        };
466        assert!(req.is_write());
467    }
468
469    #[test]
470    fn git_request_is_write_detects_receive_pack_query() {
471        let req = GitRequest {
472            method: "GET".into(),
473            path: "/repo/info/refs".into(),
474            query: "service=git-receive-pack".into(),
475            headers: vec![],
476            body: Bytes::new(),
477            host_url: None,
478        };
479        assert!(req.is_write());
480    }
481
482    #[test]
483    fn git_request_is_write_false_for_read() {
484        let req = GitRequest {
485            method: "GET".into(),
486            path: "/repo/info/refs".into(),
487            query: "service=git-upload-pack".into(),
488            headers: vec![],
489            body: Bytes::new(),
490            host_url: None,
491        };
492        assert!(!req.is_write());
493    }
494
495    #[test]
496    fn git_request_auth_url_without_query() {
497        let req = GitRequest {
498            method: "GET".into(),
499            path: "/repo/info/refs".into(),
500            query: String::new(),
501            headers: vec![],
502            body: Bytes::new(),
503            host_url: Some("https://pod.example.com".into()),
504        };
505        assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
506    }
507
508    #[test]
509    fn git_request_auth_url_with_query() {
510        let req = GitRequest {
511            method: "GET".into(),
512            path: "/repo/info/refs".into(),
513            query: "service=git-upload-pack".into(),
514            headers: vec![],
515            body: Bytes::new(),
516            host_url: Some("https://pod.example.com".into()),
517        };
518        assert_eq!(
519            req.auth_url(),
520            "https://pod.example.com/repo/info/refs?service=git-upload-pack"
521        );
522    }
523
524    #[test]
525    fn git_response_error_helper() {
526        let r = GitResponse::error(404, "not found");
527        assert_eq!(r.status, 404);
528        assert!(!r.body.is_empty());
529    }
530}