1
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#[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
36fn 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
62fn 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
82fn 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 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 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
153pub 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(¶ms.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 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 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}