1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use tokio::io::{AsyncWriteExt, BufWriter};
4use tracing::warn;
5
6const MAX_RETRIES: u32 = 3;
7const RETRY_SLEEP_SECS: u64 = 60;
8const TOTAL_TIMEOUT_SECS: u64 = 60 * 60;
9const CONNECT_TIMEOUT_SECS: u64 = 30;
10const DEFAULT_DOWNLOAD_BUFFER_SIZE: usize = 64 * 1024;
11const MAX_DOWNLOAD_BUFFER_SIZE: usize = 16 * 1024 * 1024;
12const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b];
13const TMP_PREFIX: &str = "fluidattacks_";
14const ARCHIVE_NAME: &str = "repo.tar.gz";
15
16enum Failure {
17 Transient(anyhow::Error),
18 Permanent(anyhow::Error),
19}
20
21fn retryable_status(status: reqwest::StatusCode) -> bool {
22 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
23}
24
25fn resolve_buffer_size(buffer_size: Option<usize>) -> Result<usize> {
26 match buffer_size {
27 None => Ok(DEFAULT_DOWNLOAD_BUFFER_SIZE),
28 Some(0) => bail!("the download buffer size must be greater than zero"),
29 Some(size) if size > MAX_DOWNLOAD_BUFFER_SIZE => bail!(
30 "the download buffer size must not exceed {MAX_DOWNLOAD_BUFFER_SIZE} bytes, got {size}"
31 ),
32 Some(size) => Ok(size),
33 }
34}
35
36pub async fn download_to(url: &str, path: &Path, buffer_size: Option<usize>) -> Result<()> {
45 let buffer_size = resolve_buffer_size(buffer_size)?;
46 let mut last_err = None;
47
48 for attempt in 0..MAX_RETRIES {
49 match do_download_to(url, path, buffer_size).await {
50 Ok(()) => return Ok(()),
51 Err(Failure::Permanent(e)) => return Err(e),
52 Err(Failure::Transient(e)) => {
53 warn!(
54 attempt = attempt.saturating_add(1),
55 max = MAX_RETRIES,
56 error = %e,
57 "download failed, retrying"
58 );
59 last_err = Some(e);
60 if attempt < MAX_RETRIES - 1 {
61 tokio::time::sleep(std::time::Duration::from_secs(RETRY_SLEEP_SECS)).await;
62 }
63 }
64 }
65 }
66
67 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed")))
68}
69
70async fn open_stream(url: &str) -> Result<reqwest::Response, Failure> {
71 let resp = reqwest::Client::builder()
72 .timeout(std::time::Duration::from_secs(TOTAL_TIMEOUT_SECS))
73 .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
74 .build()
75 .context("building the HTTP client")
76 .map_err(Failure::Permanent)?
77 .get(url)
78 .send()
79 .await
80 .context("HTTP request failed")
81 .map_err(Failure::Transient)?;
82
83 let status = resp.status();
84 if status == reqwest::StatusCode::OK {
85 return Ok(resp);
86 }
87
88 let error = anyhow::anyhow!("HTTP {status}");
89 Err(if retryable_status(status) {
90 Failure::Transient(error)
91 } else {
92 Failure::Permanent(error)
93 })
94}
95
96async fn do_download_to(url: &str, path: &Path, buffer_size: usize) -> Result<(), Failure> {
97 let mut resp = open_stream(url).await?;
98
99 let file = tokio::fs::File::create(path)
100 .await
101 .with_context(|| format!("creating {}", path.display()))
102 .map_err(Failure::Permanent)?;
103 let mut writer = BufWriter::with_capacity(buffer_size, file);
104 let mut header = Vec::with_capacity(GZIP_MAGIC.len());
105 let mut written: usize = 0;
106
107 while let Some(chunk) = resp
108 .chunk()
109 .await
110 .context("reading response body")
111 .map_err(Failure::Transient)?
112 {
113 take_header(&mut header, &chunk).map_err(Failure::Permanent)?;
114 for slice in chunk.chunks(buffer_size) {
115 writer
116 .write_all(slice)
117 .await
118 .context("writing the archive")
119 .map_err(Failure::Permanent)?;
120 }
121 written = written.saturating_add(chunk.len());
122 }
123 writer
124 .flush()
125 .await
126 .context("flushing the archive")
127 .map_err(Failure::Permanent)?;
128
129 if !header.starts_with(GZIP_MAGIC) {
130 return Err(Failure::Permanent(anyhow::anyhow!(
131 "response is not a valid gzip file (got {written} bytes)"
132 )));
133 }
134
135 Ok(())
136}
137
138fn take_header(header: &mut Vec<u8>, chunk: &[u8]) -> Result<()> {
139 let missing = GZIP_MAGIC.len().saturating_sub(header.len());
140 if missing == 0 {
141 return Ok(());
142 }
143
144 header.extend(chunk.iter().take(missing).copied());
145 if header.len() == GZIP_MAGIC.len() && !header.starts_with(GZIP_MAGIC) {
146 bail!("response is not a valid gzip file");
147 }
148
149 Ok(())
150}
151
152fn archive_parent(dest: &Path) -> Option<&Path> {
153 match dest.parent() {
154 Some(parent) if parent.as_os_str().is_empty() => Some(Path::new(".")),
155 parent => parent,
156 }
157}
158
159fn extracted_dir(tmp_path: &Path) -> Result<PathBuf> {
160 let mut dirs: Vec<PathBuf> = std::fs::read_dir(tmp_path)
161 .with_context(|| format!("reading {}", tmp_path.display()))?
162 .filter_map(std::result::Result::ok)
163 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
164 .map(|entry| entry.path())
165 .collect();
166 dirs.sort();
167
168 if dirs.len() > 1 {
169 warn!(
170 count = dirs.len(),
171 "multiple directories found in archive, using the first one"
172 );
173 }
174
175 dirs.into_iter()
176 .next()
177 .context("no directory found in the extracted archive")
178}
179
180fn move_or_copy(from: &Path, to: &Path) -> Result<()> {
181 if let Err(rename_error) = std::fs::rename(from, to) {
182 super::fs::copy_dir_all(from, to).with_context(|| {
183 format!(
184 "moving {} to {} failed with {rename_error}, and so did copying it",
185 from.display(),
186 to.display()
187 )
188 })?;
189 }
190 Ok(())
191}
192
193fn extract_and_replace(archive_path: &Path, tmp_path: &Path, dest: &Path) -> Result<()> {
194 let archive = std::fs::File::open(archive_path)
195 .with_context(|| format!("opening {}", archive_path.display()))?;
196 super::tar::extract(archive, tmp_path)?;
197 let _ = std::fs::remove_file(archive_path);
198
199 let extracted = extracted_dir(tmp_path)?;
200
201 if dest.exists() {
202 std::fs::remove_dir_all(dest).with_context(|| format!("removing {}", dest.display()))?;
203 }
204
205 move_or_copy(&extracted, dest)
206}
207
208async fn place_repo(url: &str, dest: &Path, buffer_size: Option<usize>) -> bool {
209 let Some(parent) = archive_parent(dest) else {
210 warn!("the destination has no parent directory");
211 return false;
212 };
213 if let Err(e) = std::fs::create_dir_all(parent) {
214 warn!(error = %e, "failed to create parent dir");
215 return false;
216 }
217
218 let tmp_dir = match tempfile::Builder::new().prefix(TMP_PREFIX).tempdir() {
219 Ok(d) => d,
220 Err(e) => {
221 warn!(error = %e, "failed to create temp dir");
222 return false;
223 }
224 };
225 let tmp_path = tmp_dir.path().to_path_buf();
226 let archive_path = tmp_path.join(ARCHIVE_NAME);
227
228 if let Err(e) = download_to(url, &archive_path, buffer_size).await {
229 warn!(error = %e, "failed to download");
230 return false;
231 }
232
233 let dest_path = dest.to_path_buf();
234 let extracted = tokio::task::spawn_blocking(move || {
235 extract_and_replace(&archive_path, &tmp_path, &dest_path)
236 })
237 .await
238 .unwrap_or_else(|e| Err(anyhow::Error::new(e)));
239
240 if let Err(e) = extracted {
241 warn!(error = %e, "failed to extract archive");
242 return false;
243 }
244
245 true
246}
247
248pub async fn download_and_extract_repo(
258 url: &str,
259 dest: &Path,
260 git_ignore: &[String],
261 buffer_size: Option<usize>,
262 checkout: bool,
263) -> bool {
264 if !place_repo(url, dest, buffer_size).await {
265 return false;
266 }
267
268 let _ = super::cli::set_safe_directory().await;
269 if !checkout {
270 return true;
271 }
272
273 if super::cli::reset_hard(dest).await.is_err() {
274 let _ = std::fs::remove_dir_all(dest);
275 return false;
276 }
277
278 let dest_path = dest.to_path_buf();
279 let patterns = git_ignore.to_vec();
280 let pruned = tokio::task::spawn_blocking(move || {
281 super::fs::remove_symlinks(&dest_path)?;
282 super::fs::delete_gitignored_files(&dest_path, &patterns)
283 })
284 .await;
285 match pruned {
286 Ok(Ok(())) => true,
287 Ok(Err(error)) => {
288 warn!(error = %error, "failed to prune the working tree");
289 false
290 }
291 Err(error) => {
292 warn!(error = %error, "failed to prune the working tree");
293 false
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::{
301 extract_and_replace, extracted_dir, resolve_buffer_size, retryable_status, take_header,
302 DEFAULT_DOWNLOAD_BUFFER_SIZE, GZIP_MAGIC, MAX_DOWNLOAD_BUFFER_SIZE,
303 };
304 use flate2::write::GzEncoder;
305 use flate2::Compression;
306 use std::path::Path;
307
308 fn write_tar_gz(path: &Path, name: &str, contents: &[u8]) {
309 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
310 let mut header = tar::Header::new_gnu();
311 header.set_size(u64::try_from(contents.len()).unwrap());
312 header.set_mode(0o644);
313 builder.append_data(&mut header, name, contents).unwrap();
314 let archive = builder.into_inner().unwrap().finish().unwrap();
315 std::fs::write(path, archive).unwrap();
316 }
317
318 #[test]
319 fn the_magic_bytes_can_arrive_split_across_chunks() {
320 let mut header = Vec::new();
321 assert!(take_header(&mut header, &[0x1f]).is_ok());
322 assert!(take_header(&mut header, &[0x8b, 0x08]).is_ok());
323 assert_eq!(header, GZIP_MAGIC);
324 }
325
326 #[test]
327 fn a_body_that_is_not_gzip_is_rejected_on_the_first_chunk() {
328 let mut header = Vec::new();
329 assert!(take_header(&mut header, b"<?xml version=\"1.0\"?>").is_err());
330 }
331
332 #[test]
333 fn later_chunks_are_not_inspected_once_the_magic_is_known() {
334 let mut header = Vec::new();
335 assert!(take_header(&mut header, GZIP_MAGIC).is_ok());
336 assert!(take_header(&mut header, b"anything at all").is_ok());
337 assert_eq!(header, GZIP_MAGIC);
338 }
339
340 #[test]
341 fn a_body_shorter_than_the_magic_never_completes_it() {
342 let mut header = Vec::new();
343 assert!(take_header(&mut header, &[0x1f]).is_ok());
344 assert!(!header.starts_with(GZIP_MAGIC));
345 }
346
347 #[test]
348 fn the_archive_replaces_an_existing_destination_and_is_deleted() {
349 let tmp = tempfile::tempdir().unwrap();
350 let work = tmp.path().join("work");
351 std::fs::create_dir_all(&work).unwrap();
352 let archive_path = work.join("repo.tar.gz");
353 write_tar_gz(&archive_path, "repo/HEAD", b"ref: refs/heads/trunk\n");
354
355 let dest = tmp.path().join("dest");
356 std::fs::create_dir_all(&dest).unwrap();
357 std::fs::write(dest.join("stale"), b"old").unwrap();
358
359 extract_and_replace(&archive_path, &work, &dest).unwrap();
360
361 assert_eq!(
362 std::fs::read_to_string(dest.join("HEAD")).unwrap(),
363 "ref: refs/heads/trunk\n"
364 );
365 assert!(!dest.join("stale").exists());
366 assert!(!archive_path.exists());
367 }
368
369 #[test]
370 fn an_archive_without_a_directory_is_rejected() {
371 let tmp = tempfile::tempdir().unwrap();
372 let work = tmp.path().join("work");
373 std::fs::create_dir_all(&work).unwrap();
374 let archive_path = work.join("repo.tar.gz");
375 write_tar_gz(&archive_path, "HEAD", b"ref: refs/heads/trunk\n");
376
377 let dest = tmp.path().join("dest");
378
379 assert!(extract_and_replace(&archive_path, &work, &dest).is_err());
380 assert!(!dest.exists());
381 }
382
383 #[test]
384 fn a_url_that_is_expired_or_wrong_is_not_retried() {
385 assert!(!retryable_status(reqwest::StatusCode::FORBIDDEN));
386 assert!(!retryable_status(reqwest::StatusCode::NOT_FOUND));
387 assert!(retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
388 assert!(retryable_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR));
389 assert!(retryable_status(reqwest::StatusCode::SERVICE_UNAVAILABLE));
390 }
391
392 #[test]
393 fn an_absent_buffer_size_takes_the_default_of_the_python() {
394 assert_eq!(
395 resolve_buffer_size(None).unwrap(),
396 DEFAULT_DOWNLOAD_BUFFER_SIZE
397 );
398 assert_eq!(resolve_buffer_size(Some(8 * 1024)).unwrap(), 8 * 1024);
399 }
400
401 #[test]
402 fn a_buffer_size_of_zero_is_rejected_instead_of_writing_an_empty_archive() {
403 assert!(resolve_buffer_size(Some(0)).is_err());
404 }
405
406 #[test]
407 fn a_buffer_size_above_the_maximum_is_rejected_instead_of_being_clamped() {
408 assert_eq!(
409 resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE)).unwrap(),
410 MAX_DOWNLOAD_BUFFER_SIZE
411 );
412 assert!(resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE + 1)).is_err());
413 }
414
415 #[test]
416 fn the_first_directory_is_taken_in_a_stable_order() {
417 let tmp = tempfile::tempdir().unwrap();
418 std::fs::write(tmp.path().join("repo.tar.gz"), b"").unwrap();
419 std::fs::create_dir_all(tmp.path().join("b_repo")).unwrap();
420 std::fs::create_dir_all(tmp.path().join("a_repo")).unwrap();
421
422 assert_eq!(
423 extracted_dir(tmp.path()).unwrap(),
424 tmp.path().join("a_repo")
425 );
426 }
427}