smart_http.rs
raw
//! Read-only clone and fetch over HTTP, by running `git http-backend` as a
//! CGI program for the two endpoints the smart protocol needs. Only public
//! repositories are reachable, and only fetching is served: the endpoint
//! for pushing is never routed, and `git http-backend` refuses pushes over
//! HTTP unless a repository is explicitly configured to accept them.
use std::sync::Arc;
use anyhow::anyhow;
use poem::{
Body, Request, Response, Result,
error::NotFoundError,
handler,
http::{HeaderName, HeaderValue, StatusCode, header},
web::{Data, Path},
};
use crate::{App, git::Process, repos::RepoName};
/// The smart protocol endpoints that are served.
#[derive(Debug, Clone, Copy)]
enum Endpoint {
InfoRefs,
UploadPack,
}
impl Endpoint {
fn path(self) -> &'static str {
match self {
Endpoint::InfoRefs => "info/refs",
Endpoint::UploadPack => "git-upload-pack",
}
}
}
#[handler]
pub async fn info_refs(
app: Data<&Arc<App>>,
Path(repo): Path<String>,
req: &Request,
) -> Result<Response> {
run_backend(&app, &repo, Endpoint::InfoRefs, req, Body::empty()).await
}
#[handler]
pub async fn upload_pack(
app: Data<&Arc<App>>,
Path(repo): Path<String>,
req: &Request,
body: Body,
) -> Result<Response> {
run_backend(&app, &repo, Endpoint::UploadPack, req, body).await
}
async fn run_backend(
app: &App,
repo: &str,
endpoint: Endpoint,
req: &Request,
body: Body,
) -> Result<Response> {
let name = crate::pages::repo_name(repo)?;
let visible_name = name.clone();
if !app
.blocking(move |store| Ok(store.is_public(&visible_name)))
.await?
{
return Err(NotFoundError.into());
}
serve_backend(app, &name, endpoint, req, body).await
}
async fn serve_backend(
app: &App,
name: &RepoName,
endpoint: Endpoint,
req: &Request,
body: Body,
) -> Result<Response> {
let project_root = app.git.root().to_owned();
let path_info = format!("/{}/{}", name.dir_name(), endpoint.path());
let query = req.uri().query().unwrap_or_default().to_owned();
let method = req.method().as_str().to_owned();
let remote_addr = req
.remote_addr()
.as_socket_addr()
.map(|addr| addr.ip().to_string());
let headers = cgi_headers(req);
let mut backend = app
.git
.start("http-backend", Some(body), move |command| {
command
.arg("http-backend")
.env("GIT_PROJECT_ROOT", project_root)
.env("PATH_INFO", path_info)
.env("QUERY_STRING", query)
.env("REQUEST_METHOD", method)
.envs(headers);
if let Some(addr) = remote_addr {
command.env("REMOTE_ADDR", addr);
}
})
.await?;
let mut response = cgi_response(&mut backend).await?;
response.set_body(backend.into_body());
Ok(response)
}
// The status and headers of a CGI response: header lines up to a blank
// one, after which the body follows.
async fn cgi_response(backend: &mut Process) -> anyhow::Result<Response> {
let mut response = Response::default();
loop {
let line = backend
.read_line()
.await?
.ok_or_else(|| anyhow!("git http-backend ended without a response"))?;
if line.is_empty() {
return Ok(response);
}
let (key, value) = line
.split_once(':')
.ok_or_else(|| anyhow!("malformed CGI header from git http-backend: {line:?}"))?;
let value = value.trim();
if key.eq_ignore_ascii_case("Status") {
let code = value.split_whitespace().next().unwrap_or_default();
response.set_status(code.parse().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
} else {
response.headers_mut().insert(
HeaderName::from_bytes(key.as_bytes())?,
HeaderValue::from_str(value)?,
);
}
}
}
// The request headers as CGI meta-variables: `Content-Type` and
// `Content-Length` under their own names, every other header as
// `HTTP_<NAME>`, so that the backend sees whatever it looks for (the
// `Git-Protocol` header that selects protocol v2, for one). `Proxy` is
// the exception: as `HTTP_PROXY` it would configure an outbound proxy for
// anything the backend runs.
fn cgi_headers(req: &Request) -> Vec<(String, String)> {
let headers = req.headers();
headers
.keys()
.filter_map(|name| {
let variable = if *name == header::CONTENT_TYPE {
"CONTENT_TYPE".to_owned()
} else if *name == header::CONTENT_LENGTH {
"CONTENT_LENGTH".to_owned()
} else if name.as_str() == "proxy" {
return None;
} else {
format!(
"HTTP_{}",
name.as_str().to_ascii_uppercase().replace('-', "_")
)
};
let values: Vec<&str> = headers
.get_all(name)
.iter()
.filter_map(|value| value.to_str().ok())
.collect();
if values.is_empty() {
return None;
}
Some((variable, values.join(", ")))
})
.collect()
}