//! Page: source archive at `/{owner}/{repo}/archive/{ref}.tar.gz` or `.zip`. The `git archive` //! stdout is streamed as the response body. use std::process::Stdio; use forge_git::browse; use forge_git::history::{self, ArchiveFormat}; use futures_util::TryStreamExt; use http::header::{ CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue, X_CONTENT_TYPE_OPTIONS, }; use http_body::Frame; use http_body_util::StreamBody; use tokio::io::AsyncReadExt; use tokio::process::Child; use tokio_util::io::ReaderStream; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::response::Response; use topcoat::router::{Body, path_param, route}; use crate::pages::commits::blocking; use crate::pages::repo_ctx::load_repo; use crate::state::AppState; path_param!(owner); path_param!(repo); path_param!(*rest); /// Splits `main.tar.gz` or `feature/x.zip` into the ref and the archive format. fn split_format(name: &str) -> Option<(&str, ArchiveFormat)> { let (reference, format) = name .strip_suffix(".tar.gz") .map(|r| (r, ArchiveFormat::TarGz)) .or_else(|| name.strip_suffix(".zip").map(|r| (r, ArchiveFormat::Zip)))?; (!reference.is_empty()).then_some((reference, format)) } /// `-`, or `-` when the ref is a hex id or sanitizes to /// nothing. The result satisfies `history::archive_command`'s prefix rules. fn archive_prefix(repo: &str, reference: &str, commit: &str) -> String { let sanitized: String = reference .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' } }) .collect(); let is_hex = reference.bytes().all(|b| b.is_ascii_hexdigit()); let tail = if sanitized.is_empty() || is_hex { commit.get(..12).unwrap_or(commit).to_owned() } else { sanitized }; format!("{repo}-{tail}") } #[route(GET "/{owner}/{repo}/archive/{*rest}")] async fn archive(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let rest = path_param::(cx).collect::>().join("/"); let Some((reference, format)) = split_format(&rest) else { return Err(not_found().into()); }; let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let repo_dir = ctx.path.clone(); let rev = reference.to_owned(); let Some(commit) = blocking(move || browse::resolve_commit(&repo_dir, &rev)).await? else { return Err(not_found().into()); }; let prefix = archive_prefix(&ctx.repo.name, reference, &commit); let (content_type, extension) = match format { ArchiveFormat::TarGz => ("application/gzip", "tar.gz"), ArchiveFormat::Zip => ("application/zip", "zip"), }; let mut command = history::archive_command(&state.git, &ctx.path, &commit, format, &prefix)?.into_tokio(); command .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn()?; let Some(stdout) = child.stdout.take() else { return Err(std::io::Error::other("git archive stdout missing").into()); }; tokio::spawn(reap(child)); let chunks = ReaderStream::new(stdout).map_ok(Frame::data); let mut res = Response::new(Body::new(StreamBody::new(chunks))); let headers = res.headers_mut(); headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert( CONTENT_DISPOSITION, HeaderValue::from_str(&format!("attachment; filename=\"{prefix}.{extension}\""))?, ); headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); Ok(res) } /// Drains stderr into the log and waits for `git archive`, so no zombie is left behind. async fn reap(mut child: Child) { let mut stderr = Vec::new(); if let Some(mut pipe) = child.stderr.take() { let _ = pipe.read_to_end(&mut stderr).await; } match child.wait().await { Ok(status) if status.success() => {} Ok(status) => tracing::warn!( %status, stderr = %String::from_utf8_lossy(&stderr).trim(), "git archive exited with failure" ), Err(e) => tracing::warn!(error = %e, "failed to wait for git archive"), } } #[cfg(test)] mod tests { use forge_core::Visibility; use super::{archive_prefix, split_format}; use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get}; use forge_git::history::ArchiveFormat; #[test] fn splits_ref_and_format() { assert!(matches!( split_format("feature/x.tar.gz"), Some(("feature/x", ArchiveFormat::TarGz)) )); assert!(matches!( split_format("v1.zip"), Some(("v1", ArchiveFormat::Zip)) )); assert!(split_format("main.rar").is_none()); assert!(split_format(".zip").is_none()); } #[test] fn prefix_is_sanitized() { assert_eq!( archive_prefix("demo", "feature/x y", "f".repeat(40).as_str()), "demo-feature-x-y" ); let oid = "0123456789abcdef0123456789abcdef01234567"; assert_eq!(archive_prefix("demo", oid, oid), "demo-0123456789ab"); } #[tokio::test] async fn tar_gz_and_zip_responses() { let fx = Fixture::new(Visibility::Public).await; let (status, headers, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.tar.gz")).await; assert_eq!(status, 200); assert_eq!(headers["content-type"], "application/gzip"); assert_eq!( headers["content-disposition"], "attachment; filename=\"demo-main.tar.gz\"" ); assert_eq!(headers["x-content-type-options"], "nosniff"); assert_eq!(&body[..2], &[0x1f, 0x8b]); let (status, headers, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.zip")).await; assert_eq!(status, 200); assert_eq!(headers["content-type"], "application/zip"); assert_eq!(&body[..2], b"PK"); let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/nope.zip")).await; assert_eq!(status, 404); let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.rar")).await; assert_eq!(status, 404); } #[tokio::test] async fn private_repo_is_404_for_anonymous() { let fx = Fixture::new(Visibility::Private).await; let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.tar.gz")).await; assert_eq!(status, 404); } }