use std::collections::HashMap;
use std::io::prelude::*;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::{io, str};
use axum::body::Bytes;
use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery, State};
use axum::http::header::HeaderName;
use axum::http::{HeaderMap, Method, StatusCode};
use axum::response::IntoResponse;
use axum::routing::any;
use axum::Router;
use axum_listener::DualAddr;
use flate2::write::GzDecoder;
use hyper::body::Buf as _;
use radicle::identity::RepoId;
use radicle::node::NodeId;
use radicle::profile::Profile;
use radicle::storage::{ReadRepository, ReadStorage};
use crate::error::GitError as Error;
pub fn router(profile: Arc<Profile>, aliases: HashMap<String, RepoId>) -> Router {
Router::new()
.route("/{rid}/{*request}", any(git_handler))
.with_state((profile, aliases))
}
async fn git_handler(
State((profile, aliases)): State<(Arc<Profile>, HashMap<String, RepoId>)>,
AxumPath((repository, request)): AxumPath<(String, String)>,
method: Method,
headers: HeaderMap,
ConnectInfo(remote): ConnectInfo<DualAddr>,
query: RawQuery,
body: Bytes,
) -> impl IntoResponse {
let query = query.0.unwrap_or_default();
let name = repository.strip_suffix(".git").unwrap_or(&repository);
let rid: RepoId = match name.parse() {
Ok(rid) => rid,
Err(_) => {
let Some(rid) = aliases.get(name) else {
return Err(Error::NotFound);
};
*rid
}
};
let (nid, request): (Option<NodeId>, &str) = {
let (first, rest) = request.split_once('/').unwrap_or((&request, ""));
match first.parse::<NodeId>() {
Ok(nid) => (Some(nid), rest),
Err(_) => (None, &request),
}
};
let (status, headers, body) = git_http_backend(
&profile, method, headers, body, remote, rid, nid, request, query,
)
.await?;
let mut response_headers = HeaderMap::new();
for (name, vec) in headers.iter() {
for value in vec {
let header: HeaderName = name.try_into()?;
response_headers.insert(header, value.parse()?);
}
}
Ok::<_, Error>((status, response_headers, body))
}
async fn git_http_backend(
profile: &Profile,
method: Method,
headers: HeaderMap,
mut body: Bytes,
remote: DualAddr,
id: RepoId,
nid: Option<NodeId>,
path: &str,
query: String,
) -> Result<(StatusCode, HashMap<String, Vec<String>>, Vec<u8>), Error> {
let git_dir = radicle::storage::git::paths::repository(&profile.storage, &id);
let content_type =
if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) {
content_type
} else {
""
};
// Don't allow cloning of private repositories.
let doc = profile.storage.repository(id)?.identity_doc()?;
if doc.visibility().is_private() {
return Err(Error::NotFound);
}
// Reject push requests.
match (path, query.as_str()) {
("git-receive-pack", _) | (_, "service=git-receive-pack") => {
return Err(Error::ServiceUnavailable("git-receive-pack"));
}
_ => {}
};
tracing::debug!("id: {:?}", id);
tracing::debug!("headers: {:?}", headers);
tracing::debug!("path: {:?}", path);
tracing::debug!("method: {:?}", method.as_str());
tracing::debug!("remote: {:?}", remote);
let mut cmd = Command::new("git");
if let Some(nid) = nid {
cmd.env("GIT_NAMESPACE", nid.to_string());
}
let mut child = cmd
// This is a workaround to allow fetching particular commits by their OID.
// Otherwise, the client errors with "Server does not allow request for unadvertised object"
// See also `REF_UNADVERTISED_NOT_ALLOWED` in Git's `fetch-pack.c` (as of 0bd2d79).
.args(["-c", "uploadpack.allowAnySHA1InWant"])
.arg("http-backend")
.env("REQUEST_METHOD", method.as_str())
.env("GIT_PROJECT_ROOT", git_dir)
// "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass
// the check for the "git-daemon-export-ok" file in each repository before allowing export of
// that repository."
.env("GIT_HTTP_EXPORT_ALL", String::default())
.env("PATH_INFO", Path::new("/").join(path))
.env("CONTENT_TYPE", content_type)
.env("QUERY_STRING", query)
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.spawn()?;
// Whether the request body is compressed.
let gzip = matches!(
headers.get("Content-Encoding").map(|h| h.to_str()),
Some(Ok("gzip"))
);
{
// This is safe because we captured the child's stdin.
let mut stdin = child.stdin.take().unwrap();
// Copy the request body to git-http-backend's stdin.
if gzip {
let mut decoder = GzDecoder::new(&mut stdin);
let mut reader = body.reader();
io::copy(&mut reader, &mut decoder)?;
decoder.finish()?;
} else {
while body.has_remaining() {
let mut chunk = body.chunk();
let count = chunk.len();
io::copy(&mut chunk, &mut stdin)?;
body.advance(count);
}
}
}
match child.wait_with_output() {
Ok(output) if output.status.success() => {
tracing::info!("git-http-backend: exited successfully for {}", id);
let mut reader = std::io::Cursor::new(output.stdout);
let mut headers = HashMap::new();
// Parse headers returned by git so that we can use them in the client response.
for line in io::Read::by_ref(&mut reader).lines() {
let line = line?;
if line.is_empty() || line == "\r" {
break;
}
let mut parts = line.splitn(2, ':');
let key = parts.next();
let value = parts.next();
if let (Some(key), Some(value)) = (key, value) {
let value = &value[1..];
headers
.entry(key.to_string())
.or_insert_with(Vec::new)
.push(value.to_string());
} else {
return Err(Error::BackendHeader(line));
}
}
let status = {
tracing::debug!("git-http-backend: {:?}", &headers);
let line = headers.remove("Status").unwrap_or_default();
let line = line.into_iter().next().unwrap_or_default();
let mut parts = line.split(' ');
parts
.next()
.and_then(|p| p.parse().ok())
.unwrap_or(StatusCode::OK)
};
let position = reader.position() as usize;
let body = reader.into_inner().split_off(position);
Ok((status, headers, body))
}
Ok(output) => {
if let Ok(output) = std::str::from_utf8(&output.stderr) {
tracing::error!("git-http-backend: stderr: {}", output.trim_end());
}
Err(Error::BackendExited(output.status))
}
Err(err) => {
panic!("failed to wait for git-http-backend: {err}");
}
}
}
#[cfg(test)]
mod routes {
use std::collections::HashMap;
use std::net::SocketAddr;
use std::str::FromStr;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::StatusCode;
use axum_listener::DualAddr;
use radicle::identity::RepoId;
use crate::test::{self, get, RID};
#[tokio::test]
async fn test_info_request() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let app = super::router(ctx.profile().to_owned(), HashMap::new()).layer(MockConnectInfo(
DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))),
));
let response = get(&app, format!("/{RID}.git/info/refs")).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_aliases() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let app = super::router(
ctx.profile().to_owned(),
HashMap::from_iter([(String::from("heartwood"), RepoId::from_str(RID).unwrap())]),
)
.layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from((
[0, 0, 0, 0],
8080,
)))));
let response = get(&app, "/woodheart.git/info/refs").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = get(&app, "/heartwood.git/info/refs").await;
assert_eq!(response.status(), StatusCode::OK);
}
}