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