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 let mut headers: Vec<(String, String)> =
127 vec![("content-type".into(), "application/json".into())];
128 headers.extend(git_cors_header_pairs());
129 Self {
130 status,
131 headers,
132 body,
133 }
134 }
135}
136
137pub const GIT_CORS_HEADERS: [(&str, &str); 3] = [
144 ("Access-Control-Allow-Origin", "*"),
145 ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
146 (
147 "Access-Control-Allow-Headers",
148 "Content-Type, Authorization, Git-Protocol",
149 ),
150];
151
152#[must_use]
154pub fn git_cors_header_pairs() -> Vec<(String, String)> {
155 GIT_CORS_HEADERS
156 .iter()
157 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
158 .collect()
159}
160
161#[derive(Clone)]
163pub struct GitHttpService {
164 repo_root: PathBuf,
165 auth: Option<Arc<dyn GitAuth>>,
166 backend_path: PathBuf,
167}
168
169impl std::fmt::Debug for GitHttpService {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.debug_struct("GitHttpService")
172 .field("repo_root", &self.repo_root)
173 .field("auth", &self.auth.is_some())
174 .field("backend_path", &self.backend_path)
175 .finish()
176 }
177}
178
179impl GitHttpService {
180 #[must_use]
183 pub fn new(repo_root: PathBuf) -> Self {
184 let backend = std::env::var("GIT_HTTP_BACKEND_PATH")
185 .map(PathBuf::from)
186 .unwrap_or_else(|_| PathBuf::from(DEFAULT_GIT_HTTP_BACKEND));
187 Self {
188 repo_root,
189 auth: None,
190 backend_path: backend,
191 }
192 }
193
194 #[must_use]
196 pub fn with_backend_path(mut self, path: PathBuf) -> Self {
197 self.backend_path = path;
198 self
199 }
200
201 #[must_use]
205 pub fn with_auth<A: GitAuth + 'static>(mut self, auth: A) -> Self {
206 self.auth = Some(Arc::new(auth));
207 self
208 }
209
210 #[must_use]
212 pub fn with_auth_arc(mut self, auth: Arc<dyn GitAuth>) -> Self {
213 self.auth = Some(auth);
214 self
215 }
216
217 pub async fn handle(&self, req: GitRequest) -> Result<GitResponse, GitError> {
219 if req.method.eq_ignore_ascii_case("OPTIONS") {
221 return Ok(GitResponse {
222 status: 200,
223 headers: git_cors_header_pairs(),
224 body: Bytes::new(),
225 });
226 }
227
228 let slug = extract_repo_slug(&req.path);
230 let repo_abs = if slug == "." {
231 self.repo_root.canonicalize()?
232 } else {
233 path_safe(&self.repo_root, &slug)?
234 };
235
236 let mut remote_user = String::new();
249 let needs_auth = req.is_write() || (req.is_read() && self.auth.is_some());
250 if needs_auth {
251 let auth = self
252 .auth
253 .as_ref()
254 .ok_or_else(|| GitError::Unauthorised("no auth provider configured".into()))?;
255 match auth.authorise(&req).await {
256 Ok(id) => remote_user = id,
257 Err(AuthError::Missing) => {
258 return Err(GitError::Unauthorised("missing Authorization".into()));
259 }
260 Err(e) => return Err(GitError::Auth(e)),
261 }
262 }
263
264 let git_dir = match find_git_dir(&repo_abs)? {
273 Some(g) => g,
274 None if req.is_write() => {
275 crate::init::GitAutoInit::new()
276 .init_repo_at(&repo_abs)
277 .await
278 .map_err(|e| GitError::BackendFailed {
279 exit_code: None,
280 stderr: format!("auto-init {}: {e}", repo_abs.display()),
281 })?;
282 find_git_dir(&repo_abs)?.ok_or_else(|| GitError::NotARepository(slug.clone()))?
284 }
285 None => {
286 return Err(GitError::NotARepository(slug));
287 }
288 };
289
290 if req.is_write() {
293 let _ = apply_write_config(&git_dir, &repo_abs).await;
294 }
295
296 spawn_cgi(
298 &self.backend_path,
299 &self.repo_root,
300 &git_dir,
301 &remote_user,
302 req,
303 )
304 .await
305 }
306}
307
308async fn spawn_cgi(
310 backend: &Path,
311 repo_root: &Path,
312 git_dir: &crate::config::GitDir,
313 remote_user: &str,
314 req: GitRequest,
315) -> Result<GitResponse, GitError> {
316 let mut env: HashMap<String, String> = HashMap::new();
321 if let Ok(path) = std::env::var("PATH") {
322 env.insert("PATH".into(), path);
323 }
324
325 env.insert(
326 "GIT_PROJECT_ROOT".into(),
327 repo_root
328 .canonicalize()
329 .unwrap_or_else(|_| repo_root.to_path_buf())
330 .to_string_lossy()
331 .into_owned(),
332 );
333 env.insert("GIT_HTTP_EXPORT_ALL".into(), String::new());
334 env.insert("GIT_HTTP_RECEIVE_PACK".into(), "true".into());
335 env.insert(
336 "GIT_CONFIG_PARAMETERS".into(),
337 "'uploadpack.allowTipSHA1InWant=true'".into(),
338 );
339 env.insert("PATH_INFO".into(), req.path.clone());
340 env.insert("REQUEST_METHOD".into(), req.method.to_uppercase());
341 env.insert("QUERY_STRING".into(), req.query.clone());
342 env.insert("REMOTE_USER".into(), remote_user.to_string());
343
344 for (k, v) in &req.headers {
345 let kl = k.to_lowercase();
346 if kl == "content-type" {
347 env.insert("CONTENT_TYPE".into(), v.clone());
348 } else if kl == "content-length" {
349 env.insert("CONTENT_LENGTH".into(), v.clone());
350 }
351 }
352 env.entry("CONTENT_LENGTH".into())
353 .or_insert_with(|| req.body.len().to_string());
354 env.entry("CONTENT_TYPE".into()).or_default();
355
356 if git_dir.is_regular {
357 env.insert(
358 "GIT_DIR".into(),
359 git_dir.git_dir.to_string_lossy().into_owned(),
360 );
361 }
362
363 let mut cmd = Command::new(backend);
364 cmd.env_clear()
365 .envs(&env)
366 .stdin(Stdio::piped())
367 .stdout(Stdio::piped())
368 .stderr(Stdio::piped());
369
370 let mut child = match cmd.spawn() {
371 Ok(c) => c,
372 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
373 return Err(GitError::BackendNotAvailable(format!(
374 "spawn {}: {}",
375 backend.display(),
376 e
377 )));
378 }
379 Err(e) => return Err(GitError::Io(e)),
380 };
381
382 if let Some(mut stdin) = child.stdin.take() {
384 if !req.body.is_empty() {
385 stdin.write_all(&req.body).await?;
386 }
387 drop(stdin); }
389
390 let mut stdout = child.stdout.take().expect("stdout piped");
392 let mut stderr = child.stderr.take().expect("stderr piped");
393
394 let stdout_task = tokio::spawn(async move {
395 let mut buf = Vec::new();
396 stdout.read_to_end(&mut buf).await.map(|_| buf)
397 });
398 let stderr_task = tokio::spawn(async move {
399 let mut buf = Vec::new();
400 let _ = stderr.read_to_end(&mut buf).await;
401 buf
402 });
403
404 let status = child.wait().await?;
405 let stdout_bytes = stdout_task
406 .await
407 .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??;
408 let stderr_bytes = stderr_task.await.unwrap_or_default();
409
410 if !status.success() && stdout_bytes.is_empty() {
411 return Err(GitError::BackendFailed {
412 exit_code: status.code(),
413 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
414 });
415 }
416
417 parse_cgi_output(&stdout_bytes)
418}
419
420fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
422 let (sep_idx, sep_len) = {
424 if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
425 (i, 4)
426 } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
427 (i, 2)
428 } else {
429 return Err(GitError::MalformedCgi("no header/body separator".into()));
430 }
431 };
432
433 let header_section = std::str::from_utf8(&stdout[..sep_idx])
434 .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
435 let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
436
437 let mut status: u16 = 200;
438 let mut headers: Vec<(String, String)> = Vec::new();
439
440 for line in header_section.split(['\n', '\r']) {
441 let line = line.trim();
442 if line.is_empty() {
443 continue;
444 }
445 let Some(colon) = line.find(':') else {
446 continue;
447 };
448 let key = line[..colon].trim().to_string();
449 let value = line[colon + 1..].trim().to_string();
450 if key.eq_ignore_ascii_case("status") {
451 status = value
452 .split_whitespace()
453 .next()
454 .and_then(|s| s.parse().ok())
455 .unwrap_or(200);
456 } else {
457 headers.push((key, value));
458 }
459 }
460
461 headers.extend(git_cors_header_pairs());
463
464 Ok(GitResponse {
465 status,
466 headers,
467 body,
468 })
469}
470
471fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
472 haystack.windows(needle.len()).position(|w| w == needle)
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::auth::{AuthError, GitAuth};
479 use tempfile::TempDir;
480
481 #[derive(Debug)]
484 struct AllowAll;
485
486 #[async_trait::async_trait]
487 impl GitAuth for AllowAll {
488 async fn authorise(&self, _req: &GitRequest) -> Result<String, AuthError> {
489 Ok("tester".to_string())
490 }
491 }
492
493 fn git_available() -> bool {
494 std::process::Command::new("git")
495 .arg("--version")
496 .stdout(Stdio::null())
497 .stderr(Stdio::null())
498 .status()
499 .map(|s| s.success())
500 .unwrap_or(false)
501 }
502
503 fn receive_pack_req(repo: &str) -> GitRequest {
504 GitRequest {
505 method: "POST".into(),
506 path: format!("/{repo}/git-receive-pack"),
507 query: String::new(),
508 headers: vec![(
509 "content-type".into(),
510 "application/x-git-receive-pack-request".into(),
511 )],
512 body: Bytes::new(),
513 host_url: Some("https://pod.example.com".into()),
514 }
515 }
516
517 #[tokio::test]
522 async fn first_push_to_missing_repo_auto_inits_not_404() {
523 if !git_available() {
524 return;
525 }
526 let root = TempDir::new().unwrap();
527 std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
530
531 let service = GitHttpService::new(root.path().to_path_buf()).with_auth(AllowAll);
532 let result = service.handle(receive_pack_req("myrepo")).await;
533
534 assert!(
536 root.path().join("myrepo").join(".git").is_dir(),
537 "first push must auto-init the repo (.git dir must exist)"
538 );
539
540 match result {
544 Ok(resp) => assert_ne!(
545 resp.status, 404,
546 "post-auto-init response must not be a 404 gate denial"
547 ),
548 Err(GitError::NotARepository(_)) => {
549 panic!("auto-init failed: still NotARepository after init")
550 }
551 Err(_) => {}
554 }
555 }
556
557 #[tokio::test]
560 async fn read_of_missing_repo_still_404s_and_does_not_init() {
561 if !git_available() {
562 return;
563 }
564 let root = TempDir::new().unwrap();
565 std::fs::create_dir_all(root.path().join("myrepo")).unwrap();
566
567 let service = GitHttpService::new(root.path().to_path_buf());
570 let req = GitRequest {
571 method: "GET".into(),
572 path: "/myrepo/info/refs".into(),
573 query: "service=git-upload-pack".into(),
574 headers: vec![],
575 body: Bytes::new(),
576 host_url: None,
577 };
578 let result = service.handle(req).await;
579
580 assert!(
581 matches!(result, Err(GitError::NotARepository(_))),
582 "read of a missing repo must 404 (NotARepository)"
583 );
584 assert!(
585 !root.path().join("myrepo").join(".git").exists(),
586 "a read must never auto-init the repo"
587 );
588 }
589
590 #[test]
591 fn parse_cgi_basic() {
592 let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
593 let r = parse_cgi_output(raw).unwrap();
594 assert_eq!(r.status, 200);
595 assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
596 assert!(r
597 .headers
598 .iter()
599 .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
600 }
601
602 #[test]
603 fn parse_cgi_lf_only_separator() {
604 let raw = b"Content-Type: text/plain\n\nHELLO";
605 let r = parse_cgi_output(raw).unwrap();
606 assert_eq!(r.body, Bytes::from_static(b"HELLO"));
607 }
608
609 #[test]
610 fn parse_cgi_status_override() {
611 let raw = b"Status: 403 Forbidden\r\n\r\nNO";
612 let r = parse_cgi_output(raw).unwrap();
613 assert_eq!(r.status, 403);
614 }
615
616 #[test]
617 fn parse_cgi_no_separator_fails() {
618 let raw = b"Content-Type: text/plain\r\nonly-headers";
619 assert!(parse_cgi_output(raw).is_err());
620 }
621
622 #[test]
623 fn git_request_is_write_detects_receive_pack_path() {
624 let req = GitRequest {
625 method: "POST".into(),
626 path: "/repo/git-receive-pack".into(),
627 query: String::new(),
628 headers: vec![],
629 body: Bytes::new(),
630 host_url: None,
631 };
632 assert!(req.is_write());
633 }
634
635 #[test]
636 fn git_request_is_write_detects_receive_pack_query() {
637 let req = GitRequest {
638 method: "GET".into(),
639 path: "/repo/info/refs".into(),
640 query: "service=git-receive-pack".into(),
641 headers: vec![],
642 body: Bytes::new(),
643 host_url: None,
644 };
645 assert!(req.is_write());
646 }
647
648 #[test]
649 fn git_request_is_write_false_for_read() {
650 let req = GitRequest {
651 method: "GET".into(),
652 path: "/repo/info/refs".into(),
653 query: "service=git-upload-pack".into(),
654 headers: vec![],
655 body: Bytes::new(),
656 host_url: None,
657 };
658 assert!(!req.is_write());
659 }
660
661 #[test]
662 fn git_request_is_read_detects_upload_pack_and_info_refs() {
663 let advert = GitRequest {
665 method: "GET".into(),
666 path: "/repo/info/refs".into(),
667 query: "service=git-upload-pack".into(),
668 headers: vec![],
669 body: Bytes::new(),
670 host_url: None,
671 };
672 assert!(advert.is_read());
673 assert!(!advert.is_write());
674
675 let pack = GitRequest {
677 method: "POST".into(),
678 path: "/repo/git-upload-pack".into(),
679 query: String::new(),
680 headers: vec![],
681 body: Bytes::new(),
682 host_url: None,
683 };
684 assert!(pack.is_read());
685
686 let object = GitRequest {
688 method: "GET".into(),
689 path: "/repo/objects/info/packs".into(),
690 query: String::new(),
691 headers: vec![],
692 body: Bytes::new(),
693 host_url: None,
694 };
695 assert!(object.is_read());
696 }
697
698 #[test]
699 fn git_request_is_read_false_for_write() {
700 let req = GitRequest {
702 method: "GET".into(),
703 path: "/repo/info/refs".into(),
704 query: "service=git-receive-pack".into(),
705 headers: vec![],
706 body: Bytes::new(),
707 host_url: None,
708 };
709 assert!(req.is_write());
710 assert!(!req.is_read());
711 }
712
713 #[test]
714 fn git_request_auth_url_without_query() {
715 let req = GitRequest {
716 method: "GET".into(),
717 path: "/repo/info/refs".into(),
718 query: String::new(),
719 headers: vec![],
720 body: Bytes::new(),
721 host_url: Some("https://pod.example.com".into()),
722 };
723 assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
724 }
725
726 #[test]
727 fn git_request_auth_url_with_query() {
728 let req = GitRequest {
729 method: "GET".into(),
730 path: "/repo/info/refs".into(),
731 query: "service=git-upload-pack".into(),
732 headers: vec![],
733 body: Bytes::new(),
734 host_url: Some("https://pod.example.com".into()),
735 };
736 assert_eq!(
737 req.auth_url(),
738 "https://pod.example.com/repo/info/refs?service=git-upload-pack"
739 );
740 }
741
742 #[test]
743 fn git_response_error_helper() {
744 let r = GitResponse::error(404, "not found");
745 assert_eq!(r.status, 404);
746 assert!(!r.body.is_empty());
747 }
748}