c4344e19

Archive
Tree [c4344e19] [>]
commit
c4344e197c362dcc772f9bab9cca15fa8d6d64c3
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-08
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-08
changes
1
insertions
194
deletions
4
Implement archive download handler with format validation
Msrc/routes/archive.rs
~//! Archive download handler.~//!~//! Covers the route `/{repo}/archive/{format}/{*ref}`.  Streams the-//! repository archive in the requested format (`tar.gz`, `tar.xz`, or-//! `zip`) for the given ref.+//! repository archive in the requested format (`tar.gz`, `tgz`, `tar.xz`,+//! `txz`, or `zip`) for the given ref.  `tar.xz` and `txz` are produced by+//! piping `git archive --format=tar` through an external `xz` process.~+use std::io::Read;+use std::process::{Command, Stdio};++use axum::extract::{Path, State};+use axum::http::header;+use axum::http::HeaderValue;~use axum::response::IntoResponse;+use serde::Deserialize;+use tokio::task::spawn_blocking;++use crate::error::{Error, Result};+use crate::routes::{AppState, RepoName};++/// Path parameters for the archive route.+#[derive(Deserialize)]+pub(super) struct ArchivePath {+    format: String,+    #[serde(rename = "ref")]+    ref_: String,+}++struct ArchiveConfig {+    content_type: &'static str,+    extension: &'static str,+}++/// Returns the content type and file extension for a given format string.+/// Returns `None` for unsupported formats.+fn archive_config(format: &str) -> Option<ArchiveConfig> {+    match format {+        "tar.gz" | "tgz" => Some(ArchiveConfig {+            content_type: "application/gzip",+            extension: "tar.gz",+        }),+        "tar.xz" | "txz" => Some(ArchiveConfig {+            content_type: "application/x-xz",+            extension: "tar.xz",+        }),+        "zip" => Some(ArchiveConfig {+            content_type: "application/zip",+            extension: "zip",+        }),+        _ => None,+    }+}++/// Runs `git archive` and returns (stdout, success, stderr).+fn run_archive(repo_path: &str, git_format: &str, ref_: &str) -> (Vec<u8>, bool, String) {+    let output = Command::new("git")+        .arg("-C")+        .arg(repo_path)+        .arg("archive")+        .arg(format!("--format={git_format}"))+        .arg(ref_)+        .output();++    match output {+        Ok(o) => (+            o.stdout,+            o.status.success(),+            String::from_utf8_lossy(&o.stderr).into_owned(),+        ),+        Err(e) => (Vec::new(), false, e.to_string()),+    }+}++/// Pipes `git archive --format=tar` through `xz`, returning (stdout, success, stderr).+fn run_archive_xz(repo_path: &str, ref_: &str) -> (Vec<u8>, bool, String) {+    let mut git = match Command::new("git")+        .arg("-C")+        .arg(repo_path)+        .arg("archive")+        .arg("--format=tar")+        .arg(ref_)+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .spawn()+    {+        Ok(c) => c,+        Err(e) => return (Vec::new(), false, format!("spawn git: {e}")),+    };++    let git_stdout = git.stdout.take().expect("stdout configured");+    let mut xz = match Command::new("xz")+        .stdin(git_stdout)+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .spawn()+    {+        Ok(c) => c,+        Err(e) => return (Vec::new(), false, format!("spawn xz: {e}")),+    };++    // Wait for xz to finish (consumes all piped git output).+    let xz_output = match xz.wait_with_output() {+        Ok(o) => o,+        Err(e) => return (Vec::new(), false, format!("xz wait: {e}")),+    };++    // Check git's exit status. Report git errors first since xz failures+    // are often a downstream consequence of git producing no output.+    let git_status = match git.wait() {+        Ok(s) => s,+        Err(e) => return (Vec::new(), false, format!("git wait: {e}")),+    };++    if !git_status.success() {+        let msg = git.stderr.take()+            .and_then(|mut s| {+                let mut buf = String::new();+                s.read_to_string(&mut buf).ok()?;+                Some(buf)+            })+            .unwrap_or_default();+        let msg = if msg.is_empty() { "git archive failed".into() } else { msg };+        return (Vec::new(), false, msg);+    }++    if !xz_output.status.success() {+        let msg = String::from_utf8_lossy(&xz_output.stderr);+        let msg = if msg.is_empty() { "xz compression failed".into() } else { msg.into_owned() };+        return (Vec::new(), false, msg);+    }++    (xz_output.stdout, true, String::new())+}++/// Streams a repository archive for the given ref in the requested format.+pub async fn handler(+    State(state): State<AppState>,+    RepoName(name): RepoName,+    Path(params): Path<ArchivePath>,+) -> Result<impl IntoResponse> {+    if params.ref_.is_empty() {+        return Err(Error::BadRequest("empty ref".into()));+    }++    let cfg = archive_config(&params.format)+        .ok_or_else(|| Error::BadRequest(format!("unsupported format: {}", params.format)))?;++    let repo_path = state.root.join(&name);+    if !repo_path.exists() {+        return Err(Error::RepoNotFound(name.clone()));+    }++    // Verify the ref resolves before shelling out.+    let git_repo = gix::open(&repo_path).map_err(|_| Error::RepoNotFound(name.clone()))?;+    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {+        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));+    }++    let repo_path_str = repo_path.to_string_lossy().into_owned();+    let archive_ref = params.ref_.clone();++    let uses_xz = matches!(params.format.as_str(), "tar.xz" | "txz");+    let git_format = match params.format.as_str() {+        "zip" => "zip",+        _ => "tar.gz",+    };+    let ref_for_archive = archive_ref.clone();++    let (stdout, success, stderr) = spawn_blocking(move || {+        if uses_xz {+            run_archive_xz(&repo_path_str, &ref_for_archive)+        } else {+            run_archive(&repo_path_str, git_format, &ref_for_archive)+        }+    })+    .await+    .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;++    if !success {+        return Err(Error::NotFound(format!("archive failed: {stderr}")));+    }++    // Strip refs/heads/ or refs/tags/ prefix for a cleaner filename.+    let short_ref = archive_ref+        .strip_prefix("refs/heads/")+        .or_else(|| archive_ref.strip_prefix("refs/tags/"))+        .unwrap_or(&archive_ref);+    let filename = format!("{name}-{short_ref}.{}", cfg.extension);+    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))+        .map_err(|_| Error::BadRequest("invalid header value".into()))?;++    let content_type = HeaderValue::from_static(cfg.content_type);~-pub async fn handler() -> impl IntoResponse {-    "Archive handler"+    Ok((+        [+            (header::CONTENT_TYPE, content_type),+            (header::CONTENT_DISPOSITION, disposition),+        ],+        stdout,+    ))~}