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