//! Git smart-HTTP transport.
//!
//! This bridges incoming HTTP requests to `git http-backend` (git's own CGI
//! program), which implements the upload-pack (clone/fetch) and receive-pack
//! (push) protocols. Push and access to private repositories require HTTP Basic
//! authentication against Ferrit's user store.
use crate::App;
use axum::body::{Body, Bytes};
use axum::extract::{Path, RawQuery, State};
use axum::http::{HeaderMap, Method, Response, StatusCode};
use base64::Engine;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
fn unauthorized() -> Response<Body> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"Ferrit\"")
.header("content-type", "text/plain")
.body(Body::from("Authentication required\n"))
.unwrap()
}
fn status(code: StatusCode, msg: &str) -> Response<Body> {
Response::builder()
.status(code)
.header("content-type", "text/plain")
.body(Body::from(format!("{msg}\n")))
.unwrap()
}
/// Parse an HTTP Basic `Authorization` header into `(user, pass)`.
fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
let val = headers.get("authorization")?.to_str().ok()?;
let b64 = val.strip_prefix("Basic ").or_else(|| val.strip_prefix("basic "))?;
let decoded = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?;
let text = String::from_utf8(decoded).ok()?;
let (u, p) = text.split_once(':')?;
Some((u.to_string(), p.to_string()))
}
/// `endpoint` is one of: "info/refs", "git-upload-pack", "git-receive-pack".
async fn serve(
app: Arc<App>,
owner: String,
repo: String,
endpoint: &str,
method: Method,
headers: HeaderMap,
query: Option<String>,
body: Bytes,
) -> Response<Body> {
let name = repo.trim_end_matches(".git").to_string();
let query = query.unwrap_or_default();
let meta = match app.store.repo(&owner, &name) {
Some(r) => r,
None => return status(StatusCode::NOT_FOUND, "repository not found"),
};
let is_push = endpoint == "git-receive-pack"
|| (endpoint == "info/refs" && query.contains("service=git-receive-pack"));
let need_auth = is_push || meta.private;
let mut remote_user = String::new();
if need_auth {
match basic_auth(&headers) {
Some((u, p)) => match app.store.authenticate(&u, &p) {
Some(user) if user.username.eq_ignore_ascii_case(&owner) => {
remote_user = user.username;
}
Some(_) => return status(StatusCode::FORBIDDEN, "you do not have access to this repository"),
None => return unauthorized(),
},
None => return unauthorized(),
}
}
let repo_dir = gitio_repo_dir(&app, &owner, &name);
if !repo_dir.exists() {
return status(StatusCode::NOT_FOUND, "repository not found on disk");
}
let project_root = app.data_dir.join("repos").join(&owner);
let path_info = format!("/{name}.git/{endpoint}");
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let mut cmd = tokio::process::Command::new("git");
cmd.arg("http-backend")
.env("GIT_PROJECT_ROOT", &project_root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("PATH_INFO", &path_info)
.env("QUERY_STRING", &query)
.env("REQUEST_METHOD", method.as_str())
.env("CONTENT_TYPE", &content_type)
.env("CONTENT_LENGTH", body.len().to_string())
.env("REMOTE_USER", &remote_user)
.env("REMOTE_ADDR", "127.0.0.1")
.env("SERVER_PROTOCOL", "HTTP/1.1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return status(StatusCode::INTERNAL_SERVER_ERROR, &format!("cannot start git: {e}")),
};
if let Some(mut stdin) = child.stdin.take() {
let body = body.clone();
tokio::spawn(async move {
let _ = stdin.write_all(&body).await;
let _ = stdin.shutdown().await;
});
}
let out = match child.wait_with_output().await {
Ok(o) => o,
Err(e) => return status(StatusCode::INTERNAL_SERVER_ERROR, &format!("git failed: {e}")),
};
if !out.status.success() && out.stdout.is_empty() {
return status(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("git http-backend error: {}", String::from_utf8_lossy(&out.stderr)),
);
}
parse_cgi(out.stdout)
}
/// Reuse gitio's path logic without exposing internals.
fn gitio_repo_dir(app: &App, owner: &str, name: &str) -> std::path::PathBuf {
crate::gitio::repo_path(&app.data_dir, owner, name)
}
/// Split a CGI response (`Header: value` lines, blank line, then body) into an
/// axum HTTP response.
fn parse_cgi(raw: Vec<u8>) -> Response<Body> {
let split = find_header_boundary(&raw);
let (head, body_start, _) = match split {
Some((end, body_start)) => (&raw[..end], body_start, ()),
None => {
// No header section; treat the whole thing as a body.
return Response::builder()
.header("content-type", "application/octet-stream")
.body(Body::from(raw))
.unwrap();
}
};
let head_str = String::from_utf8_lossy(head);
let mut builder = Response::builder();
let mut code = StatusCode::OK;
for line in head_str.lines() {
let (k, v) = match line.split_once(':') {
Some(kv) => kv,
None => continue,
};
let k = k.trim();
let v = v.trim();
if k.eq_ignore_ascii_case("status") {
// e.g. "403 Forbidden"
if let Some(num) = v.split_whitespace().next() {
if let Ok(n) = num.parse::<u16>() {
code = StatusCode::from_u16(n).unwrap_or(StatusCode::OK);
}
}
} else {
builder = builder.header(k, v);
}
}
builder
.status(code)
.body(Body::from(raw[body_start..].to_vec()))
.unwrap()
}
/// Returns `(header_end, body_start)` for the first CRLFCRLF or LFLF.
fn find_header_boundary(raw: &[u8]) -> Option<(usize, usize)> {
for i in 0..raw.len() {
if raw[i..].starts_with(b"\r\n\r\n") {
return Some((i, i + 4));
}
if raw[i..].starts_with(b"\n\n") {
return Some((i, i + 2));
}
}
None
}
// ---- axum handlers -------------------------------------------------------
pub async fn info_refs(
State(app): State<Arc<App>>,
Path((owner, repo)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
RawQuery(query): RawQuery,
body: Bytes,
) -> Response<Body> {
serve(app, owner, repo, "info/refs", method, headers, query, body).await
}
pub async fn upload_pack(
State(app): State<Arc<App>>,
Path((owner, repo)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
RawQuery(query): RawQuery,
body: Bytes,
) -> Response<Body> {
serve(app, owner, repo, "git-upload-pack", method, headers, query, body).await
}
pub async fn receive_pack(
State(app): State<Arc<App>>,
Path((owner, repo)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
RawQuery(query): RawQuery,
body: Bytes,
) -> Response<Body> {
serve(app, owner, repo, "git-receive-pack", method, headers, query, body).await
}