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