Parent [>]
1//! Archive download handler.
2//!
3//! Covers the route `/{repo}/archive/{format}/{*ref}`.  Streams the
4//! repository archive in the requested format (`tar.gz`, `tgz`, `tar.xz`,
5//! `txz`, or `zip`) for the given ref.  `tar.xz` and `txz` are produced by
6//! piping `git archive --format=tar` through an external `xz` process.
7
8use std::io::Read;
9use std::process::{Command, Stdio};
10
11use axum::extract::{Path, State};
12use axum::http::header;
13use axum::http::HeaderValue;
14use axum::response::IntoResponse;
15use serde::Deserialize;
16use tokio::task::spawn_blocking;
17
18use crate::error::{Error, Result};
19use crate::routes::{AppState, RepoName};
20
21/// Path parameters for the archive route.
22#[derive(Deserialize)]
23pub(super) struct ArchivePath {
24    format: String,
25    #[serde(rename = "ref")]
26    ref_: String,
27}
28
29struct ArchiveConfig {
30    content_type: &'static str,
31    extension: &'static str,
32}
33
34/// Returns the content type and file extension for a given format string.
35/// Returns `None` for unsupported formats.
36fn archive_config(format: &str) -> Option<ArchiveConfig> {
37    match format {
38        "tar.gz" | "tgz" => Some(ArchiveConfig {
39            content_type: "application/gzip",
40            extension: "tar.gz",
41        }),
42        "tar.xz" | "txz" => Some(ArchiveConfig {
43            content_type: "application/x-xz",
44            extension: "tar.xz",
45        }),
46        "zip" => Some(ArchiveConfig {
47            content_type: "application/zip",
48            extension: "zip",
49        }),
50        _ => None,
51    }
52}
53
54/// Runs `git archive` and returns (stdout, success, stderr).
55fn run_archive(repo_path: &str, git_format: &str, ref_: &str) -> (Vec<u8>, bool, String) {
56    let output = Command::new("git")
57        .arg("-C")
58        .arg(repo_path)
59        .arg("archive")
60        .arg(format!("--format={git_format}"))
61        .arg(ref_)
62        .output();
63
64    match output {
65        Ok(o) => (
66            o.stdout,
67            o.status.success(),
68            String::from_utf8_lossy(&o.stderr).into_owned(),
69        ),
70        Err(e) => (Vec::new(), false, e.to_string()),
71    }
72}
73
74/// Pipes `git archive --format=tar` through `xz`, returning (stdout, success, stderr).
75fn run_archive_xz(repo_path: &str, ref_: &str) -> (Vec<u8>, bool, String) {
76    let mut git = match Command::new("git")
77        .arg("-C")
78        .arg(repo_path)
79        .arg("archive")
80        .arg("--format=tar")
81        .arg(ref_)
82        .stdout(Stdio::piped())
83        .stderr(Stdio::piped())
84        .spawn()
85    {
86        Ok(c) => c,
87        Err(e) => return (Vec::new(), false, format!("spawn git: {e}")),
88    };
89
90    let git_stdout = git.stdout.take().expect("stdout configured");
91    let mut xz = match Command::new("xz")
92        .stdin(git_stdout)
93        .stdout(Stdio::piped())
94        .stderr(Stdio::piped())
95        .spawn()
96    {
97        Ok(c) => c,
98        Err(e) => return (Vec::new(), false, format!("spawn xz: {e}")),
99    };
100
101    // Wait for xz to finish (consumes all piped git output).
102    let xz_output = match xz.wait_with_output() {
103        Ok(o) => o,
104        Err(e) => return (Vec::new(), false, format!("xz wait: {e}")),
105    };
106
107    // Check git's exit status. Report git errors first since xz failures
108    // are often a downstream consequence of git producing no output.
109    let git_status = match git.wait() {
110        Ok(s) => s,
111        Err(e) => return (Vec::new(), false, format!("git wait: {e}")),
112    };
113
114    if !git_status.success() {
115        let msg = git.stderr.take()
116            .and_then(|mut s| {
117                let mut buf = String::new();
118                s.read_to_string(&mut buf).ok()?;
119                Some(buf)
120            })
121            .unwrap_or_default();
122        let msg = if msg.is_empty() { "git archive failed".into() } else { msg };
123        return (Vec::new(), false, msg);
124    }
125
126    if !xz_output.status.success() {
127        let msg = String::from_utf8_lossy(&xz_output.stderr);
128        let msg = if msg.is_empty() { "xz compression failed".into() } else { msg.into_owned() };
129        return (Vec::new(), false, msg);
130    }
131
132    (xz_output.stdout, true, String::new())
133}
134
135/// Streams a repository archive for the given ref in the requested format.
136pub async fn handler(
137    State(state): State<AppState>,
138    RepoName(name): RepoName,
139    Path(params): Path<ArchivePath>,
140) -> Result<impl IntoResponse> {
141    if params.ref_.is_empty() {
142        return Err(Error::BadRequest("empty ref".into()));
143    }
144
145    let cfg = archive_config(&params.format)
146        .ok_or_else(|| Error::BadRequest(format!("unsupported format: {}", params.format)))?;
147
148    let repo_path = state.root.join(&name);
149    if !repo_path.exists() {
150        return Err(Error::RepoNotFound(name.clone()));
151    }
152
153    // Verify the ref resolves before shelling out.
154    let git_repo = gix::open(&repo_path).map_err(|_| Error::RepoNotFound(name.clone()))?;
155    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {
156        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));
157    }
158
159    let repo_path_str = repo_path.to_string_lossy().into_owned();
160    let archive_ref = params.ref_.clone();
161
162    let uses_xz = matches!(params.format.as_str(), "tar.xz" | "txz");
163    let git_format = match params.format.as_str() {
164        "zip" => "zip",
165        _ => "tar.gz",
166    };
167    let ref_for_archive = archive_ref.clone();
168
169    let (stdout, success, stderr) = spawn_blocking(move || {
170        if uses_xz {
171            run_archive_xz(&repo_path_str, &ref_for_archive)
172        } else {
173            run_archive(&repo_path_str, git_format, &ref_for_archive)
174        }
175    })
176    .await
177    .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
178
179    if !success {
180        return Err(Error::NotFound(format!("archive failed: {stderr}")));
181    }
182
183    // Strip refs/heads/ or refs/tags/ prefix for a cleaner filename.
184    let short_ref = archive_ref
185        .strip_prefix("refs/heads/")
186        .or_else(|| archive_ref.strip_prefix("refs/tags/"))
187        .unwrap_or(&archive_ref);
188    let filename = format!("{name}-{short_ref}.{}", cfg.extension);
189    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
190        .map_err(|_| Error::BadRequest("invalid header value".into()))?;
191
192    let content_type = HeaderValue::from_static(cfg.content_type);
193
194    Ok((
195        [
196            (header::CONTENT_TYPE, content_type),
197            (header::CONTENT_DISPOSITION, disposition),
198        ],
199        stdout,
200    ))
201}