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::process::Stdio;
9
10use axum::body::Body;
11use axum::extract::{Path, State};
12use axum::http::HeaderValue;
13use axum::http::header;
14use axum::response::IntoResponse;
15use serde::Deserialize;
16use tokio::io::AsyncRead;
17use tokio::process::{Child, Command};
18use tokio_util::io::ReaderStream;
19
20use crate::error::{Error, Result};
21use crate::git;
22use crate::routes::{AppState, RepoName};
23
24/// Path parameters for the archive route.
25#[derive(Deserialize)]
26pub(super) struct ArchivePath {
27    format: String,
28    #[serde(rename = "ref")]
29    ref_: String,
30}
31
32struct ArchiveConfig {
33    content_type: &'static str,
34    extension: &'static str,
35    git_format: &'static str,
36    uses_xz: bool,
37}
38
39/// Returns the archive configuration for a given format string.
40/// Returns `None` for unsupported formats.
41fn archive_config(format: &str) -> Option<ArchiveConfig> {
42    match format {
43        "tar.gz" | "tgz" => Some(ArchiveConfig {
44            content_type: "application/gzip",
45            extension: "tar.gz",
46            git_format: "tar.gz",
47            uses_xz: false,
48        }),
49        "tar.xz" | "txz" => Some(ArchiveConfig {
50            content_type: "application/x-xz",
51            extension: "tar.xz",
52            git_format: "tar",
53            uses_xz: true,
54        }),
55        "zip" => Some(ArchiveConfig {
56            content_type: "application/zip",
57            extension: "zip",
58            git_format: "zip",
59            uses_xz: false,
60        }),
61        _ => None,
62    }
63}
64
65/// Spawns `git archive` and streams its stdout directly into the response body.
66fn stream_archive(repo_path: &str, git_format: &str, ref_: &str) -> Result<Body> {
67    let mut child = Command::new("git")
68        .arg("-C")
69        .arg(repo_path)
70        .arg("archive")
71        .arg(format!("--format={git_format}"))
72        .arg(ref_)
73        .stdout(Stdio::piped())
74        .stderr(Stdio::piped())
75        .spawn()
76        .map_err(Error::Io)?;
77
78    let stdout = child.stdout.take().expect("stdout configured");
79    let stderr = child.stderr.take().expect("stderr configured");
80
81    tokio::spawn(reaper(child, stderr, "git archive"));
82
83    let stream = ReaderStream::new(stdout);
84    Ok(Body::from_stream(stream))
85}
86
87/// Pipes `git archive --format=tar` through `xz` and streams the compressed
88/// output.
89///
90/// Tokio's `ChildStdout` does not implement `Into<Stdio>`, so we cannot use
91/// the direct `.stdin(git_stdout)` approach that `std::process::Command`
92/// supports.  Instead we relay data between the two processes in a background
93/// task via `tokio::io::copy`.
94fn stream_archive_xz(repo_path: &str, ref_: &str) -> Result<Body> {
95    let mut git = Command::new("git")
96        .arg("-C")
97        .arg(repo_path)
98        .arg("archive")
99        .arg("--format=tar")
100        .arg(ref_)
101        .stdout(Stdio::piped())
102        .stderr(Stdio::piped())
103        .spawn()
104        .map_err(Error::Io)?;
105
106    let git_stdout = git.stdout.take().expect("stdout configured");
107    let git_stderr = git.stderr.take().expect("stderr configured");
108
109    let mut xz = Command::new("xz")
110        .arg("-c")
111        .stdin(Stdio::piped())
112        .stdout(Stdio::piped())
113        .stderr(Stdio::piped())
114        .spawn()
115        .map_err(Error::Io)?;
116
117    let mut xz_stdin = xz.stdin.take().expect("stdin configured");
118    let xz_stdout = xz.stdout.take().expect("stdout configured");
119    let xz_stderr = xz.stderr.take().expect("stderr configured");
120
121    // Relay git's stdout to xz's stdin in a background task.
122    tokio::spawn(async move {
123        use tokio::io::AsyncWriteExt;
124        if let Err(e) =
125            tokio::io::copy(&mut tokio::io::BufReader::new(git_stdout), &mut xz_stdin).await
126        {
127            eprintln!("pipe git->xz: {e}");
128        }
129        let _ = xz_stdin.shutdown().await;
130        // Reap git (its stdout is fully consumed now).
131        reaper(git, git_stderr, "git archive").await;
132    });
133
134    tokio::spawn(reaper(xz, xz_stderr, "xz compression"));
135
136    let stream = ReaderStream::new(xz_stdout);
137    Ok(Body::from_stream(stream))
138}
139
140/// Background task that reads stderr and waits for the child process to exit.
141/// Errors are printed to stderr — by this point we have already started
142/// streaming the response and cannot change the HTTP status.
143pub(crate) async fn reaper(
144    mut child: Child,
145    mut stderr: impl AsyncRead + Unpin,
146    label: &'static str,
147) {
148    use tokio::io::AsyncReadExt;
149    let mut buf = String::new();
150    let _ = stderr.read_to_string(&mut buf).await;
151    let ok = child.wait().await.is_ok_and(|s| s.success());
152    if !ok || !buf.is_empty() {
153        eprintln!("{label} failed: {buf}");
154    }
155}
156
157/// Streams a repository archive for the given ref in the requested format.
158pub async fn handler(
159    State(state): State<AppState>,
160    RepoName(name): RepoName,
161    Path(params): Path<ArchivePath>,
162) -> Result<impl IntoResponse> {
163    if params.ref_.is_empty() {
164        return Err(Error::BadRequest("empty ref".into()));
165    }
166
167    let cfg = archive_config(&params.format)
168        .ok_or_else(|| Error::BadRequest(format!("unsupported format: {}", params.format)))?;
169
170    let repo_path = state.root.join(&name);
171    if !repo_path.exists() {
172        return Err(Error::RepoNotFound(name.clone()));
173    }
174
175    // Verify the ref resolves before shelling out.
176    let git_repo = git::open_repo(&state.root, &name)?;
177    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {
178        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));
179    }
180
181    // Strip refs/heads/ or refs/tags/ prefix for a cleaner filename.
182    let short_ref = params
183        .ref_
184        .strip_prefix("refs/heads/")
185        .or_else(|| params.ref_.strip_prefix("refs/tags/"))
186        .unwrap_or(&params.ref_);
187    let filename = format!("{name}-{short_ref}.{}", cfg.extension);
188    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
189        .map_err(|_| Error::BadRequest("invalid header value".into()))?;
190
191    let content_type = HeaderValue::from_static(cfg.content_type);
192
193    let repo_path = repo_path.to_string_lossy().into_owned();
194    let archive_ref = params.ref_.clone();
195
196    let body = if cfg.uses_xz {
197        stream_archive_xz(&repo_path, &archive_ref)?
198    } else {
199        stream_archive(&repo_path, cfg.git_format, &archive_ref)?
200    };
201
202    Ok((
203        [
204            (header::CONTENT_TYPE, content_type),
205            (header::CONTENT_DISPOSITION, disposition),
206        ],
207        body,
208    ))
209}