1use 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
35pub const DEFAULT_GIT_HTTP_BACKEND: &str = "/usr/lib/git-core/git-http-backend";
39
40#[derive(Debug, Clone)]
46pub struct GitRequest {
47 pub method: String,
49 pub path: String,
52 pub query: String,
54 pub headers: Vec<(String, String)>,
57 pub body: Bytes,
59 pub host_url: Option<String>,
63}
64
65impl GitRequest {
66 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 #[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 #[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#[derive(Debug, Clone)]
110pub struct GitResponse {
111 pub status: u16,
114 pub headers: Vec<(String, String)>,
116 pub body: Bytes,
118}
119
120impl GitResponse {
121 #[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#[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 #[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 #[must_use]
172 pub fn with_backend_path(mut self, path: PathBuf) -> Self {
173 self.backend_path = path;
174 self
175 }
176
177 #[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 #[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 pub async fn handle(&self, req: GitRequest) -> Result<GitResponse, GitError> {
195 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 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 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 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 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 if req.is_write() {
282 let _ = apply_write_config(&git_dir, &repo_abs).await;
283 }
284
285 spawn_cgi(
287 &self.backend_path,
288 &self.repo_root,
289 &git_dir,
290 &remote_user,
291 req,
292 )
293 .await
294 }
295}
296
297async 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 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 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); }
378
379 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
409fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
411 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 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 #[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 #[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 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 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 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 Err(_) => {}
551 }
552 }
553
554 #[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 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 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 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 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 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}