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 .stdin(Stdio::piped())
451 .stdout(Stdio::piped())
452 .stderr(Stdio::piped());
453
454 let mut child = match cmd.spawn() {
455 Ok(c) => c,
456 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
457 return Err(GitError::BackendNotAvailable(format!(
458 "spawn {}: {}",
459 backend.display(),
460 e
461 )));
462 }
463 Err(e) => return Err(GitError::Io(e)),
464 };
465
466 if let Some(mut stdin) = child.stdin.take() {
468 if !req.body.is_empty() {
469 stdin.write_all(&req.body).await?;
470 }
471 drop(stdin); }
473
474 let mut stdout = child.stdout.take().expect("stdout piped");
476 let mut stderr = child.stderr.take().expect("stderr piped");
477
478 let mut stdout_task =
479 tokio::spawn(async move { read_bounded(&mut stdout, CGI_STDOUT_CAP).await });
480 let stderr_task =
481 tokio::spawn(async move { read_diagnostics_bounded(&mut stderr, CGI_STDERR_CAP).await });
482 let deadline = sleep(CGI_EXECUTION_TIMEOUT);
483 tokio::pin!(deadline);
484 let mut early_stdout = None;
485 let status = tokio::select! {
486 result = &mut stdout_task => {
487 match result {
488 Ok(Ok(bytes)) => early_stdout = Some(bytes),
489 Ok(Err(error)) => {
490 let _ = child.kill().await;
491 return Err(GitError::MalformedCgi(error.to_string()));
492 }
493 Err(error) => {
494 let _ = child.kill().await;
495 return Err(GitError::MalformedCgi(format!("stdout task: {error}")));
496 }
497 }
498 tokio::select! {
499 status = child.wait() => status?,
500 _ = &mut deadline => {
501 let _ = child.kill().await;
502 return Err(GitError::BackendFailed {
503 exit_code: None,
504 stderr: "git HTTP backend execution timed out".into(),
505 });
506 }
507 }
508 }
509 status = child.wait() => status?,
510 _ = &mut deadline => {
511 let _ = child.kill().await;
512 return Err(GitError::BackendFailed {
513 exit_code: None,
514 stderr: "git HTTP backend execution timed out".into(),
515 });
516 }
517 };
518 let stdout_bytes = match early_stdout {
519 Some(bytes) => bytes,
520 None => stdout_task
521 .await
522 .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??,
523 };
524 let stderr_bytes = stderr_task.await.unwrap_or_default();
525
526 if !status.success() && stdout_bytes.is_empty() {
527 return Err(GitError::BackendFailed {
528 exit_code: status.code(),
529 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
530 });
531 }
532
533 parse_cgi_output(&stdout_bytes)
534}
535
536fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
538 let (sep_idx, sep_len) = {
540 if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
541 (i, 4)
542 } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
543 (i, 2)
544 } else {
545 return Err(GitError::MalformedCgi("no header/body separator".into()));
546 }
547 };
548
549 let header_section = std::str::from_utf8(&stdout[..sep_idx])
550 .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
551 let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
552
553 let mut status: u16 = 200;
554 let mut headers: Vec<(String, String)> = Vec::new();
555
556 for line in header_section.split(['\n', '\r']) {
557 let line = line.trim();
558 if line.is_empty() {
559 continue;
560 }
561 let Some(colon) = line.find(':') else {
562 continue;
563 };
564 let key = line[..colon].trim().to_string();
565 let value = line[colon + 1..].trim().to_string();
566 if key.eq_ignore_ascii_case("status") {
567 status = value
568 .split_whitespace()
569 .next()
570 .and_then(|s| s.parse().ok())
571 .unwrap_or(200);
572 } else {
573 headers.push((key, value));
574 }
575 }
576
577 headers.extend(git_cors_header_pairs());
579
580 Ok(GitResponse {
581 status,
582 headers,
583 body,
584 })
585}
586
587fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
588 haystack.windows(needle.len()).position(|w| w == needle)
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::auth::{AuthError, GitAuth};
595 use tempfile::TempDir;
596
597 #[derive(Debug)]
600 struct AllowAll;
601
602 #[async_trait::async_trait]
603 impl GitAuth for AllowAll {
604 async fn authorise(&self, _req: &GitRequest) -> Result<String, AuthError> {
605 Ok("tester".to_string())
606 }
607 }
608
609 fn git_available() -> bool {
610 std::process::Command::new("git")
611 .arg("--version")
612 .stdout(Stdio::null())
613 .stderr(Stdio::null())
614 .status()
615 .map(|s| s.success())
616 .unwrap_or(false)
617 }
618
619 fn receive_pack_req(repo: &str) -> GitRequest {
620 GitRequest {
621 method: "POST".into(),
622 path: format!("/{repo}/git-receive-pack"),
623 query: String::new(),
624 headers: vec![(
625 "content-type".into(),
626 "application/x-git-receive-pack-request".into(),
627 )],
628 body: Bytes::new(),
629 host_url: Some("https://pod.example.com".into()),
630 }
631 }
632
633 #[tokio::test]
638 async fn first_push_to_missing_repo_auto_inits_not_404() {
639 if !git_available() {
640 return;
641 }
642 let root = TempDir::new().unwrap();
643 std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
646
647 let service = GitHttpService::new(root.path().to_path_buf()).with_auth(AllowAll);
648 let result = service.handle(receive_pack_req("myrepo")).await;
649
650 assert!(
652 root.path().join("myrepo").join(".git").is_dir(),
653 "first push must auto-init the repo (.git dir must exist)"
654 );
655
656 match result {
660 Ok(resp) => assert_ne!(
661 resp.status, 404,
662 "post-auto-init response must not be a 404 gate denial"
663 ),
664 Err(GitError::NotARepository(_)) => {
665 panic!("auto-init failed: still NotARepository after init")
666 }
667 Err(_) => {}
670 }
671 }
672
673 #[tokio::test]
676 async fn read_of_missing_repo_still_404s_and_does_not_init() {
677 if !git_available() {
678 return;
679 }
680 let root = TempDir::new().unwrap();
681 std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
682
683 let service = GitHttpService::new(root.path().to_path_buf());
686 let req = GitRequest {
687 method: "GET".into(),
688 path: "/myrepo/info/refs".into(),
689 query: "service=git-upload-pack".into(),
690 headers: vec![],
691 body: Bytes::new(),
692 host_url: None,
693 };
694 let result = service.handle(req).await;
695
696 assert!(
697 matches!(result, Err(GitError::NotARepository(_))),
698 "read of a missing repo must 404 (NotARepository)"
699 );
700 assert!(
701 !root.path().join("myrepo").join(".git").exists(),
702 "a read must never auto-init the repo"
703 );
704 }
705
706 #[test]
707 fn parse_cgi_basic() {
708 let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
709 let r = parse_cgi_output(raw).unwrap();
710 assert_eq!(r.status, 200);
711 assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
712 assert!(r
713 .headers
714 .iter()
715 .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
716 }
717
718 #[test]
719 fn parse_cgi_lf_only_separator() {
720 let raw = b"Content-Type: text/plain\n\nHELLO";
721 let r = parse_cgi_output(raw).unwrap();
722 assert_eq!(r.body, Bytes::from_static(b"HELLO"));
723 }
724
725 #[test]
726 fn parse_cgi_status_override() {
727 let raw = b"Status: 403 Forbidden\r\n\r\nNO";
728 let r = parse_cgi_output(raw).unwrap();
729 assert_eq!(r.status, 403);
730 }
731
732 #[test]
733 fn parse_cgi_no_separator_fails() {
734 let raw = b"Content-Type: text/plain\r\nonly-headers";
735 assert!(parse_cgi_output(raw).is_err());
736 }
737
738 #[test]
739 fn git_request_is_write_detects_receive_pack_path() {
740 let req = GitRequest {
741 method: "POST".into(),
742 path: "/repo/git-receive-pack".into(),
743 query: String::new(),
744 headers: vec![],
745 body: Bytes::new(),
746 host_url: None,
747 };
748 assert!(req.is_write());
749 }
750
751 #[test]
752 fn git_request_is_write_detects_receive_pack_query() {
753 let req = GitRequest {
754 method: "GET".into(),
755 path: "/repo/info/refs".into(),
756 query: "service=git-receive-pack".into(),
757 headers: vec![],
758 body: Bytes::new(),
759 host_url: None,
760 };
761 assert!(req.is_write());
762 }
763
764 #[test]
765 fn git_request_is_write_false_for_read() {
766 let req = GitRequest {
767 method: "GET".into(),
768 path: "/repo/info/refs".into(),
769 query: "service=git-upload-pack".into(),
770 headers: vec![],
771 body: Bytes::new(),
772 host_url: None,
773 };
774 assert!(!req.is_write());
775 }
776
777 #[test]
778 fn git_request_is_read_detects_upload_pack_and_info_refs() {
779 let advert = GitRequest {
781 method: "GET".into(),
782 path: "/repo/info/refs".into(),
783 query: "service=git-upload-pack".into(),
784 headers: vec![],
785 body: Bytes::new(),
786 host_url: None,
787 };
788 assert!(advert.is_read());
789 assert!(!advert.is_write());
790
791 let pack = GitRequest {
793 method: "POST".into(),
794 path: "/repo/git-upload-pack".into(),
795 query: String::new(),
796 headers: vec![],
797 body: Bytes::new(),
798 host_url: None,
799 };
800 assert!(pack.is_read());
801
802 let object = GitRequest {
804 method: "GET".into(),
805 path: "/repo/objects/info/packs".into(),
806 query: String::new(),
807 headers: vec![],
808 body: Bytes::new(),
809 host_url: None,
810 };
811 assert!(object.is_read());
812 }
813
814 #[test]
815 fn git_request_is_read_false_for_write() {
816 let req = GitRequest {
818 method: "GET".into(),
819 path: "/repo/info/refs".into(),
820 query: "service=git-receive-pack".into(),
821 headers: vec![],
822 body: Bytes::new(),
823 host_url: None,
824 };
825 assert!(req.is_write());
826 assert!(!req.is_read());
827 }
828
829 #[test]
830 fn git_request_auth_url_without_query() {
831 let req = GitRequest {
832 method: "GET".into(),
833 path: "/repo/info/refs".into(),
834 query: String::new(),
835 headers: vec![],
836 body: Bytes::new(),
837 host_url: Some("https://pod.example.com".into()),
838 };
839 assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
840 }
841
842 #[test]
843 fn git_request_auth_url_with_query() {
844 let req = GitRequest {
845 method: "GET".into(),
846 path: "/repo/info/refs".into(),
847 query: "service=git-upload-pack".into(),
848 headers: vec![],
849 body: Bytes::new(),
850 host_url: Some("https://pod.example.com".into()),
851 };
852 assert_eq!(
853 req.auth_url(),
854 "https://pod.example.com/repo/info/refs?service=git-upload-pack"
855 );
856 }
857
858 #[test]
859 fn git_response_error_helper() {
860 let r = GitResponse::error(404, "not found");
861 assert_eq!(r.status, 404);
862 assert!(!r.body.is_empty());
863 }
864
865 #[tokio::test]
866 async fn bounded_reader_rejects_output_above_cap() {
867 let (mut writer, mut reader) = tokio::io::duplex(64);
868 let write = tokio::spawn(async move {
869 writer.write_all(&[7u8; 33]).await.unwrap();
870 });
871 let result = read_bounded(&mut reader, 32).await;
872 write.await.unwrap();
873 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::FileTooLarge);
874 }
875}