// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The smart-HTTP git transport (read-only). //! //! Only clone/fetch (`upload-pack`) is served; push (`receive-pack`) returns a //! deliberate `403` pointing at the SSH URL (§3.3). Authorization happens **before** //! the `git` subprocess is spawned. The refs advertisement is small and buffered; //! the pack response streams straight from the child's stdout so it is never held //! in memory. Dumb HTTP is not implemented. use axum::body::Body; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum_extra::extract::cookie::CookieJar; use base64::Engine as _; use model::{Repo, User}; use tokio::io::AsyncWriteExt as _; use tokio_util::io::ReaderStream; use crate::AppState; /// Split `"repo/path.git/service..."` into the repo path and the trailing service /// path (`info/refs`, `git-upload-pack`, `git-receive-pack`). Also accepts the /// forms without `.git` is *not* supported here — callers strip that themselves. #[must_use] pub fn split_git(rest: &str) -> Option<(&str, &str)> { let idx = rest.find(".git/")?; Some((&rest[..idx], &rest[idx + ".git/".len()..])) } /// Handle a GET under a `.git/` path — only `info/refs?service=git-upload-pack`. pub async fn http_get( state: &AppState, jar: &CookieJar, headers: &HeaderMap, owner: &str, repo_path: &str, tail: &str, service: Option<&str>, ) -> Response { if tail != "info/refs" { return StatusCode::NOT_FOUND.into_response(); } match service { Some("git-upload-pack") => {} Some("git-receive-pack") => return push_disabled(state, owner, repo_path), _ => return StatusCode::FORBIDDEN.into_response(), } let repo = match authorize(state, jar, headers, owner, repo_path, auth::Access::Read).await { Ok(repo) => repo, Err(response) => return response, }; info_refs(state, &repo, headers).await } /// Handle a POST under a `.git/` path — `git-upload-pack` (served) or /// `git-receive-pack` (403). pub async fn pack_rpc( State(state): State, jar: CookieJar, headers: HeaderMap, Path((owner, rest)): Path<(String, String)>, body: Body, ) -> Response { // LFS batch/verify share the POST catch-all; route them before pack RPC. if let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest) { let repo_path = repo_path.to_string(); return match tail { "objects/batch" => crate::lfs::batch(state, jar, headers, owner, repo_path, body).await, t if t.starts_with("objects/") && t.ends_with("/verify") => { crate::lfs::verify(state, jar, headers, owner, repo_path, body).await } _ => StatusCode::NOT_FOUND.into_response(), }; } let Some((repo_path, tail)) = split_git(&rest) else { return StatusCode::NOT_FOUND.into_response(); }; match tail { "git-upload-pack" => {} "git-receive-pack" => return push_disabled(&state, &owner, repo_path), _ => return StatusCode::NOT_FOUND.into_response(), } let repo = match authorize( &state, &jar, &headers, &owner, repo_path, auth::Access::Read, ) .await { Ok(repo) => repo, Err(response) => return response, }; upload_pack_rpc(&state, &repo, &headers, body).await } /// Spawn `upload-pack --advertise-refs`, frame the output as a smart-HTTP service /// advertisement, and return it. async fn info_refs(state: &AppState, repo: &Repo, headers: &HeaderMap) -> Response { let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) { Ok(p) => p, Err(e) => return internal(&e.to_string()), }; let env = pack_env(state, headers, repo); let child = git::pack::spawn( &state.config.git.binary, git::pack::Service::UploadPack, &["--stateless-rpc", "--advertise-refs"], &repo_dir, &env, ); let output = match child { Ok(child) => match child.wait_with_output().await { Ok(out) => out, Err(e) => return internal(&e.to_string()), }, Err(e) => return internal(&e.to_string()), }; let mut framed = git::pack::pkt_line("# service=git-upload-pack\n").into_bytes(); framed.extend_from_slice(git::pack::FLUSH_PKT.as_bytes()); framed.extend_from_slice(&output.stdout); ( [ ( header::CONTENT_TYPE, "application/x-git-upload-pack-advertisement", ), (header::CACHE_CONTROL, "no-cache"), ], framed, ) .into_response() } /// Spawn `upload-pack --stateless-rpc`, feed it the (buffered, possibly gunzipped) /// request, and stream its stdout back as the response body. async fn upload_pack_rpc( state: &AppState, repo: &Repo, headers: &HeaderMap, body: Body, ) -> Response { let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) { Ok(p) => p, Err(e) => return internal(&e.to_string()), }; // The upload-pack request (the client's "wants") is small; buffer it, and // decompress if the client gzipped it. The large pack is the *response*, which // is streamed below. let Ok(raw) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { return (StatusCode::BAD_REQUEST, "request body too large").into_response(); }; let request = if header_eq(headers, header::CONTENT_ENCODING, "gzip") { match gunzip(&raw) { Ok(data) => data, Err(_) => return (StatusCode::BAD_REQUEST, "invalid gzip body").into_response(), } } else { raw.to_vec() }; let env = pack_env(state, headers, repo); let mut child = match git::pack::spawn( &state.config.git.binary, git::pack::Service::UploadPack, &["--stateless-rpc"], &repo_dir, &env, ) { Ok(child) => child, Err(e) => return internal(&e.to_string()), }; let (Some(mut stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else { return internal("failed to capture pack subprocess stdio"); }; // Feed the request, then own the child until it exits (or the client // disconnects, closing stdout and making upload-pack exit on write). tokio::spawn(async move { let _ = stdin.write_all(&request).await; let _ = stdin.shutdown().await; drop(stdin); let _ = child.wait().await; }); let stream = ReaderStream::new(stdout); ( [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")], Body::from_stream(stream), ) .into_response() } /// Resolve and authorize a repo for pack access. On failure returns the response /// to send: `401` (anonymous, credentials needed) or `404` (missing, or an /// authenticated stranger — no existence leak). #[allow(clippy::result_large_err)] // the Err is a ready-to-send Response by design. pub(crate) async fn authorize( state: &AppState, jar: &CookieJar, headers: &HeaderMap, owner: &str, repo_path: &str, required: auth::Access, ) -> Result { let owner_user = state.store.user_by_username(owner).await.ok().flatten(); let repo = match owner_user { Some(ref u) => state .store .repo_by_owner_path(&u.id, repo_path) .await .ok() .flatten(), None => None, }; let Some(repo) = repo else { return Err(StatusCode::NOT_FOUND.into_response()); }; let viewer = authenticate(state, jar, headers).await; let viewer_id = viewer.as_ref().map(|u| auth::Viewer { id: u.id.clone(), is_admin: u.is_admin, }); let collaborator = match &viewer { Some(u) => state .store .effective_permission(&repo.id, &u.id) .await .ok() .flatten() .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access( viewer_id.as_ref(), &repo, collaborator, state.allow_anonymous(), ); if access >= required { Ok(repo) } else if viewer.is_none() { Err(unauthorized()) } else { // An authenticated stranger: 404, not 403, so existence never leaks. Err(StatusCode::NOT_FOUND.into_response()) } } /// Authenticate a request via the session cookie, an LFS bearer token (minted by /// SSH `git-lfs-authenticate`), or HTTP Basic (username + password). Returns the /// user, or `None` for anonymous. pub(crate) async fn authenticate( state: &AppState, jar: &CookieJar, headers: &HeaderMap, ) -> Option { if let Some(cookie) = jar.get(&state.config.auth.cookie_name) && let Ok(Some(user)) = state .store .user_for_session(&auth::session_id(cookie.value())) .await { return Some(user); } if let Some(user) = bearer_user(state, headers).await { return Some(user); } let (username, password) = parse_basic(headers)?; let user = state .store .user_by_username(&username) .await .ok() .flatten()?; if user.disabled_at.is_none() && user.password_hash.is_some() && auth::verify_password(&password, user.password_hash.as_deref()) { Some(user) } else { None } } /// Resolve an `Authorization: Bearer ` header to its user. The token is an /// LFS access token minted by SSH `git-lfs-authenticate`; only its signature and /// expiry are proven here (access is re-checked per repo by [`authorize`]). async fn bearer_user(state: &AppState, headers: &HeaderMap) -> Option { let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; let token = value.strip_prefix("Bearer ")?.trim(); let now = crate::now_ms() / 1000; let claims = auth::verify_jwt(state.secret.as_str(), token, now).ok()?; state .store .user_by_id(&claims.sub) .await .ok() .flatten() .filter(|u| u.disabled_at.is_none()) } /// Parse an HTTP Basic `Authorization` header into `(username, password)`. fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; let encoded = value.strip_prefix("Basic ")?; let decoded = base64::engine::general_purpose::STANDARD .decode(encoded.trim()) .ok()?; let text = String::from_utf8(decoded).ok()?; let (user, pass) = text.split_once(':')?; Some((user.to_string(), pass.to_string())) } /// Build the subprocess environment for a pack spawn. fn pack_env(state: &AppState, headers: &HeaderMap, repo: &Repo) -> git::pack::PackEnv { git::pack::PackEnv { home: state.config.storage.data_dir.join("tmp"), protocol: headers .get("git-protocol") .and_then(|v| v.to_str().ok()) .map(str::to_string), repo_id: Some(repo.id.clone()), ..git::pack::PackEnv::default() } } /// The `403` push-over-HTTPS-disabled response, naming the SSH remote. fn push_disabled(state: &AppState, owner: &str, repo_path: &str) -> Response { let host = &state.config.ssh.clone_host; let port = state.config.ssh.clone_port; let ssh_url = if port == 22 { format!("git@{host}:{owner}/{repo_path}.git") } else { format!("ssh://git@{host}:{port}/{owner}/{repo_path}.git") }; ( StatusCode::FORBIDDEN, format!("Pushing over HTTPS is disabled. Push over SSH instead:\n\n {ssh_url}\n"), ) .into_response() } /// The `401` challenge for anonymous access to a private repo. fn unauthorized() -> Response { ( StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"fabrica\"")], "Authentication required.\n", ) .into_response() } /// A `500` for an internal transport failure (detail logged, not shown). fn internal(detail: &str) -> Response { tracing::error!(error.detail = %detail, "pack transport error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response() } /// Whether a header equals a value (ASCII case-insensitive). fn header_eq(headers: &HeaderMap, name: header::HeaderName, value: &str) -> bool { headers .get(name) .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.eq_ignore_ascii_case(value)) } /// Decompress a gzip body. fn gunzip(data: &[u8]) -> std::io::Result> { use std::io::Read as _; let mut decoder = flate2::read::GzDecoder::new(data); let mut out = Vec::new(); decoder.read_to_end(&mut out)?; Ok(out) }