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
87#[derive(Debug, Clone)]
89pub struct GitResponse {
90 pub status: u16,
93 pub headers: Vec<(String, String)>,
95 pub body: Bytes,
97}
98
99impl GitResponse {
100 #[must_use]
102 pub fn error(status: u16, msg: impl Into<String>) -> Self {
103 let msg = msg.into();
104 let body = Bytes::from(format!("{{\"error\":\"{msg}\"}}"));
105 Self {
106 status,
107 headers: vec![
108 ("content-type".into(), "application/json".into()),
109 ("access-control-allow-origin".into(), "*".into()),
110 ],
111 body,
112 }
113 }
114}
115
116#[derive(Clone)]
118pub struct GitHttpService {
119 repo_root: PathBuf,
120 auth: Option<Arc<dyn GitAuth>>,
121 backend_path: PathBuf,
122}
123
124impl std::fmt::Debug for GitHttpService {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("GitHttpService")
127 .field("repo_root", &self.repo_root)
128 .field("auth", &self.auth.is_some())
129 .field("backend_path", &self.backend_path)
130 .finish()
131 }
132}
133
134impl GitHttpService {
135 #[must_use]
138 pub fn new(repo_root: PathBuf) -> Self {
139 let backend = std::env::var("GIT_HTTP_BACKEND_PATH")
140 .map(PathBuf::from)
141 .unwrap_or_else(|_| PathBuf::from(DEFAULT_GIT_HTTP_BACKEND));
142 Self {
143 repo_root,
144 auth: None,
145 backend_path: backend,
146 }
147 }
148
149 #[must_use]
151 pub fn with_backend_path(mut self, path: PathBuf) -> Self {
152 self.backend_path = path;
153 self
154 }
155
156 #[must_use]
160 pub fn with_auth<A: GitAuth + 'static>(mut self, auth: A) -> Self {
161 self.auth = Some(Arc::new(auth));
162 self
163 }
164
165 #[must_use]
167 pub fn with_auth_arc(mut self, auth: Arc<dyn GitAuth>) -> Self {
168 self.auth = Some(auth);
169 self
170 }
171
172 pub async fn handle(&self, req: GitRequest) -> Result<GitResponse, GitError> {
174 if req.method.eq_ignore_ascii_case("OPTIONS") {
176 return Ok(GitResponse {
177 status: 200,
178 headers: vec![
179 ("access-control-allow-origin".into(), "*".into()),
180 (
181 "access-control-allow-methods".into(),
182 "GET, POST, OPTIONS".into(),
183 ),
184 (
185 "access-control-allow-headers".into(),
186 "Content-Type, Authorization".into(),
187 ),
188 ],
189 body: Bytes::new(),
190 });
191 }
192
193 let slug = extract_repo_slug(&req.path);
195 let repo_abs = if slug == "." {
196 self.repo_root.canonicalize()?
197 } else {
198 path_safe(&self.repo_root, &slug)?
199 };
200
201 let git_dir = match find_git_dir(&repo_abs)? {
203 Some(g) => g,
204 None => {
205 return Err(GitError::NotARepository(slug));
206 }
207 };
208
209 let mut remote_user = String::new();
213 if req.is_write() {
214 let auth = self
215 .auth
216 .as_ref()
217 .ok_or_else(|| GitError::Unauthorised("no auth provider configured".into()))?;
218 match auth.authorise(&req).await {
219 Ok(id) => remote_user = id,
220 Err(AuthError::Missing) => {
221 return Err(GitError::Unauthorised("missing Authorization".into()));
222 }
223 Err(e) => return Err(GitError::Auth(e)),
224 }
225 }
226
227 if req.is_write() {
230 let _ = apply_write_config(&git_dir, &repo_abs).await;
231 }
232
233 spawn_cgi(
235 &self.backend_path,
236 &self.repo_root,
237 &git_dir,
238 &remote_user,
239 req,
240 )
241 .await
242 }
243}
244
245async fn spawn_cgi(
247 backend: &Path,
248 repo_root: &Path,
249 git_dir: &crate::config::GitDir,
250 remote_user: &str,
251 req: GitRequest,
252) -> Result<GitResponse, GitError> {
253 let mut env: HashMap<String, String> = HashMap::new();
258 if let Ok(path) = std::env::var("PATH") {
259 env.insert("PATH".into(), path);
260 }
261
262 env.insert(
263 "GIT_PROJECT_ROOT".into(),
264 repo_root
265 .canonicalize()
266 .unwrap_or_else(|_| repo_root.to_path_buf())
267 .to_string_lossy()
268 .into_owned(),
269 );
270 env.insert("GIT_HTTP_EXPORT_ALL".into(), String::new());
271 env.insert("GIT_HTTP_RECEIVE_PACK".into(), "true".into());
272 env.insert(
273 "GIT_CONFIG_PARAMETERS".into(),
274 "'uploadpack.allowTipSHA1InWant=true'".into(),
275 );
276 env.insert("PATH_INFO".into(), req.path.clone());
277 env.insert("REQUEST_METHOD".into(), req.method.to_uppercase());
278 env.insert("QUERY_STRING".into(), req.query.clone());
279 env.insert("REMOTE_USER".into(), remote_user.to_string());
280
281 for (k, v) in &req.headers {
282 let kl = k.to_lowercase();
283 if kl == "content-type" {
284 env.insert("CONTENT_TYPE".into(), v.clone());
285 } else if kl == "content-length" {
286 env.insert("CONTENT_LENGTH".into(), v.clone());
287 }
288 }
289 env.entry("CONTENT_LENGTH".into())
290 .or_insert_with(|| req.body.len().to_string());
291 env.entry("CONTENT_TYPE".into()).or_default();
292
293 if git_dir.is_regular {
294 env.insert(
295 "GIT_DIR".into(),
296 git_dir.git_dir.to_string_lossy().into_owned(),
297 );
298 }
299
300 let mut cmd = Command::new(backend);
301 cmd.env_clear()
302 .envs(&env)
303 .stdin(Stdio::piped())
304 .stdout(Stdio::piped())
305 .stderr(Stdio::piped());
306
307 let mut child = match cmd.spawn() {
308 Ok(c) => c,
309 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
310 return Err(GitError::BackendNotAvailable(format!(
311 "spawn {}: {}",
312 backend.display(),
313 e
314 )));
315 }
316 Err(e) => return Err(GitError::Io(e)),
317 };
318
319 if let Some(mut stdin) = child.stdin.take() {
321 if !req.body.is_empty() {
322 stdin.write_all(&req.body).await?;
323 }
324 drop(stdin); }
326
327 let mut stdout = child.stdout.take().expect("stdout piped");
329 let mut stderr = child.stderr.take().expect("stderr piped");
330
331 let stdout_task = tokio::spawn(async move {
332 let mut buf = Vec::new();
333 stdout.read_to_end(&mut buf).await.map(|_| buf)
334 });
335 let stderr_task = tokio::spawn(async move {
336 let mut buf = Vec::new();
337 let _ = stderr.read_to_end(&mut buf).await;
338 buf
339 });
340
341 let status = child.wait().await?;
342 let stdout_bytes = stdout_task
343 .await
344 .map_err(|e| GitError::MalformedCgi(format!("stdout task: {e}")))??;
345 let stderr_bytes = stderr_task.await.unwrap_or_default();
346
347 if !status.success() && stdout_bytes.is_empty() {
348 return Err(GitError::BackendFailed {
349 exit_code: status.code(),
350 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
351 });
352 }
353
354 parse_cgi_output(&stdout_bytes)
355}
356
357fn parse_cgi_output(stdout: &[u8]) -> Result<GitResponse, GitError> {
359 let (sep_idx, sep_len) = {
361 if let Some(i) = find_subsequence(stdout, b"\r\n\r\n") {
362 (i, 4)
363 } else if let Some(i) = find_subsequence(stdout, b"\n\n") {
364 (i, 2)
365 } else {
366 return Err(GitError::MalformedCgi("no header/body separator".into()));
367 }
368 };
369
370 let header_section = std::str::from_utf8(&stdout[..sep_idx])
371 .map_err(|e| GitError::MalformedCgi(format!("utf-8 in headers: {e}")))?;
372 let body = Bytes::copy_from_slice(&stdout[sep_idx + sep_len..]);
373
374 let mut status: u16 = 200;
375 let mut headers: Vec<(String, String)> = Vec::new();
376
377 for line in header_section.split(['\n', '\r']) {
378 let line = line.trim();
379 if line.is_empty() {
380 continue;
381 }
382 let Some(colon) = line.find(':') else {
383 continue;
384 };
385 let key = line[..colon].trim().to_string();
386 let value = line[colon + 1..].trim().to_string();
387 if key.eq_ignore_ascii_case("status") {
388 status = value
389 .split_whitespace()
390 .next()
391 .and_then(|s| s.parse().ok())
392 .unwrap_or(200);
393 } else {
394 headers.push((key, value));
395 }
396 }
397
398 headers.push(("Access-Control-Allow-Origin".into(), "*".into()));
400 headers.push((
401 "Access-Control-Allow-Methods".into(),
402 "GET, POST, OPTIONS".into(),
403 ));
404 headers.push((
405 "Access-Control-Allow-Headers".into(),
406 "Content-Type, Authorization".into(),
407 ));
408
409 Ok(GitResponse {
410 status,
411 headers,
412 body,
413 })
414}
415
416fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
417 haystack.windows(needle.len()).position(|w| w == needle)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn parse_cgi_basic() {
426 let raw = b"Content-Type: application/x-git-upload-pack-advertisement\r\nStatus: 200 OK\r\n\r\nPKFILE-BODY";
427 let r = parse_cgi_output(raw).unwrap();
428 assert_eq!(r.status, 200);
429 assert_eq!(r.body, Bytes::from_static(b"PKFILE-BODY"));
430 assert!(r
431 .headers
432 .iter()
433 .any(|(k, _)| k.eq_ignore_ascii_case("content-type")));
434 }
435
436 #[test]
437 fn parse_cgi_lf_only_separator() {
438 let raw = b"Content-Type: text/plain\n\nHELLO";
439 let r = parse_cgi_output(raw).unwrap();
440 assert_eq!(r.body, Bytes::from_static(b"HELLO"));
441 }
442
443 #[test]
444 fn parse_cgi_status_override() {
445 let raw = b"Status: 403 Forbidden\r\n\r\nNO";
446 let r = parse_cgi_output(raw).unwrap();
447 assert_eq!(r.status, 403);
448 }
449
450 #[test]
451 fn parse_cgi_no_separator_fails() {
452 let raw = b"Content-Type: text/plain\r\nonly-headers";
453 assert!(parse_cgi_output(raw).is_err());
454 }
455
456 #[test]
457 fn git_request_is_write_detects_receive_pack_path() {
458 let req = GitRequest {
459 method: "POST".into(),
460 path: "/repo/git-receive-pack".into(),
461 query: String::new(),
462 headers: vec![],
463 body: Bytes::new(),
464 host_url: None,
465 };
466 assert!(req.is_write());
467 }
468
469 #[test]
470 fn git_request_is_write_detects_receive_pack_query() {
471 let req = GitRequest {
472 method: "GET".into(),
473 path: "/repo/info/refs".into(),
474 query: "service=git-receive-pack".into(),
475 headers: vec![],
476 body: Bytes::new(),
477 host_url: None,
478 };
479 assert!(req.is_write());
480 }
481
482 #[test]
483 fn git_request_is_write_false_for_read() {
484 let req = GitRequest {
485 method: "GET".into(),
486 path: "/repo/info/refs".into(),
487 query: "service=git-upload-pack".into(),
488 headers: vec![],
489 body: Bytes::new(),
490 host_url: None,
491 };
492 assert!(!req.is_write());
493 }
494
495 #[test]
496 fn git_request_auth_url_without_query() {
497 let req = GitRequest {
498 method: "GET".into(),
499 path: "/repo/info/refs".into(),
500 query: String::new(),
501 headers: vec![],
502 body: Bytes::new(),
503 host_url: Some("https://pod.example.com".into()),
504 };
505 assert_eq!(req.auth_url(), "https://pod.example.com/repo/info/refs");
506 }
507
508 #[test]
509 fn git_request_auth_url_with_query() {
510 let req = GitRequest {
511 method: "GET".into(),
512 path: "/repo/info/refs".into(),
513 query: "service=git-upload-pack".into(),
514 headers: vec![],
515 body: Bytes::new(),
516 host_url: Some("https://pod.example.com".into()),
517 };
518 assert_eq!(
519 req.auth_url(),
520 "https://pod.example.com/repo/info/refs?service=git-upload-pack"
521 );
522 }
523
524 #[test]
525 fn git_response_error_helper() {
526 let r = GitResponse::error(404, "not found");
527 assert_eq!(r.status, 404);
528 assert!(!r.body.is_empty());
529 }
530}