mod mime; pub use mime::get_mime_type; use std::{convert::Infallible, path::Path, sync::Arc}; use anyhow::{Context as _, Result, bail}; use bytes::Bytes; use http::{Method, Request, Response, StatusCode, header, request::Parts}; use http_body_util::Full; use percent_encoding::percent_decode_str; use tokio::{fs, process::Command}; use tower::{service_fn, util::BoxCloneService}; pub type Body = Full; const CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; pub type Handler = BoxCloneService, Response, Infallible>; pub fn create_handler(root: impl AsRef) -> std::io::Result> { let root = Arc::new(std::fs::canonicalize(root)?); if !root.is_dir() { return Err(std::io::Error::new( std::io::ErrorKind::NotADirectory, "TARGET_DIR must be a directory", )); } Ok(BoxCloneService::new(service_fn(move |req: Request| { let root = root.clone(); let (parts, _) = req.into_parts(); async move { let head = parts.method == Method::HEAD; let mut response = handle(&root, parts).await.unwrap_or_else(|error| { eprintln!("{error:#}"); text(StatusCode::INTERNAL_SERVER_ERROR, "internal error") }); if head { *response.body_mut() = Full::default(); } Ok(response) } }))) } async fn handle(root: &Path, req: Parts) -> Result> { let Some(path) = req.uri.path().strip_prefix('/') else { return Ok(text(StatusCode::NOT_FOUND, "not found")); }; let mut segments = path.splitn(3, '/'); let (Some(repo), Some(version), Some(file)) = (segments.next(), segments.next(), segments.next()) else { return Ok(text(StatusCode::NOT_FOUND, "not found")); }; if repo.is_empty() || version.is_empty() || file.is_empty() { return Ok(text(StatusCode::NOT_FOUND, "not found")); } if req.method != Method::GET && req.method != Method::HEAD { let mut response = text(StatusCode::METHOD_NOT_ALLOWED, "method not allowed"); response .headers_mut() .insert(header::ALLOW, "GET, HEAD".parse()?); return Ok(response); } let file = percent_decode_str(file).decode_utf8(); let file = match file { Ok(file) if !file.contains('\0') && !file .split('/') .any(|segment| segment.is_empty() || segment == "..") => { file } _ => return Ok(text(StatusCode::NOT_FOUND, "file not found")), }; let repo = percent_decode_str(repo).decode_utf8(); let repo = match repo { Ok(repo) if !matches!(repo.as_ref(), "." | "..") && !repo.contains(['/', '\\', '\0']) => { repo } _ => return Ok(text(StatusCode::NOT_FOUND, "repository not found")), }; let repo_dir = match fs::canonicalize(root.join(repo.as_ref())).await { Ok(dir) if dir.starts_with(root) && fs::metadata(&dir) .await .is_ok_and(|metadata| metadata.is_dir()) => { dir } _ => return Ok(text(StatusCode::NOT_FOUND, "repository not found")), }; // Check membership before using a version in a Git ref expression. let tags = git(&repo_dir, &["tag", "--list", "--sort=-version:refname"]).await?; let tags = String::from_utf8(tags).context("Git returned non-UTF-8 tags")?; let tags: Vec<_> = tags.lines().filter(|tag| !tag.is_empty()).collect(); let version = percent_decode_str(version).decode_utf8(); let version = match version { Ok(version) if tags.contains(&version.as_ref()) => version, _ => { let list = if tags.is_empty() { "(no tags)".to_owned() } else { tags.join("\n") }; return Ok(text( StatusCode::NOT_FOUND, format!("version not found\n\navailable versions:\n{list}\n"), )); } }; // The object ID lets HEAD and conditional requests skip reading the blob. let info = git( &repo_dir, &[ "--literal-pathspecs", "ls-tree", "-z", "--format=%(objectname) %(objecttype) %(objectsize)", &format!("refs/tags/{version}"), "--", &file, ], ) .await?; let info = std::str::from_utf8(&info)?; let fields: Vec<_> = info.strip_suffix('\0').unwrap_or("").split(' ').collect(); let (object_id, size) = match fields.as_slice() { [id, "blob", size] if !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()) => { (*id, size.parse::()?) } _ => return Ok(text(StatusCode::NOT_FOUND, "file not found")), }; let etag = format!("\"{object_id}\""); let not_modified = req .headers .get_all(header::IF_NONE_MATCH) .iter() .any(|value| { value.to_str().is_ok_and(|value| { value.split(',').any(|candidate| { let tag = candidate.trim(); tag == "*" || tag.strip_prefix("W/").unwrap_or(tag) == etag }) }) }); let response = Response::builder() .header(header::CACHE_CONTROL, CACHE_CONTROL) .header(header::ETAG, etag); if not_modified { return Ok(response .status(StatusCode::NOT_MODIFIED) .body(Full::default())?); } let response = response .header( header::CONTENT_TYPE, get_mime_type(&file).unwrap_or("application/octet-stream"), ) .header(header::CONTENT_LENGTH, size); let body = if req.method == Method::HEAD { Full::default() } else { Full::new(Bytes::from( git(&repo_dir, &["cat-file", "blob", object_id]).await?, )) }; Ok(response.body(body)?) } fn text(status: StatusCode, body: impl Into) -> Response { let body = body.into(); Response::builder() .status(status) .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(header::CONTENT_LENGTH, body.len()) .body(Full::new(body)) .expect("static response headers are valid") } async fn git(repo: &Path, args: &[&str]) -> Result> { let output = Command::new("git") .arg("-C") .arg(repo) .args(args) // Do not discover a parent repository or inherit an unrelated Git context. .env("GIT_CEILING_DIRECTORIES", repo.parent().unwrap_or(repo)) .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_COMMON_DIR") .env_remove("GIT_OBJECT_DIRECTORY") .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") .kill_on_drop(true) .output() .await .with_context(|| format!("running git {args:?} in {}", repo.display()))?; if !output.status.success() { bail!( "git {args:?} in {} failed ({}): {}", repo.display(), output.status, String::from_utf8_lossy(&output.stderr).trim_end() ); } Ok(output.stdout) }