//! git smart HTTP, read only, by `git upload-pack --stateless-rpc`. use crate::{App, AppError, repo_name}; use anyhow::Context; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::Response; use knotless::git::Git; use serde::Deserialize; use std::io::Read; use std::process::Stdio; use std::sync::Arc; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio_util::io::ReaderStream; #[derive(Deserialize)] pub struct Service { service: Option, } fn protocol(headers: &HeaderMap) -> String { headers .get("git-protocol") .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_string() } fn command(git: &Git, protocol: &str, advertise: bool) -> Command { let mut cmd = Command::new("git"); cmd.arg("upload-pack").arg("--stateless-rpc"); if advertise { cmd.arg("--http-backend-info-refs"); } cmd.arg(&git.dir) .env("GIT_PROTOCOL", protocol) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); cmd } fn pkt(line: &str) -> String { format!("{:04x}{line}", line.len() + 4) } pub async fn info_refs( State(app): State>, Path((authority, repo)): Path<(String, String)>, Query(q): Query, headers: HeaderMap, ) -> Result { if q.service.as_deref() != Some("git-upload-pack") { return Err(AppError( StatusCode::FORBIDDEN, "read only: push with knotless:// instead".into(), )); } let git = app.mirror(&authority, repo_name(&repo)).await?; let protocol = protocol(&headers); let out = command(&git, &protocol, true) .output() .await .context("running git upload-pack") .map_err(AppError::internal)?; let mut body = Vec::new(); if !protocol.contains("version=2") { body.extend(pkt("# service=git-upload-pack\n").as_bytes()); body.extend(b"0000"); } body.extend(out.stdout); Ok(Response::builder() .header( header::CONTENT_TYPE, "application/x-git-upload-pack-advertisement", ) .header(header::CACHE_CONTROL, "no-cache") .body(Body::from(body)) .expect("static headers are valid")) } pub async fn upload_pack( State(app): State>, Path((authority, repo)): Path<(String, String)>, headers: HeaderMap, body: Bytes, ) -> Result { let input = if headers .get(header::CONTENT_ENCODING) .is_some_and(|v| v == "gzip") { let mut out = Vec::new(); flate2::read::GzDecoder::new(&body[..]) .read_to_end(&mut out) .map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("bad gzip body: {e}")))?; out } else { body.to_vec() }; let git = app.mirror(&authority, repo_name(&repo)).await?; let mut child = command(&git, &protocol(&headers), false) .spawn() .context("running git upload-pack") .map_err(AppError::internal)?; let mut stdin = child.stdin.take().expect("stdin is piped"); tokio::spawn(async move { let _ = stdin.write_all(&input).await; }); let stdout = child.stdout.take().expect("stdout is piped"); // The child is reaped when the stream ends; kill_on_drop covers a // client that hangs up first. tokio::spawn(async move { let _ = child.wait().await; }); Ok(Response::builder() .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result") .header(header::CACHE_CONTROL, "no-cache") .body(Body::from_stream(ReaderStream::new(stdout))) .expect("static headers are valid")) }