| 1 | //! Read-only smart-HTTP clones through `git http-backend`. |
| 2 | //! |
| 3 | //! Only `info/refs?service=git-upload-pack` and `POST git-upload-pack` are |
| 4 | //! served; anything that would push is refused before git runs. The CGI |
| 5 | //! environment is built from the index (never from the raw URL), and both |
| 6 | //! bodies are streamed. |
| 7 | |
| 8 | use std::process::Stdio; |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | use axum::body::Body; |
| 12 | use axum::extract::Request; |
| 13 | use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header}; |
| 14 | use axum::response::{IntoResponse, Response}; |
| 15 | use futures_util::{StreamExt, TryStreamExt}; |
| 16 | use tokio::io::AsyncReadExt; |
| 17 | use tokio::process::Command; |
| 18 | use tokio_util::io::{ReaderStream, StreamReader}; |
| 19 | |
| 20 | use crate::handlers::error; |
| 21 | use crate::index::Repo; |
| 22 | use crate::state::AppState; |
| 23 | |
| 24 | /// CGI response headers larger than this are an error. |
| 25 | const MAX_HEADER_BYTES: usize = 64 * 1024; |
| 26 | |
| 27 | /// Whether `rest` (the path after the repository) is a clone endpoint. |
| 28 | pub fn is_clone_path(rest: &[Vec<u8>]) -> bool { |
| 29 | matches!( |
| 30 | rest, |
| 31 | [a, b] if a == b"info" && b == b"refs" |
| 32 | ) || matches!(rest, [a] if a == b"git-upload-pack" || a == b"git-receive-pack") |
| 33 | } |
| 34 | |
| 35 | pub async fn serve( |
| 36 | state: Arc<AppState>, |
| 37 | repo: Arc<Repo>, |
| 38 | rest: Vec<Vec<u8>>, |
| 39 | req: Request, |
| 40 | ) -> Response { |
| 41 | let config = state.config.load_full(); |
| 42 | if !config.repos.https_clone { |
| 43 | return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"); |
| 44 | } |
| 45 | let query = req.uri().query().unwrap_or_default().to_string(); |
| 46 | let service = query |
| 47 | .split('&') |
| 48 | .find_map(|kv| kv.strip_prefix("service=")) |
| 49 | .unwrap_or_default(); |
| 50 | let endpoint = match (req.method(), rest.last().map(Vec::as_slice)) { |
| 51 | (&Method::GET | &Method::HEAD, Some(b"refs")) => match service { |
| 52 | "git-upload-pack" => "info/refs", |
| 53 | "git-receive-pack" => return forbidden(), |
| 54 | _ => { |
| 55 | return ( |
| 56 | StatusCode::FORBIDDEN, |
| 57 | "Only the smart HTTP protocol is supported.\n", |
| 58 | ) |
| 59 | .into_response(); |
| 60 | } |
| 61 | }, |
| 62 | (&Method::POST, Some(b"git-upload-pack")) => "git-upload-pack", |
| 63 | (_, Some(b"git-receive-pack")) => return forbidden(), |
| 64 | _ => return StatusCode::METHOD_NOT_ALLOWED.into_response(), |
| 65 | }; |
| 66 | |
| 67 | let Ok(permit) = state.heavy_permits.clone().acquire_owned().await else { |
| 68 | return StatusCode::SERVICE_UNAVAILABLE.into_response(); |
| 69 | }; |
| 70 | let (Some(root), Some(dir)) = (repo.git_dir.parent(), repo.git_dir.file_name()) else { |
| 71 | return StatusCode::NOT_FOUND.into_response(); |
| 72 | }; |
| 73 | let path_info = format!("/{}/{endpoint}", dir.to_string_lossy()); |
| 74 | |
| 75 | let headers = req.headers(); |
| 76 | let header = |name: HeaderName| { |
| 77 | headers |
| 78 | .get(name) |
| 79 | .and_then(|v| v.to_str().ok()) |
| 80 | .map(str::to_string) |
| 81 | }; |
| 82 | let mut cmd = Command::new("git"); |
| 83 | cmd.arg("http-backend") |
| 84 | .env_clear() |
| 85 | .env("PATH", std::env::var_os("PATH").unwrap_or_default()) |
| 86 | .env("GIT_PROJECT_ROOT", root) |
| 87 | .env("PATH_INFO", path_info) |
| 88 | .env("REQUEST_METHOD", req.method().as_str()) |
| 89 | .env("QUERY_STRING", &query) |
| 90 | .env("GIT_HTTP_EXPORT_ALL", "1") |
| 91 | // Only the repository's own config, plus ours. |
| 92 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 93 | .env("GIT_CONFIG_GLOBAL", "/dev/null") |
| 94 | .env("HOME", "/nonexistent") |
| 95 | .env("GIT_CONFIG_COUNT", "2") |
| 96 | .env("GIT_CONFIG_KEY_0", "http.receivepack") |
| 97 | .env("GIT_CONFIG_VALUE_0", "false") |
| 98 | // Visibility is decided by the index; ownership is not our concern. |
| 99 | .env("GIT_CONFIG_KEY_1", "safe.directory") |
| 100 | .env("GIT_CONFIG_VALUE_1", "*") |
| 101 | .stdin(Stdio::piped()) |
| 102 | .stdout(Stdio::piped()) |
| 103 | .stderr(Stdio::piped()) |
| 104 | .kill_on_drop(true); |
| 105 | for (var, name) in [ |
| 106 | ("CONTENT_TYPE", header::CONTENT_TYPE), |
| 107 | ("CONTENT_LENGTH", header::CONTENT_LENGTH), |
| 108 | ("HTTP_CONTENT_ENCODING", header::CONTENT_ENCODING), |
| 109 | ] { |
| 110 | if let Some(value) = header(name) { |
| 111 | cmd.env(var, value); |
| 112 | } |
| 113 | } |
| 114 | if let Some(protocol) = header(HeaderName::from_static("git-protocol")) { |
| 115 | cmd.env("HTTP_GIT_PROTOCOL", protocol); |
| 116 | } |
| 117 | |
| 118 | let mut child = match cmd.spawn() { |
| 119 | Ok(child) => child, |
| 120 | Err(e) => { |
| 121 | tracing::error!("cannot run git http-backend: {e}"); |
| 122 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
| 123 | } |
| 124 | }; |
| 125 | let (Some(mut stdin), Some(mut stdout), Some(mut stderr)) = |
| 126 | (child.stdin.take(), child.stdout.take(), child.stderr.take()) |
| 127 | else { |
| 128 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
| 129 | }; |
| 130 | |
| 131 | // Request body → stdin, then EOF. |
| 132 | let body = req |
| 133 | .into_body() |
| 134 | .into_data_stream() |
| 135 | .map_err(std::io::Error::other); |
| 136 | tokio::spawn(async move { |
| 137 | let mut reader = StreamReader::new(body); |
| 138 | if let Err(e) = tokio::io::copy(&mut reader, &mut stdin).await { |
| 139 | tracing::debug!("clone request body: {e}"); |
| 140 | } |
| 141 | }); |
| 142 | let name = repo.name.clone(); |
| 143 | tokio::spawn(async move { |
| 144 | let mut text = String::new(); |
| 145 | let _ = stderr.read_to_string(&mut text).await; |
| 146 | for line in text.lines().filter(|l| !l.trim().is_empty()) { |
| 147 | tracing::warn!("{name}: git http-backend: {line}"); |
| 148 | } |
| 149 | }); |
| 150 | |
| 151 | // CGI headers, then the rest of stdout as the body. |
| 152 | let mut buf = Vec::with_capacity(4096); |
| 153 | let (status, cgi_headers, body_start) = loop { |
| 154 | if let Some(parsed) = parse_cgi_headers(&buf) { |
| 155 | break parsed; |
| 156 | } |
| 157 | if buf.len() > MAX_HEADER_BYTES { |
| 158 | tracing::error!("git http-backend sent oversized headers"); |
| 159 | return StatusCode::BAD_GATEWAY.into_response(); |
| 160 | } |
| 161 | match stdout.read_buf(&mut buf).await { |
| 162 | Ok(0) | Err(_) => { |
| 163 | tracing::error!("git http-backend ended before its headers"); |
| 164 | return StatusCode::BAD_GATEWAY.into_response(); |
| 165 | } |
| 166 | Ok(_) => {} |
| 167 | } |
| 168 | }; |
| 169 | let head = buf.split_off(body_start); |
| 170 | let stream = futures_util::stream::once(async move { Ok(bytes::Bytes::from(head)) }) |
| 171 | .chain(ReaderStream::new(stdout)); |
| 172 | // Reap git and release the permit when it exits. |
| 173 | tokio::spawn(async move { |
| 174 | let _permit = permit; |
| 175 | let _ = child.wait().await; |
| 176 | }); |
| 177 | |
| 178 | let mut res = Body::from_stream(stream).into_response(); |
| 179 | *res.status_mut() = status; |
| 180 | for (name, value) in cgi_headers { |
| 181 | if let (Ok(name), Ok(value)) = ( |
| 182 | HeaderName::from_bytes(name.as_bytes()), |
| 183 | HeaderValue::from_str(&value), |
| 184 | ) { |
| 185 | res.headers_mut().insert(name, value); |
| 186 | } |
| 187 | } |
| 188 | res |
| 189 | } |
| 190 | |
| 191 | fn forbidden() -> Response { |
| 192 | (StatusCode::FORBIDDEN, "This server is read-only.\n").into_response() |
| 193 | } |
| 194 | |
| 195 | /// A parsed CGI response head: status, headers, and where the body starts. |
| 196 | pub type CgiHead = (StatusCode, Vec<(String, String)>, usize); |
| 197 | |
| 198 | /// Parse a CGI response head, or `None` while the blank line hasn't |
| 199 | /// arrived yet. |
| 200 | pub fn parse_cgi_headers(buf: &[u8]) -> Option<CgiHead> { |
| 201 | let (end, sep) = [&b"\r\n\r\n"[..], b"\n\n"] |
| 202 | .iter() |
| 203 | .filter_map(|sep| { |
| 204 | buf.windows(sep.len()) |
| 205 | .position(|w| w == *sep) |
| 206 | .map(|i| (i, sep.len())) |
| 207 | }) |
| 208 | .min()?; |
| 209 | let head = String::from_utf8_lossy(&buf[..end]); |
| 210 | let mut status = StatusCode::OK; |
| 211 | let mut headers = Vec::new(); |
| 212 | for line in head.lines() { |
| 213 | let Some((name, value)) = line.split_once(':') else { |
| 214 | continue; |
| 215 | }; |
| 216 | let value = value.trim().to_string(); |
| 217 | if name.eq_ignore_ascii_case("status") { |
| 218 | if let Some(code) = value |
| 219 | .split_whitespace() |
| 220 | .next() |
| 221 | .and_then(|c| c.parse::<u16>().ok()) |
| 222 | .and_then(|c| StatusCode::from_u16(c).ok()) |
| 223 | { |
| 224 | status = code; |
| 225 | } |
| 226 | } else { |
| 227 | headers.push((name.trim().to_string(), value)); |
| 228 | } |
| 229 | } |
| 230 | Some((status, headers, end + sep)) |
| 231 | } |
| 232 | |
| 233 | #[cfg(test)] |
| 234 | mod tests { |
| 235 | use super::*; |
| 236 | |
| 237 | #[test] |
| 238 | fn cgi_headers() { |
| 239 | let raw = b"Expires: Fri, 01 Jan 1980 00:00:00 GMT\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\n001e# service"; |
| 240 | let (status, headers, start) = parse_cgi_headers(raw).unwrap(); |
| 241 | assert_eq!(status, StatusCode::OK); |
| 242 | assert_eq!(headers.len(), 2); |
| 243 | assert_eq!(headers[1].1, "application/x-git-upload-pack-advertisement"); |
| 244 | assert_eq!(&raw[start..], b"001e# service"); |
| 245 | |
| 246 | let raw = b"Status: 403 Forbidden\nContent-Type: text/plain\n\nno"; |
| 247 | let (status, headers, start) = parse_cgi_headers(raw).unwrap(); |
| 248 | assert_eq!(status, StatusCode::FORBIDDEN); |
| 249 | assert_eq!(headers, vec![("Content-Type".into(), "text/plain".into())]); |
| 250 | assert_eq!(&raw[start..], b"no"); |
| 251 | |
| 252 | assert!(parse_cgi_headers(b"Content-Type: x\r\n").is_none()); |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn clone_paths() { |
| 257 | let p = |s: &[&str]| s.iter().map(|x| x.as_bytes().to_vec()).collect::<Vec<_>>(); |
| 258 | assert!(is_clone_path(&p(&["info", "refs"]))); |
| 259 | assert!(is_clone_path(&p(&["git-upload-pack"]))); |
| 260 | assert!(is_clone_path(&p(&["git-receive-pack"]))); |
| 261 | assert!(!is_clone_path(&p(&["info"]))); |
| 262 | assert!(!is_clone_path(&p(&["tree", "main"]))); |
| 263 | } |
| 264 | } |