//! A repository's files at a revision as one download, `.tar.gz` or //! `.zip`, as `git archive` writes it from the mirror. use super::{Repo, RestPath, Result, cache_for, not_found}; use crate::{App, AppError}; use anyhow::Context; use axum::body::Body; use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, header}; use axum::response::Response; use knotless::read; use std::process::Stdio; use std::sync::Arc; use tokio::process::{Child, Command}; use tokio_util::io::ReaderStream; /// How many archives are written at once: each reads the whole tree and /// compresses it, so the rest wait their turn. static ARCHIVING: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); /// How long an archive may take to write, its client reading it: past /// that, git is killed and the download ends cut short, so that clients /// that stall cannot keep the rest waiting. const WRITING: std::time::Duration = std::time::Duration::from_secs(10 * 60); /// The kinds of archive, by the ending that asks for each: `git archive`'s /// name for it, and its media type. const KINDS: [(&str, &str, &str); 2] = [ (".tar.gz", "tar.gz", "application/gzip"), (".zip", "zip", "application/zip"), ]; /// `/{authority}/{repo}/archive/{rev}.tar.gz` (or `.zip`): the files at /// `rev`, a branch, tag or commit, in a folder named for the repository /// and the revision. pub async fn archive( State(app): State>, headers: HeaderMap, path: RestPath, ) -> Result { let viewer = crate::login::viewer(&app, &headers); let (made, touched) = crate::private::reading(viewer, None, archived(&app, path)).await; let mut res = made?; if touched { res = crate::private::unshared(res); } res.headers_mut() .append(header::VARY, HeaderValue::from_static("cookie")); Ok(res) } async fn archived( app: &App, axum::extract::Path((authority, name, rest)): RestPath, ) -> Result { let repo = Repo::open(app, &authority, &name).await?; let Some((rev, format, kind)) = KINDS .iter() .find_map(|(end, format, kind)| Some((rest.strip_suffix(end)?, *format, *kind))) else { return Err(not_found(anyhow::anyhow!( "no archive {rest:?}: ask for one ending .tar.gz or .zip" ))); }; let rev = read::rev(Some(rev)).map_err(not_found)?; let commit = repo.objects()?.resolve(&rev).map_err(not_found)?; let folder = filename(&format!("{}-{rev}", repo.name)); let ending = &rest[rev.len()..]; let permit = ARCHIVING.acquire().await.expect("never closed"); let mut child = spawn(&repo.git.dir, format, &folder, &commit).map_err(AppError::internal)?; let stdout = child.stdout.take().expect("stdout is piped"); // The permit is held until git is done writing, or killed at the // deadline; kill_on_drop covers a client that hangs up first. tokio::spawn(async move { let waited = tokio::time::timeout(WRITING, child.wait()).await; drop((waited, child, permit)); }); let disposition = format!("attachment; filename=\"{folder}{ending}\""); Ok(Response::builder() .header(header::CONTENT_TYPE, kind) .header(header::CONTENT_DISPOSITION, disposition) .header(header::CACHE_CONTROL, cache_for(&rev)) .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .body(Body::from_stream(ReaderStream::new(stdout))) .expect("the headers are valid")) } /// `git archive` writing `commit` from `dir` as `format`, its files in /// `folder`. fn spawn(dir: &std::path::Path, format: &str, folder: &str, commit: &str) -> anyhow::Result { let child = Command::new("git") .arg("--git-dir") .arg(dir) .args(["archive", &format!("--format={format}")]) .arg(format!("--prefix={folder}/")) .arg(commit) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true) .spawn(); child.context("running git archive") } /// `name` as a file's name: what is not a letter, a digit, `.`, `_` or /// `-` becomes `-`, so a branch's `/` and anything a header would not /// carry are left out. fn filename(name: &str) -> String { name.chars() .map(|c| match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => c, _ => '-', }) .collect() } #[cfg(test)] mod tests { #[test] fn filenames_keep_only_what_a_header_carries() { assert_eq!(super::filename("tally-web/mermaid"), "tally-web-mermaid"); assert_eq!(super::filename("a-v1.0_b"), "a-v1.0_b"); assert_eq!(super::filename("x-\"é\""), "x----"); } }