git.rs
1 use std::collections::HashMap; 2 use std::io::prelude::*; 3 use std::net::SocketAddr; 4 use std::path::Path; 5 use std::process::{Command, Stdio}; 6 use std::sync::Arc; 7 use std::{io, net, str}; 8 9 use axum::body::Bytes; 10 use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery, State}; 11 use axum::http::header::HeaderName; 12 use axum::http::{HeaderMap, Method, StatusCode}; 13 use axum::response::IntoResponse; 14 use axum::routing::any; 15 use axum::Router; 16 use flate2::write::GzDecoder; 17 use hyper::body::Buf as _; 18 19 use radicle::identity::Id; 20 use radicle::profile::Profile; 21 22 use crate::error::GitError as Error; 23 24 pub fn router(profile: Arc<Profile>, aliases: HashMap<String, Id>) -> Router { 25 Router::new() 26 .route("/:project/*request", any(git_handler)) 27 .with_state((profile, aliases)) 28 } 29 30 async fn git_handler( 31 State((profile, aliases)): State<(Arc<Profile>, HashMap<String, Id>)>, 32 AxumPath((project, request)): AxumPath<(String, String)>, 33 method: Method, 34 headers: HeaderMap, 35 ConnectInfo(remote): ConnectInfo<SocketAddr>, 36 query: RawQuery, 37 body: Bytes, 38 ) -> impl IntoResponse { 39 let query = query.0.unwrap_or_default(); 40 let name = project.strip_suffix(".git").unwrap_or(&project); 41 let rid: Id = match name.parse() { 42 Ok(rid) => rid, 43 Err(_) => { 44 let Some(rid) = aliases.get(name) else { 45 return Err(Error::NotFound); 46 }; 47 *rid 48 } 49 }; 50 51 let (status, headers, body) = git_http_backend( 52 &profile, method, headers, body, remote, rid, &request, query, 53 ) 54 .await?; 55 56 let mut response_headers = HeaderMap::new(); 57 for (name, vec) in headers.iter() { 58 for value in vec { 59 let header: HeaderName = name.try_into()?; 60 response_headers.insert(header, value.parse()?); 61 } 62 } 63 64 Ok::<_, Error>((status, response_headers, body)) 65 } 66 67 async fn git_http_backend( 68 profile: &Profile, 69 method: Method, 70 headers: HeaderMap, 71 mut body: Bytes, 72 remote: net::SocketAddr, 73 id: Id, 74 path: &str, 75 query: String, 76 ) -> Result<(StatusCode, HashMap<String, Vec<String>>, Vec<u8>), Error> { 77 let git_dir = radicle::storage::git::paths::repository(&profile.storage, &id); 78 let content_type = 79 if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) { 80 content_type 81 } else { 82 "" 83 }; 84 85 // Reject push requests. 86 match (path, query.as_str()) { 87 ("git-receive-pack", _) | (_, "service=git-receive-pack") => { 88 return Err(Error::ServiceUnavailable("git-receive-pack")); 89 } 90 _ => {} 91 }; 92 93 tracing::debug!("id: {:?}", id); 94 tracing::debug!("headers: {:?}", headers); 95 tracing::debug!("path: {:?}", path); 96 tracing::debug!("method: {:?}", method.as_str()); 97 tracing::debug!("remote: {:?}", remote.to_string()); 98 99 let mut cmd = Command::new("git"); 100 let mut child = cmd 101 .arg("http-backend") 102 .env("REQUEST_METHOD", method.as_str()) 103 .env("GIT_PROJECT_ROOT", git_dir) 104 // "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass 105 // the check for the "git-daemon-export-ok" file in each repository before allowing export of 106 // that repository." 107 .env("GIT_HTTP_EXPORT_ALL", String::default()) 108 .env("PATH_INFO", Path::new("/").join(path)) 109 .env("CONTENT_TYPE", content_type) 110 .env("QUERY_STRING", query) 111 .stderr(Stdio::piped()) 112 .stdout(Stdio::piped()) 113 .stdin(Stdio::piped()) 114 .spawn()?; 115 116 // Whether the request body is compressed. 117 let gzip = matches!( 118 headers.get("Content-Encoding").map(|h| h.to_str()), 119 Some(Ok("gzip")) 120 ); 121 122 { 123 // This is safe because we captured the child's stdin. 124 let mut stdin = child.stdin.take().unwrap(); 125 126 // Copy the request body to git-http-backend's stdin. 127 if gzip { 128 let mut decoder = GzDecoder::new(&mut stdin); 129 let mut reader = body.reader(); 130 131 io::copy(&mut reader, &mut decoder)?; 132 decoder.finish()?; 133 } else { 134 while body.has_remaining() { 135 let mut chunk = body.chunk(); 136 let count = chunk.len(); 137 138 io::copy(&mut chunk, &mut stdin)?; 139 body.advance(count); 140 } 141 } 142 } 143 144 match child.wait_with_output() { 145 Ok(output) if output.status.success() => { 146 tracing::info!("git-http-backend: exited successfully for {}", id); 147 148 let mut reader = std::io::Cursor::new(output.stdout); 149 let mut headers = HashMap::new(); 150 151 // Parse headers returned by git so that we can use them in the client response. 152 for line in io::Read::by_ref(&mut reader).lines() { 153 let line = line?; 154 155 if line.is_empty() || line == "\r" { 156 break; 157 } 158 159 let mut parts = line.splitn(2, ':'); 160 let key = parts.next(); 161 let value = parts.next(); 162 163 if let (Some(key), Some(value)) = (key, value) { 164 let value = &value[1..]; 165 166 headers 167 .entry(key.to_string()) 168 .or_insert_with(Vec::new) 169 .push(value.to_string()); 170 } else { 171 return Err(Error::BackendHeader(line)); 172 } 173 } 174 175 let status = { 176 tracing::debug!("git-http-backend: {:?}", &headers); 177 178 let line = headers.remove("Status").unwrap_or_default(); 179 let line = line.into_iter().next().unwrap_or_default(); 180 let mut parts = line.split(' '); 181 182 parts 183 .next() 184 .and_then(|p| p.parse().ok()) 185 .unwrap_or(StatusCode::OK) 186 }; 187 188 let position = reader.position() as usize; 189 let body = reader.into_inner().split_off(position); 190 191 Ok((status, headers, body)) 192 } 193 Ok(output) => { 194 if let Ok(output) = std::str::from_utf8(&output.stderr) { 195 tracing::error!("git-http-backend: stderr: {}", output.trim_end()); 196 } 197 Err(Error::BackendExited(output.status)) 198 } 199 Err(err) => { 200 panic!("failed to wait for git-http-backend: {err}"); 201 } 202 } 203 } 204 205 #[cfg(test)] 206 mod routes { 207 use std::collections::HashMap; 208 use std::net::SocketAddr; 209 use std::str::FromStr; 210 211 use axum::extract::connect_info::MockConnectInfo; 212 use axum::http::StatusCode; 213 use radicle::identity::Id; 214 215 use crate::test::{self, get, RID}; 216 217 #[tokio::test] 218 async fn test_info_request() { 219 let tmp = tempfile::tempdir().unwrap(); 220 let ctx = test::seed(tmp.path()); 221 let app = super::router(ctx.profile().to_owned(), HashMap::new()) 222 .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080)))); 223 224 let response = get(&app, format!("/{RID}.git/info/refs")).await; 225 226 assert_eq!(response.status(), StatusCode::OK); 227 } 228 229 #[tokio::test] 230 async fn test_aliases() { 231 let tmp = tempfile::tempdir().unwrap(); 232 let ctx = test::seed(tmp.path()); 233 let app = super::router( 234 ctx.profile().to_owned(), 235 HashMap::from_iter([(String::from("heartwood"), Id::from_str(RID).unwrap())]), 236 ) 237 .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080)))); 238 239 let response = get(&app, "/woodheart.git/info/refs").await; 240 assert_eq!(response.status(), StatusCode::NOT_FOUND); 241 242 let response = get(&app, "/heartwood.git/info/refs").await; 243 assert_eq!(response.status(), StatusCode::OK); 244 } 245 }