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::git;
20use crate::routes::{AppState, RepoName};
21
22#[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
37fn 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
63fn 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
83fn 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 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 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
154pub 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(¶ms.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 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 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}