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