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