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;
29use tokio::time::{sleep, Duration};
30
31use crate::auth::{AuthError, GitAuth};
32use crate::config::{apply_write_config, find_git_dir};
33use crate::error::GitError;
34use crate::guard::{extract_repo_slug, path_safe};
35use crate::push_guard::{check_push, refusal_response, repo_lock, PushPathGuard};
36
37pub const DEFAULT_GIT_HTTP_BACKEND: &str = "/usr/lib/git-core/git-http-backend";
41const CGI_STDOUT_CAP: usize = 256 * 1024 * 1024;
42const CGI_STDERR_CAP: usize = 64 * 1024;
43const CGI_EXECUTION_TIMEOUT: Duration = Duration::from_secs(120);
44
45async fn read_bounded<R: tokio::io::AsyncRead + Unpin>(
46 reader: &mut R,
47 cap: usize,
48) -> std::io::Result<Vec<u8>> {
49 let mut output = Vec::with_capacity(cap.min(64 * 1024));
50 let mut chunk = [0u8; 16 * 1024];
51 loop {
52 let read = reader.read(&mut chunk).await?;
53 if read == 0 {
54 return Ok(output);
55 }
56 if output.len().saturating_add(read) > cap {
57 return Err(std::io::Error::new(
58 std::io::ErrorKind::FileTooLarge,
59 "CGI stdout exceeded configured cap",
60 ));
61 }
62 output.extend_from_slice(&chunk[..read]);
63 }
64}
65
66async fn read_diagnostics_bounded<R: tokio::io::AsyncRead + Unpin>(
67 reader: &mut R,
68 cap: usize,
69) -> Vec<u8> {
70 let mut output = Vec::with_capacity(cap.min(8 * 1024));
71 let mut chunk = [0u8; 4096];
72 loop {
73 let Ok(read) = reader.read(&mut chunk).await else {
74 return output;
75 };
76 if read == 0 {
77 return output;
78 }
79 let remaining = cap.saturating_sub(output.len());
80 output.extend_from_slice(&chunk[..read.min(remaining)]);
81 }
84}
85
86#[derive(Debug, Clone)]
92pub struct GitRequest {
93 pub method: String,
95 pub path: String,
98 pub query: String,
100 pub headers: Vec<(String, String)>,
103 pub body: Bytes,
105 pub host_url: Option<String>,
109}
110
111impl GitRequest {
112 pub fn auth_url(&self) -> String {
115 let base = self
116 .host_url
117 .clone()
118 .unwrap_or_else(|| "http://localhost".to_string());
119 if self.query.is_empty() {
120 format!("{base}{}", self.path)
121 } else {
122 format!("{base}{}?{}", self.path, self.query)
123 }
124 }
125
126 #[must_use]
128 pub fn is_write(&self) -> bool {
129 self.path.contains("/git-receive-pack") || self.query.contains("service=git-receive-pack")
130 }
131
132 #[must_use]
142 pub fn is_read(&self) -> bool {
143 if self.is_write() {
144 return false;
145 }
146 self.path.contains("/git-upload-pack")
147 || self.query.contains("service=git-upload-pack")
148 || self.path.contains("/info/refs")
149 || self.path.contains("/objects/")
150 || self.path.ends_with("/HEAD")
151 }
152}
153
154#[derive(Debug, Clone)]
156pub struct GitResponse {
157 pub status: u16,
160 pub headers: Vec<(String, String)>,
162 pub body: Bytes,
164}
165
166impl GitResponse {
167 #[must_use]
169 pub fn error(status: u16, msg: impl Into<String>) -> Self {
170 let msg = msg.into();
171 let body = Bytes::from(format!("{{\"error\":\"{msg}\"}}"));
172 let mut headers: Vec<(String, String)> =
173 vec![("content-type".into(), "application/json".into())];
174 headers.extend(git_cors_header_pairs());
175 Self {
176 status,
177 headers,
178 body,
179 }
180 }
181}
182
183pub const GIT_CORS_HEADERS: [(&str, &str); 3] = [
190 ("Access-Control-Allow-Origin", "*"),
191 ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
192 (
193 "Access-Control-Allow-Headers",
194 "Content-Type, Authorization, Git-Protocol",
195 ),
196];
197
198#[must_use]
200pub fn git_cors_header_pairs() -> Vec<(String, String)> {
201 GIT_CORS_HEADERS
202 .iter()
203 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
204 .collect()
205}
206
207#[derive(Clone)]
209pub struct GitHttpService {
210 repo_root: PathBuf,
211 auth: Option<Arc<dyn GitAuth>>,
212 backend_path: PathBuf,
213 push_guard: Option<PushPathGuard>,
214}
215
216impl std::fmt::Debug for GitHttpService {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 f.debug_struct("GitHttpService")
219 .field("repo_root", &self.repo_root)
220 .field("auth", &self.auth.is_some())
221 .field("backend_path", &self.backend_path)
222 .field("push_guard", &self.push_guard.is_some())
223 .finish()
224 }
225}
226
227impl GitHttpService {
228 #[must_use]
231 pub fn new(repo_root: PathBuf) -> Self {
232 let backend = std::env::var("GIT_HTTP_BACKEND_PATH")
233 .map(PathBuf::from)
234 .unwrap_or_else(|_| PathBuf::from(DEFAULT_GIT_HTTP_BACKEND));
235 Self {
236 repo_root,
237 auth: None,
238 backend_path: backend,
239 push_guard: None,
240 }
241 }
242
243 #[must_use]
254 pub fn with_push_guard(mut self, guard: PushPathGuard) -> Self {
255 self.push_guard = Some(guard);
256 self
257 }
258
259 #[must_use]
261 pub fn with_backend_path(mut self, path: PathBuf) -> Self {
262 self.backend_path = path;
263 self
264 }
265
266 #[must_use]
270 pub fn with_auth<A: GitAuth + 'static>(mut self, auth: A) -> Self {
271 self.auth = Some(Arc::new(auth));
272 self
273 }
274
275 #[must_use]
277 pub fn with_auth_arc(mut self, auth: Arc<dyn GitAuth>) -> Self {
278 self.auth = Some(auth);
279 self
280 }
281
282 pub async fn handle(&self, req: GitRequest) -> Result<GitResponse, GitError> {
284 if req.method.eq_ignore_ascii_case("OPTIONS") {
286 return Ok(GitResponse {
287 status: 200,
288 headers: git_cors_header_pairs(),
289 body: Bytes::new(),
290 });
291 }
292
293 let slug = extract_repo_slug(&req.path);
295 let repo_abs = if slug == "." {
296 self.repo_root.canonicalize()?
297 } else {
298 path_safe(&self.repo_root, &slug)?
299 };
300
301 let mut remote_user = String::new();
314 let needs_auth = req.is_write() || (req.is_read() && self.auth.is_some());
315 if needs_auth {
316 let auth = self
317 .auth
318 .as_ref()
319 .ok_or_else(|| GitError::Unauthorised("no auth provider configured".into()))?;
320 match auth.authorise(&req).await {
321 Ok(id) => remote_user = id,
322 Err(AuthError::Missing) => {
323 return Err(GitError::Unauthorised("missing Authorization".into()));
324 }
325 Err(e) => return Err(GitError::Auth(e)),
326 }
327 }
328
329 let git_dir = match find_git_dir(&repo_abs)? {
338 Some(g) => g,
339 None if req.is_write() => {
340 crate::init::GitAutoInit::new()
341 .init_repo_at(&repo_abs)
342 .await
343 .map_err(|e| GitError::BackendFailed {
344 exit_code: None,
345 stderr: format!("auto-init {}: {e}", repo_abs.display()),
346 })?;
347 find_git_dir(&repo_abs)?.ok_or_else(|| GitError::NotARepository(slug.clone()))?
349 }
350 None => {
351 return Err(GitError::NotARepository(slug));
352 }
353 };
354
355 if req.is_write() {
358 let _ = apply_write_config(&git_dir, &repo_abs).await;
359 }
360
361 let mut _push_lock = None;
364 if let Some(guard) = self.push_guard.as_ref().filter(|_| {
365 req.is_write()
366 && req.method.eq_ignore_ascii_case("POST")
367 && req.path.ends_with("/git-receive-pack")
368 }) {
369 _push_lock = Some(repo_lock(&git_dir.git_dir).lock_owned().await);
370 if let Err(reason) = check_push(&git_dir.git_dir, &req.body, guard).await {
371 return refusal_response(&req.body, &reason)
372 .map_err(|r| GitError::PushRefused(r.0));
373 }
374 }
375
376 spawn_cgi(
378 &self.backend_path,
379 &self.repo_root,
380 &git_dir,
381 &remote_user,
382 req,
383 )
384 .await
385 }
386}
387
388async fn spawn_cgi(
390 backend: &Path,
391 repo_root: &Path,
392 git_dir: &crate::config::GitDir,
393 remote_user: &str,
394 req: GitRequest,
395) -> Result<GitResponse, GitError> {
396 let mut env: HashMap<String, String> = HashMap::new();
401 if let Ok(path) = std::env::var("PATH") {
402 env.insert("PATH".into(), path);
403 }
404
405 env.insert(
406 "GIT_PROJECT_ROOT".into(),
407 repo_root
408 .canonicalize()
409 .unwrap_or_else(|_| repo_root.to_path_buf())
410 .to_string_lossy()
411 .into_owned(),
412 );
413 env.insert("GIT_HTTP_EXPORT_ALL".into(), String::new());
414 env.insert("GIT_HTTP_RECEIVE_PACK".into(), "true".into());
415 env.insert(
420 "GIT_CONFIG_PARAMETERS".into(),
421 "'uploadpack.allowTipSHA1InWant=true' 'core.symlinks=false'".into(),
422 );
423 env.insert("PATH_INFO".into(), req.path.clone());
424 env.insert("REQUEST_METHOD".into(), req.method.to_uppercase());
425 env.insert("QUERY_STRING".into(), req.query.clone());
426 env.insert("REMOTE_USER".into(), remote_user.to_string());
427
428 for (k, v) in &req.headers {
429 let kl = k.to_lowercase();
430 if kl == "content-type" {
431 env.insert("CONTENT_TYPE".into(), v.clone());
432 } else if kl == "content-length" {
433 env.insert("CONTENT_LENGTH".into(), v.clone());
434 }
435 }
436 env.entry("CONTENT_LENGTH".into())
437 .or_insert_with(|| req.body.len().to_string());
438 env.entry("CONTENT_TYPE".into()).or_default();
439
440 if git_dir.is_regular {
441 env.insert(
442 "GIT_DIR".into(),
443 git_dir.git_dir.to_string_lossy().into_owned(),
444 );
445 }
446
447 let mut cmd = Command::new(backend);
448 cmd.env_clear()
449 .envs(&env)
450 .envs(crate::hardened::hardened_cgi_env())
452 .stdin(Stdio::piped())
453 .stdout(Stdio::piped())
454 .stderr(Stdio::piped());
455
456 let mut child = match cmd.spawn() {
457 Ok(c) => c,
458 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
459 return Err(GitError::BackendNotAvailable(format!(
460 "spawn {}: {}",
461 backend.display(),
462 e
463 )));
464 }
465 Err(e) => return Err(GitError::Io(e)),
466 };
467
468 if let Some(mut stdin) = child.stdin.take() {
470 if !req.body.is_empty() {
471 stdin.write_all(&req.body).await?;
472 }
473 drop(stdin); }
475
476 let mut stdout = child.stdout.take().expect("stdout piped");
478 let mut stderr = child.stderr.take().expect("stderr piped");
479
480 let mut stdout_task =
481 tokio::spawn(async move { read_bounded(&mut stdout, CGI_STDOUT_CAP).await });
482 let stderr_task =
483 tokio::spawn(async move { read_diagnostics_bounded(&mut stderr, CGI_STDERR_CAP).await });
484 let deadline = sleep(CGI_EXECUTION_TIMEOUT);
485 tokio::pin!(deadline);
486 let mut early_stdout = None;
487 let status = tokio::select! {
488 result = &mut stdout_task => {
489 match result {
490 Ok(Ok(bytes)) => early_stdout = Some(bytes),
491 Ok(Err(error)) => {
492 let _ = child.kill().await;
493 return Err(GitError::MalformedCgi(error.to_string()));
494 }
495 Err(error) => {
496 let _ = child.kill().await;
497 return Err(GitError::MalformedCgi(format!("stdout task: {error}")));
498 }
499 }
500 tokio::select! {
501 status = child.wait() => status?,
502 _ = &mut deadline => {
503 let _ = child.kill().await;
504 return Err(GitError::BackendFailed {
505 exit_code: None,
506 stderr: "git HTTP backend execution timed out".into(),
507 });
508 }
509 }
510 }
511 status = child.wait() => status?,
512 _ = &mut deadline => {
513 let _ = child.kill().await;
514 return Err(GitError::BackendFailed {
515 exit_code: None,
516 stderr: "git HTTP backend execution timed out".into(),
517 });
518 }
519 };
520 let stdout_bytes = match early_stdout {
521 Some(bytes) => bytes,
522 None => stdout_task
523 .await
524 .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??,
525 };
526 let stderr_bytes = stderr_task.await.unwrap_or_default();
527
528 if !status.success() && stdout_bytes.is_empty() {
529 return Err(GitError::BackendFailed {
530 exit_code: status.code(),
531 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
532 });
533 }
534
535 parse_cgi_output(&stdout_bytes)
536}
537
538fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
540 let (sep_idx, sep_len) = {
542 if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
543 (i, 4)
544 } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
545 (i, 2)
546 } else {
547 return Err(GitError::MalformedCgi("no header/body separator".into()));
548 }
549 };
550
551 let header_section = std::str::from_utf8(&stdout[..sep_idx])
552 .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
553 let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
554
555 let mut status: u16 = 200;
556 let mut headers: Vec<(String, String)> = Vec::new();
557
558 for line in header_section.split(['\n', '\r']) {
559 let line = line.trim();
560 if line.is_empty() {
561 continue;
562 }
563 let Some(colon) = line.find(':') else {
564 continue;
565 };
566 let key = line[..colon].trim().to_string();
567 let value = line[colon + 1..].trim().to_string();
568 if key.eq_ignore_ascii_case("status") {
569 status = value
570 .split_whitespace()
571 .next()
572 .and_then(|s| s.parse().ok())
573 .unwrap_or(200);
574 } else {
575 headers.push((key, value));
576 }
577 }
578
579 headers.extend(git_cors_header_pairs());
581
582 Ok(GitResponse {
583 status,
584 headers,
585 body,
586 })
587}
588
589fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
590 haystack.windows(needle.len()).position(|w| w == needle)
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::auth::{AuthError, GitAuth};
597 use tempfile::TempDir;
598
599 #[derive(Debug)]
602 struct AllowAll;
603
604 #[async_trait::async_trait]
605 impl GitAuth for AllowAll {
606 async fn authorise(&self, _req: &GitRequest) -> Result<String, AuthError> {
607 Ok("tester".to_string())
608 }
609 }
610
611 fn git_available() -> bool {
612 std::process::Command::new("git")
613 .arg("--version")
614 .stdout(Stdio::null())
615 .stderr(Stdio::null())
616 .status()
617 .map(|s| s.success())
618 .unwrap_or(false)
619 }
620
621 fn receive_pack_req(repo: &str) -> GitRequest {
622 GitRequest {
623 method: "POST".into(),
624 path: format!("/{repo}/git-receive-pack"),
625 query: String::new(),
626 headers: vec![(
627 "content-type".into(),
628 "application/x-git-receive-pack-request".into(),
629 )],
630 body: Bytes::new(),
631 host_url: Some("https://pod.example.com".into()),
632 }
633 }
634
635 #[tokio::test]
640 async fn first_push_to_missing_repo_auto_inits_not_404() {
641 if !git_available() {
642 return;
643 }
644 let root = TempDir::new().unwrap();
645 std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
648
649 let service = GitHttpService::new(root.path().to_path_buf()).with_auth(AllowAll);
650 let result = service.handle(receive_pack_req("myrepo")).await;
651
652 assert!(
654 root.path().join("myrepo").join(".git").is_dir(),
655 "first push must auto-init the repo (.git dir must exist)"
656 );
657
658 match result {
662 Ok(resp) => assert_ne!(
663 resp.status, 404,
664 "post-auto-init response must not be a 404 gate denial"
665 ),
666 Err(GitError::NotARepository(_)) => {
667 panic!("auto-init failed: still NotARepository after init")
668 }
669 Err(_) => {}
672 }
673 }
674
675 #[tokio::test]
678 async fn read_of_missing_repo_still_404s_and_does_not_init() {
679 if !git_available() {
680 return;
681 }
682 let root = TempDir::new().unwrap();
683 std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
684
685 let service = GitHttpService::new(root.path().to_path_buf());
688 let req = GitRequest {
689 method: "GET".into(),
690 path: "/myrepo/info/refs".into(),
691 query: "service=git-upload-pack".into(),
692 headers: vec![],
693 body: Bytes::new(),
694 host_url: None,
695 };
696 let result = service.handle(req).await;
697
698 assert!(
699 matches!(result, Err(GitError::NotARepository(_))),
700 "read of a missing repo must 404 (NotARepository)"
701 );
702 assert!(
703 !root.path().join("myrepo").join(".git").exists(),
704 "a read must never auto-init the repo"
705 );
706 }
707
708 #[test]
709 fn parse_cgi_basic() {
710 let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
711 let r = parse_cgi_output(raw).unwrap();
712 assert_eq!(r.status, 200);
713 assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
714 assert!(r
715 .headers
716 .iter()
717 .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
718 }
719
720 #[test]
721 fn parse_cgi_lf_only_separator() {
722 let raw = b"Content-Type: text/plain\n\nHELLO";
723 let r = parse_cgi_output(raw).unwrap();
724 assert_eq!(r.body, Bytes::from_static(b"HELLO"));
725 }
726
727 #[test]
728 fn parse_cgi_status_override() {
729 let raw = b"Status: 403 Forbidden\r\n\r\nNO";
730 let r = parse_cgi_output(raw).unwrap();
731 assert_eq!(r.status, 403);
732 }
733
734 #[test]
735 fn parse_cgi_no_separator_fails() {
736 let raw = b"Content-Type: text/plain\r\nonly-headers";
737 assert!(parse_cgi_output(raw).is_err());
738 }
739
740 #[test]
741 fn git_request_is_write_detects_receive_pack_path() {
742 let req = GitRequest {
743 method: "POST".into(),
744 path: "/repo/git-receive-pack".into(),
745 query: String::new(),
746 headers: vec![],
747 body: Bytes::new(),
748 host_url: None,
749 };
750 assert!(req.is_write());
751 }
752
753 #[test]
754 fn git_request_is_write_detects_receive_pack_query() {
755 let req = GitRequest {
756 method: "GET".into(),
757 path: "/repo/info/refs".into(),
758 query: "service=git-receive-pack".into(),
759 headers: vec![],
760 body: Bytes::new(),
761 host_url: None,
762 };
763 assert!(req.is_write());
764 }
765
766 #[test]
767 fn git_request_is_write_false_for_read() {
768 let req = GitRequest {
769 method: "GET".into(),
770 path: "/repo/info/refs".into(),
771 query: "service=git-upload-pack".into(),
772 headers: vec![],
773 body: Bytes::new(),
774 host_url: None,
775 };
776 assert!(!req.is_write());
777 }
778
779 #[test]
780 fn git_request_is_read_detects_upload_pack_and_info_refs() {
781 let advert = GitRequest {
783 method: "GET".into(),
784 path: "/repo/info/refs".into(),
785 query: "service=git-upload-pack".into(),
786 headers: vec![],
787 body: Bytes::new(),
788 host_url: None,
789 };
790 assert!(advert.is_read());
791 assert!(!advert.is_write());
792
793 let pack = GitRequest {
795 method: "POST".into(),
796 path: "/repo/git-upload-pack".into(),
797 query: String::new(),
798 headers: vec![],
799 body: Bytes::new(),
800 host_url: None,
801 };
802 assert!(pack.is_read());
803
804 let object = GitRequest {
806 method: "GET".into(),
807 path: "/repo/objects/info/packs".into(),
808 query: String::new(),
809 headers: vec![],
810 body: Bytes::new(),
811 host_url: None,
812 };
813 assert!(object.is_read());
814 }
815
816 #[test]
817 fn git_request_is_read_false_for_write() {
818 let req = GitRequest {
820 method: "GET".into(),
821 path: "/repo/info/refs".into(),
822 query: "service=git-receive-pack".into(),
823 headers: vec![],
824 body: Bytes::new(),
825 host_url: None,
826 };
827 assert!(req.is_write());
828 assert!(!req.is_read());
829 }
830
831 #[test]
832 fn git_request_auth_url_without_query() {
833 let req = GitRequest {
834 method: "GET".into(),
835 path: "/repo/info/refs".into(),
836 query: String::new(),
837 headers: vec![],
838 body: Bytes::new(),
839 host_url: Some("https://pod.example.com".into()),
840 };
841 assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
842 }
843
844 #[test]
845 fn git_request_auth_url_with_query() {
846 let req = GitRequest {
847 method: "GET".into(),
848 path: "/repo/info/refs".into(),
849 query: "service=git-upload-pack".into(),
850 headers: vec![],
851 body: Bytes::new(),
852 host_url: Some("https://pod.example.com".into()),
853 };
854 assert_eq!(
855 req.auth_url(),
856 "https://pod.example.com/repo/info/refs?service=git-upload-pack"
857 );
858 }
859
860 #[test]
861 fn git_response_error_helper() {
862 let r = GitResponse::error(404, "not found");
863 assert_eq!(r.status, 404);
864 assert!(!r.body.is_empty());
865 }
866
867 #[tokio::test]
868 async fn bounded_reader_rejects_output_above_cap() {
869 let (mut writer, mut reader) = tokio::io::duplex(64);
870 let write = tokio::spawn(async move {
871 writer.write_all(&[7u8; 33]).await.unwrap();
872 });
873 let result = read_bounded(&mut reader, 32).await;
874 write.await.unwrap();
875 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::FileTooLarge);
876 }
877}