/ radicle-httpd / src / git.rs
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  }