1
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#[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
39fn 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
65fn 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
89fn 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 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 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
145pub(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
162fn sanitize_filename_component(s: &str) -> String {
166 s.replace(['"', '\\'], "-")
167}
168
169pub 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(¶ms.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 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 let short_ref = params
195 .ref_
196 .strip_prefix("refs/heads/")
197 .or_else(|| params.ref_.strip_prefix("refs/tags/"))
198 .unwrap_or(¶ms.ref_);
199
200 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}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn config_tar_gz() {
237 let cfg = archive_config("tar.gz").unwrap();
238 assert_eq!(cfg.content_type, "application/gzip");
239 assert_eq!(cfg.extension, "tar.gz");
240 assert!(!cfg.uses_xz);
241 }
242
243 #[test]
244 fn config_tgz() {
245 let cfg = archive_config("tgz").unwrap();
246 assert_eq!(cfg.content_type, "application/gzip");
247 assert!(!cfg.uses_xz);
248 }
249
250 #[test]
251 fn config_tar_xz() {
252 let cfg = archive_config("tar.xz").unwrap();
253 assert_eq!(cfg.content_type, "application/x-xz");
254 assert!(cfg.uses_xz);
255 }
256
257 #[test]
258 fn config_txz() {
259 let cfg = archive_config("txz").unwrap();
260 assert_eq!(cfg.content_type, "application/x-xz");
261 assert!(cfg.uses_xz);
262 }
263
264 #[test]
265 fn config_zip() {
266 let cfg = archive_config("zip").unwrap();
267 assert_eq!(cfg.content_type, "application/zip");
268 assert!(!cfg.uses_xz);
269 }
270
271 #[test]
272 fn config_unsupported() {
273 assert!(archive_config("rar").is_none());
274 assert!(archive_config("tar").is_none());
275 assert!(archive_config("").is_none());
276 }
277
278 #[test]
279 fn sanitize_passes_clean() {
280 assert_eq!(sanitize_filename_component("hello"), "hello");
281 }
282
283 #[test]
284 fn sanitize_replaces_double_quote() {
285 assert_eq!(sanitize_filename_component(r#"foo"bar"#), "foo-bar");
286 }
287
288 #[test]
289 fn sanitize_replaces_backslash() {
290 assert_eq!(sanitize_filename_component(r"a\b"), "a-b");
291 }
292}