//! Read-only Git smart-HTTP support backed directly by stateless //! `git upload-pack` processes. use std::io::Read as _; use std::path::Path; use std::process::Stdio; use axum::body::{Body, Bytes}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::Response; use flate2::read::GzDecoder; use tokio::io::AsyncWriteExt as _; use tokio::sync::OwnedSemaphorePermit; use tokio_util::io::ReaderStream; const MAX_INFLATED_REQUEST: u64 = 64 * 1024 * 1024; const SERVICE_PREAMBLE: &[u8] = b"001e# service=git-upload-pack\n0000"; type WebError = (StatusCode, String); pub fn advertise( repo_dir: &Path, headers: &HeaderMap, permit: OwnedSemaphorePermit, ) -> Result { let protocol = git_protocol(headers); let stdout = spawn(repo_dir, "--http-backend-info-refs", protocol, None, permit)?; let preamble = if protocol.is_some_and(|p| p.split(':').any(|v| v == "version=2")) { &[][..] } else { SERVICE_PREAMBLE }; response( "application/x-git-upload-pack-advertisement", tokio::io::AsyncReadExt::chain(std::io::Cursor::new(preamble), stdout), ) } pub fn upload( repo_dir: &Path, headers: &HeaderMap, body: Bytes, permit: OwnedSemaphorePermit, ) -> Result { if headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) != Some("application/x-git-upload-pack-request") { return Err(( StatusCode::UNSUPPORTED_MEDIA_TYPE, "expected a git-upload-pack request".into(), )); } let body = decode_body(headers, body)?; let stdout = spawn( repo_dir, "--stateless-rpc", git_protocol(headers), Some(body), permit, )?; response("application/x-git-upload-pack-result", stdout) } fn spawn( repo_dir: &Path, mode: &str, protocol: Option<&str>, input: Option, permit: OwnedSemaphorePermit, ) -> Result { let mut command = tokio::process::Command::new("git"); command .arg("upload-pack") .arg(mode) .arg(repo_dir) .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true); if let Some(protocol) = protocol { command.env("GIT_PROTOCOL", protocol); } let mut child = command .spawn() .map_err(|e| bad_gateway(format!("spawning git upload-pack: {e}")))?; let stdin = child.stdin.take(); let stdout = child.stdout.take().expect("stdout was piped"); tokio::spawn(async move { if let Some(input) = input { let mut stdin = stdin.expect("stdin was piped"); let _ = stdin.write_all(&input).await; } match child.wait().await { Ok(status) if !status.success() => { eprintln!("sorceryd: git upload-pack exited with {status}") } Err(error) => eprintln!("sorceryd: waiting for git upload-pack: {error}"), _ => {} } drop(permit); }); Ok(stdout) } fn decode_body(headers: &HeaderMap, body: Bytes) -> Result { let Some(encoding) = headers.get(header::CONTENT_ENCODING) else { return Ok(body); }; let encoding = encoding.to_str().map_err(|_| { ( StatusCode::UNSUPPORTED_MEDIA_TYPE, "unsupported content encoding".into(), ) })?; if encoding.eq_ignore_ascii_case("identity") { return Ok(body); } if !encoding.eq_ignore_ascii_case("gzip") && !encoding.eq_ignore_ascii_case("x-gzip") { return Err(( StatusCode::UNSUPPORTED_MEDIA_TYPE, "unsupported content encoding".into(), )); } let mut decoded = Vec::new(); GzDecoder::new(body.as_ref()) .take(MAX_INFLATED_REQUEST + 1) .read_to_end(&mut decoded) .map_err(|_| (StatusCode::BAD_REQUEST, "invalid gzip request body".into()))?; if decoded.len() as u64 > MAX_INFLATED_REQUEST { return Err(( StatusCode::PAYLOAD_TOO_LARGE, "git request body is too large".into(), )); } Ok(decoded.into()) } fn git_protocol(headers: &HeaderMap) -> Option<&str> { headers.get("git-protocol").and_then(|v| v.to_str().ok()) } fn response( reader_type: &'static str, reader: impl tokio::io::AsyncRead + Send + 'static, ) -> Result { Response::builder() .header(header::CONTENT_TYPE, reader_type) .header(header::EXPIRES, "Fri, 01 Jan 1980 00:00:00 GMT") .header(header::PRAGMA, "no-cache") .header( header::CACHE_CONTROL, "no-cache, max-age=0, must-revalidate", ) .body(Body::from_stream(ReaderStream::new(reader))) .map_err(|e| bad_gateway(format!("building response: {e}"))) } fn bad_gateway(message: String) -> WebError { eprintln!("sorceryd: {message}"); (StatusCode::BAD_GATEWAY, "git backend error".into()) } #[cfg(test)] mod tests { use std::fs; use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; use axum::Router; use axum::body::Bytes; use axum::extract::State; use axum::http::HeaderMap; use axum::response::Response; use axum::routing::{get, post}; use tokio::sync::Semaphore; use crate::testutil::{TempDir, commit, git, init_sha256_repo}; use super::{WebError, advertise, upload}; #[derive(Clone)] struct GitState { repo: PathBuf, permits: Arc, } async fn info_refs( State(state): State, headers: HeaderMap, ) -> Result { advertise( &state.repo, &headers, state.permits.try_acquire_owned().unwrap(), ) } async fn upload_pack( State(state): State, headers: HeaderMap, body: Bytes, ) -> Result { upload( &state.repo, &headers, body, state.permits.try_acquire_owned().unwrap(), ) } #[tokio::test] async fn clones_sha256_over_smart_http() -> Result<()> { let root = TempDir::new("sha256-clone"); let repo = root.join("repo"); init_sha256_repo(&repo)?; fs::write(repo.join("file"), "contents")?; let head = commit(&repo, "initial")?; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; let app = Router::new() .route("/repo/info/refs", get(info_refs)) .route("/repo/git-upload-pack", post(upload_pack)) .with_state(GitState { repo, permits: Arc::new(Semaphore::new(8)), }); let server = tokio::spawn(async move { axum::serve(listener, app).await }); let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { git( &root, &[ "-c", "protocol.version=2", "clone", "-q", &format!("http://{address}/repo"), "clone", ], )?; assert_eq!(git(&root.join("clone"), &["rev-parse", "HEAD"])?, head); assert_eq!( git(&root.join("clone"), &["rev-parse", "--show-object-format"])?, "sha256" ); Ok::<(), anyhow::Error>(()) }) .await??; server.abort(); Ok(()) } }