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("--")
73        .arg(ref_)
74        .stdout(Stdio::piped())
75        .stderr(Stdio::piped())
76        .kill_on_drop(true)
77        .spawn()
78        .map_err(Error::Io)?;
79
80    let stdout = child.stdout.take().expect("stdout configured");
81    let stderr = child.stderr.take().expect("stderr configured");
82
83    tokio::spawn(reaper(child, stderr, "git archive"));
84
85    let stream = ReaderStream::new(stdout);
86    Ok(Body::from_stream(stream))
87}
88
89/// Pipes `git archive --format=tar` through `xz` and streams the compressed
90/// output.
91///
92/// Tokio's `ChildStdout` does not implement `Into<Stdio>`, so we cannot use
93/// the direct `.stdin(git_stdout)` approach that `std::process::Command`
94/// supports.  Instead we relay data between the two processes in a background
95/// task via `tokio::io::copy`.
96fn stream_archive_xz(repo_path: &str, ref_: &str) -> Result<Body> {
97    let mut git = Command::new("git")
98        .arg("-C")
99        .arg(repo_path)
100        .arg("archive")
101        .arg("--format=tar")
102        .arg("--")
103        .arg(ref_)
104        .stdout(Stdio::piped())
105        .stderr(Stdio::piped())
106        .kill_on_drop(true)
107        .spawn()
108        .map_err(Error::Io)?;
109
110    let git_stdout = git.stdout.take().expect("stdout configured");
111    let git_stderr = git.stderr.take().expect("stderr configured");
112
113    let mut xz = Command::new("xz")
114        .arg("-c")
115        .stdin(Stdio::piped())
116        .stdout(Stdio::piped())
117        .stderr(Stdio::piped())
118        .kill_on_drop(true)
119        .spawn()
120        .map_err(Error::Io)?;
121
122    let mut xz_stdin = xz.stdin.take().expect("stdin configured");
123    let xz_stdout = xz.stdout.take().expect("stdout configured");
124    let xz_stderr = xz.stderr.take().expect("stderr configured");
125
126    // Relay git's stdout to xz's stdin in a background task.
127    tokio::spawn(async move {
128        use tokio::io::AsyncWriteExt;
129        if let Err(e) =
130            tokio::io::copy(&mut tokio::io::BufReader::new(git_stdout), &mut xz_stdin).await
131        {
132            tracing::warn!(error = %e, "pipe git->xz");
133        }
134        let _ = xz_stdin.shutdown().await;
135        // Reap git (its stdout is fully consumed now).
136        reaper(git, git_stderr, "git archive").await;
137    });
138
139    tokio::spawn(reaper(xz, xz_stderr, "xz compression"));
140
141    let stream = ReaderStream::new(xz_stdout);
142    Ok(Body::from_stream(stream))
143}
144
145/// Background task that reads stderr and waits for the child process to exit.
146/// Errors are printed to stderr — by this point we have already started
147/// streaming the response and cannot change the HTTP status.
148pub(crate) async fn reaper(
149    mut child: Child,
150    mut stderr: impl AsyncRead + Unpin,
151    label: &'static str,
152) {
153    use tokio::io::AsyncReadExt;
154    let mut buf = String::new();
155    let _ = stderr.read_to_string(&mut buf).await;
156    let ok = child.wait().await.is_ok_and(|s| s.success());
157    if !ok || !buf.is_empty() {
158        tracing::warn!(label, stderr = %buf, "child process failed");
159    }
160}
161
162/// Replaces characters in `s` that are invalid in an HTTP quoted-string
163/// (`"` and `\`) with `-` so that the value can safely appear in a
164/// `Content-Disposition` filename parameter.
165fn sanitize_filename_component(s: &str) -> String {
166    s.replace(['"', '\\'], "-")
167}
168
169/// Streams a repository archive for the given ref in the requested format.
170pub async fn handler(
171    State(state): State<AppState>,
172    RepoName(name): RepoName,
173    Path(params): Path<ArchivePath>,
174) -> Result<impl IntoResponse> {
175    if params.ref_.is_empty() {
176        return Err(Error::BadRequest("empty ref".into()));
177    }
178
179    let cfg = archive_config(&params.format)
180        .ok_or_else(|| Error::BadRequest(format!("unsupported format: {}", params.format)))?;
181
182    let repo_path = state.root.join(&name);
183    if !repo_path.exists() {
184        return Err(Error::RepoNotFound(name.clone()));
185    }
186
187    // Verify the ref resolves before shelling out.
188    let git_repo = git::open_repo(&state.root, &name)?;
189    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {
190        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));
191    }
192
193    // Strip refs/heads/ or refs/tags/ prefix for a cleaner filename.
194    let short_ref = params
195        .ref_
196        .strip_prefix("refs/heads/")
197        .or_else(|| params.ref_.strip_prefix("refs/tags/"))
198        .unwrap_or(&params.ref_);
199
200    // Replace characters that are invalid in HTTP quoted-string values (`"` and `\`)
201    // so the Content-Disposition header is always well-formed.
202    let filename = format!(
203        "{}-{}.{}",
204        sanitize_filename_component(&name),
205        sanitize_filename_component(short_ref),
206        cfg.extension
207    );
208    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
209        .map_err(|_| Error::BadRequest("invalid filename in archive response".into()))?;
210
211    let content_type = HeaderValue::from_static(cfg.content_type);
212
213    let repo_path = repo_path.to_string_lossy().into_owned();
214    let archive_ref = params.ref_.clone();
215
216    let body = if cfg.uses_xz {
217        stream_archive_xz(&repo_path, &archive_ref)?
218    } else {
219        stream_archive(&repo_path, cfg.git_format, &archive_ref)?
220    };
221
222    Ok((
223        [
224            (header::CONTENT_TYPE, content_type),
225            (header::CONTENT_DISPOSITION, disposition),
226        ],
227        body,
228    ))
229}