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";
15const GIT_DIR_DATA: [&str; 6] = ["HEAD", "index", "objects", "packed-refs", "refs", "shallow"];
16const OBJECT_STORE_POINTERS: [&str; 2] = ["alternates", "http-alternates"];
18const TRUSTED_CONFIG: &str = "[core]\nrepositoryformatversion = 0\nbare = false\n";
19
20enum Failure {
21 Transient(anyhow::Error),
22 Permanent(anyhow::Error),
23}
24
25fn retryable_status(status: reqwest::StatusCode) -> bool {
26 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
27}
28
29fn resolve_buffer_size(buffer_size: Option<usize>) -> Result<usize> {
30 match buffer_size {
31 None => Ok(DEFAULT_DOWNLOAD_BUFFER_SIZE),
32 Some(0) => bail!("the download buffer size must be greater than zero"),
33 Some(size) if size > MAX_DOWNLOAD_BUFFER_SIZE => bail!(
34 "the download buffer size must not exceed {MAX_DOWNLOAD_BUFFER_SIZE} bytes, got {size}"
35 ),
36 Some(size) => Ok(size),
37 }
38}
39
40pub async fn download_to(url: &str, path: &Path, buffer_size: Option<usize>) -> Result<()> {
49 let buffer_size = resolve_buffer_size(buffer_size)?;
50 let mut last_err = None;
51
52 for attempt in 0..MAX_RETRIES {
53 match do_download_to(url, path, buffer_size).await {
54 Ok(()) => return Ok(()),
55 Err(Failure::Permanent(e)) => return Err(e),
56 Err(Failure::Transient(e)) => {
57 warn!(
58 attempt = attempt.saturating_add(1),
59 max = MAX_RETRIES,
60 error = %e,
61 "download failed, retrying"
62 );
63 last_err = Some(e);
64 if attempt < MAX_RETRIES - 1 {
65 tokio::time::sleep(std::time::Duration::from_secs(RETRY_SLEEP_SECS)).await;
66 }
67 }
68 }
69 }
70
71 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed")))
72}
73
74async fn open_stream(url: &str) -> Result<reqwest::Response, Failure> {
75 let resp = reqwest::Client::builder()
76 .timeout(std::time::Duration::from_secs(TOTAL_TIMEOUT_SECS))
77 .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
78 .build()
79 .context("building the HTTP client")
80 .map_err(Failure::Permanent)?
81 .get(url)
82 .send()
83 .await
84 .context("HTTP request failed")
85 .map_err(Failure::Transient)?;
86
87 let status = resp.status();
88 if status == reqwest::StatusCode::OK {
89 return Ok(resp);
90 }
91
92 let error = anyhow::anyhow!("HTTP {status}");
93 Err(if retryable_status(status) {
94 Failure::Transient(error)
95 } else {
96 Failure::Permanent(error)
97 })
98}
99
100async fn do_download_to(url: &str, path: &Path, buffer_size: usize) -> Result<(), Failure> {
101 let mut resp = open_stream(url).await?;
102
103 let file = tokio::fs::File::create(path)
104 .await
105 .with_context(|| format!("creating {}", path.display()))
106 .map_err(Failure::Permanent)?;
107 let mut writer = BufWriter::with_capacity(buffer_size, file);
108 let mut header = Vec::with_capacity(GZIP_MAGIC.len());
109 let mut written: usize = 0;
110
111 while let Some(chunk) = resp
112 .chunk()
113 .await
114 .context("reading response body")
115 .map_err(Failure::Transient)?
116 {
117 take_header(&mut header, &chunk).map_err(Failure::Permanent)?;
118 for slice in chunk.chunks(buffer_size) {
119 writer
120 .write_all(slice)
121 .await
122 .context("writing the archive")
123 .map_err(Failure::Permanent)?;
124 }
125 written = written.saturating_add(chunk.len());
126 }
127 writer
128 .flush()
129 .await
130 .context("flushing the archive")
131 .map_err(Failure::Permanent)?;
132
133 if !header.starts_with(GZIP_MAGIC) {
134 return Err(Failure::Permanent(anyhow::anyhow!(
135 "response is not a valid gzip file (got {written} bytes)"
136 )));
137 }
138
139 Ok(())
140}
141
142fn take_header(header: &mut Vec<u8>, chunk: &[u8]) -> Result<()> {
143 let missing = GZIP_MAGIC.len().saturating_sub(header.len());
144 if missing == 0 {
145 return Ok(());
146 }
147
148 header.extend(chunk.iter().take(missing).copied());
149 if header.len() == GZIP_MAGIC.len() && !header.starts_with(GZIP_MAGIC) {
150 bail!("response is not a valid gzip file");
151 }
152
153 Ok(())
154}
155
156fn archive_parent(dest: &Path) -> Option<&Path> {
157 match dest.parent() {
158 Some(parent) if parent.as_os_str().is_empty() => Some(Path::new(".")),
159 parent => parent,
160 }
161}
162
163fn extracted_dir(tmp_path: &Path) -> Result<PathBuf> {
164 let mut dirs: Vec<PathBuf> = std::fs::read_dir(tmp_path)
165 .with_context(|| format!("reading {}", tmp_path.display()))?
166 .filter_map(std::result::Result::ok)
167 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
168 .map(|entry| entry.path())
169 .collect();
170 dirs.sort();
171
172 if dirs.len() > 1 {
173 warn!(
174 count = dirs.len(),
175 "multiple directories found in archive, using the first one"
176 );
177 }
178
179 dirs.into_iter()
180 .next()
181 .context("no directory found in the extracted archive")
182}
183
184fn move_or_copy(from: &Path, to: &Path) -> Result<()> {
185 if let Err(rename_error) = std::fs::rename(from, to) {
186 super::fs::copy_dir_all(from, to).with_context(|| {
187 format!(
188 "moving {} to {} failed with {rename_error}, and so did copying it",
189 from.display(),
190 to.display()
191 )
192 })?;
193 }
194 Ok(())
195}
196
197fn keep_only_history(repo: &Path) -> Result<()> {
199 let git_dir = repo.join(".git");
200 let is_dir = std::fs::symlink_metadata(&git_dir).is_ok_and(|meta| meta.is_dir());
201 if !is_dir {
202 bail!("no .git directory found in the extracted archive");
203 }
204
205 for entry in
206 std::fs::read_dir(&git_dir).with_context(|| format!("reading {}", git_dir.display()))?
207 {
208 let entry = entry.with_context(|| format!("reading {}", git_dir.display()))?;
209 if GIT_DIR_DATA.iter().any(|kept| entry.file_name() == *kept) {
210 continue;
211 }
212 let path = entry.path();
213 let removed = if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
214 std::fs::remove_dir_all(&path)
215 } else {
216 std::fs::remove_file(&path)
217 };
218 removed.with_context(|| format!("removing {}", path.display()))?;
219 }
220 for pointer in OBJECT_STORE_POINTERS {
221 let path = git_dir.join("objects/info").join(pointer);
222 match std::fs::remove_file(&path) {
223 Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
224 return Err(error).with_context(|| format!("removing {}", path.display()));
225 }
226 _ => {}
227 }
228 }
229
230 let config = git_dir.join("config");
231 std::fs::write(&config, TRUSTED_CONFIG).with_context(|| format!("writing {}", config.display()))
232}
233
234async fn extract_and_replace(archive_path: &Path, tmp_path: &Path, dest: &Path) -> Result<()> {
235 let archive = tokio::fs::File::open(archive_path)
236 .await
237 .with_context(|| format!("opening {}", archive_path.display()))?;
238 super::tar::extract(archive, tmp_path).await?;
239 let _ = tokio::fs::remove_file(archive_path).await;
240
241 let (tmp_path, dest) = (tmp_path.to_path_buf(), dest.to_path_buf());
242 tokio::task::spawn_blocking(move || replace_destination(&tmp_path, &dest)).await?
243}
244
245fn replace_destination(tmp_path: &Path, dest: &Path) -> Result<()> {
246 let extracted = extracted_dir(tmp_path)?;
247 keep_only_history(&extracted)?;
249
250 if dest.exists() {
251 std::fs::remove_dir_all(dest).with_context(|| format!("removing {}", dest.display()))?;
252 }
253
254 move_or_copy(&extracted, dest)
255}
256
257async fn place_repo(url: &str, dest: &Path, buffer_size: Option<usize>) -> bool {
258 let Some(parent) = archive_parent(dest) else {
259 warn!("the destination has no parent directory");
260 return false;
261 };
262 if let Err(e) = std::fs::create_dir_all(parent) {
263 warn!(error = %e, "failed to create parent dir");
264 return false;
265 }
266
267 let tmp_dir = match tempfile::Builder::new().prefix(TMP_PREFIX).tempdir() {
268 Ok(d) => d,
269 Err(e) => {
270 warn!(error = %e, "failed to create temp dir");
271 return false;
272 }
273 };
274 let tmp_path = tmp_dir.path().to_path_buf();
275 let archive_path = tmp_path.join(ARCHIVE_NAME);
276
277 if let Err(e) = download_to(url, &archive_path, buffer_size).await {
278 warn!(error = %e, "failed to download");
279 return false;
280 }
281
282 if let Err(e) = extract_and_replace(&archive_path, &tmp_path, dest).await {
283 warn!(error = %e, "failed to extract archive");
284 return false;
285 }
286
287 true
288}
289
290pub async fn download_and_extract_repo(
301 url: &str,
302 dest: &Path,
303 git_ignore: &[String],
304 buffer_size: Option<usize>,
305 checkout: bool,
306) -> bool {
307 if !place_repo(url, dest, buffer_size).await {
308 return false;
309 }
310
311 if !checkout {
312 return true;
313 }
314
315 if super::cli::reset_hard(dest).await.is_err() {
316 let _ = std::fs::remove_dir_all(dest);
317 return false;
318 }
319
320 let dest_path = dest.to_path_buf();
321 let patterns = git_ignore.to_vec();
322 let pruned = tokio::task::spawn_blocking(move || {
323 super::fs::remove_symlinks(&dest_path)?;
324 super::fs::delete_gitignored_files(&dest_path, &patterns)
325 })
326 .await;
327 match pruned {
328 Ok(Ok(())) => true,
329 Ok(Err(error)) => {
330 warn!(error = %error, "failed to prune the working tree");
331 false
332 }
333 Err(error) => {
334 warn!(error = %error, "failed to prune the working tree");
335 false
336 }
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::{
343 extract_and_replace, extracted_dir, resolve_buffer_size, retryable_status, take_header,
344 DEFAULT_DOWNLOAD_BUFFER_SIZE, GZIP_MAGIC, MAX_DOWNLOAD_BUFFER_SIZE, TRUSTED_CONFIG,
345 };
346 use flate2::write::GzEncoder;
347 use flate2::Compression;
348 use std::path::{Path, PathBuf};
349
350 fn write_tar_gz(path: &Path, name: &str, contents: &[u8]) {
351 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
352 let mut header = tar::Header::new_gnu();
353 header.set_size(u64::try_from(contents.len()).unwrap());
354 header.set_mode(0o644);
355 builder.append_data(&mut header, name, contents).unwrap();
356 let archive = builder.into_inner().unwrap().finish().unwrap();
357 std::fs::write(path, archive).unwrap();
358 }
359
360 #[test]
361 fn the_magic_bytes_can_arrive_split_across_chunks() {
362 let mut header = Vec::new();
363 assert!(take_header(&mut header, &[0x1f]).is_ok());
364 assert!(take_header(&mut header, &[0x8b, 0x08]).is_ok());
365 assert_eq!(header, GZIP_MAGIC);
366 }
367
368 #[test]
369 fn a_body_that_is_not_gzip_is_rejected_on_the_first_chunk() {
370 let mut header = Vec::new();
371 assert!(take_header(&mut header, b"<?xml version=\"1.0\"?>").is_err());
372 }
373
374 #[test]
375 fn later_chunks_are_not_inspected_once_the_magic_is_known() {
376 let mut header = Vec::new();
377 assert!(take_header(&mut header, GZIP_MAGIC).is_ok());
378 assert!(take_header(&mut header, b"anything at all").is_ok());
379 assert_eq!(header, GZIP_MAGIC);
380 }
381
382 #[test]
383 fn a_body_shorter_than_the_magic_never_completes_it() {
384 let mut header = Vec::new();
385 assert!(take_header(&mut header, &[0x1f]).is_ok());
386 assert!(!header.starts_with(GZIP_MAGIC));
387 }
388
389 #[tokio::test]
390 async fn the_archive_replaces_an_existing_destination_and_is_deleted() {
391 let tmp = tempfile::tempdir().unwrap();
392 let work = tmp.path().join("work");
393 std::fs::create_dir_all(&work).unwrap();
394 let archive_path = work.join("repo.tar.gz");
395 write_tar_gz(&archive_path, "repo/.git/HEAD", b"ref: refs/heads/trunk\n");
396
397 let dest = tmp.path().join("dest");
398 std::fs::create_dir_all(&dest).unwrap();
399 std::fs::write(dest.join("stale"), b"old").unwrap();
400
401 extract_and_replace(&archive_path, &work, &dest)
402 .await
403 .unwrap();
404
405 assert_eq!(
406 std::fs::read_to_string(dest.join(".git/HEAD")).unwrap(),
407 "ref: refs/heads/trunk\n"
408 );
409 assert!(!dest.join("stale").exists());
410 assert!(!archive_path.exists());
411 }
412
413 #[tokio::test]
414 async fn an_archive_without_a_directory_is_rejected() {
415 let tmp = tempfile::tempdir().unwrap();
416 let work = tmp.path().join("work");
417 std::fs::create_dir_all(&work).unwrap();
418 let archive_path = work.join("repo.tar.gz");
419 write_tar_gz(&archive_path, "HEAD", b"ref: refs/heads/trunk\n");
420
421 let dest = tmp.path().join("dest");
422
423 assert!(extract_and_replace(&archive_path, &work, &dest)
424 .await
425 .is_err());
426 assert!(!dest.exists());
427 }
428
429 #[test]
430 fn a_url_that_is_expired_or_wrong_is_not_retried() {
431 assert!(!retryable_status(reqwest::StatusCode::FORBIDDEN));
432 assert!(!retryable_status(reqwest::StatusCode::NOT_FOUND));
433 assert!(retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
434 assert!(retryable_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR));
435 assert!(retryable_status(reqwest::StatusCode::SERVICE_UNAVAILABLE));
436 }
437
438 #[test]
439 fn an_absent_buffer_size_takes_the_default_of_the_python() {
440 assert_eq!(
441 resolve_buffer_size(None).unwrap(),
442 DEFAULT_DOWNLOAD_BUFFER_SIZE
443 );
444 assert_eq!(resolve_buffer_size(Some(8 * 1024)).unwrap(), 8 * 1024);
445 }
446
447 #[test]
448 fn a_buffer_size_of_zero_is_rejected_instead_of_writing_an_empty_archive() {
449 assert!(resolve_buffer_size(Some(0)).is_err());
450 }
451
452 #[test]
453 fn a_buffer_size_above_the_maximum_is_rejected_instead_of_being_clamped() {
454 assert_eq!(
455 resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE)).unwrap(),
456 MAX_DOWNLOAD_BUFFER_SIZE
457 );
458 assert!(resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE + 1)).is_err());
459 }
460
461 fn git(repo: &Path, args: &[&str]) {
462 let status = std::process::Command::new("git")
463 .arg("-C")
464 .arg(repo)
465 .args([
466 "-c",
467 "user.email=dev@fluidattacks.com",
468 "-c",
469 "user.name=dev",
470 ])
471 .args(args)
472 .status()
473 .unwrap();
474 assert!(status.success());
475 }
476
477 fn hostile_archive(dir: &Path, key: &str) -> (PathBuf, PathBuf) {
478 let marker = dir.join("pwned");
479 let payload = dir.join("payload.sh");
480 std::fs::write(
481 &payload,
482 format!("#!/bin/sh\ntouch {}\ncat\n", marker.display()),
483 )
484 .unwrap();
485 #[cfg(unix)]
486 {
487 use std::os::unix::fs::PermissionsExt;
488 std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
489 }
490
491 let source = dir.join("source");
492 std::fs::create_dir_all(&source).unwrap();
493 git(&source, &["init", "-q"]);
494 std::fs::write(source.join(".gitattributes"), b"* filter=pwn\n").unwrap();
495 std::fs::write(source.join("tracked.txt"), b"content\n").unwrap();
496 git(&source, &["add", "-A"]);
497 git(&source, &["commit", "-q", "-m", "add tracked file"]);
498 git(&source, &["config", key, &payload.to_string_lossy()]);
499 std::fs::create_dir_all(source.join(".git/objects/info")).unwrap();
500 std::fs::write(
501 source.join(".git/objects/info/alternates"),
502 format!("{}\n", dir.display()),
503 )
504 .unwrap();
505
506 let archive = dir.join("work/repo.tar.gz");
507 std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
508 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
509 builder
510 .append_dir_all("source/.git", source.join(".git"))
511 .unwrap();
512 std::fs::write(&archive, builder.into_inner().unwrap().finish().unwrap()).unwrap();
513 std::fs::remove_dir_all(&source).unwrap();
514 (archive, marker)
515 }
516
517 #[tokio::test]
518 async fn a_restored_archive_runs_nothing_it_configures() {
519 for key in ["filter.pwn.smudge", "filter.pwn.process", "core.fsmonitor"] {
520 let tmp = tempfile::tempdir().unwrap();
521 let (archive, marker) = hostile_archive(tmp.path(), key);
522 let dest = tmp.path().join("dest");
523
524 extract_and_replace(&archive, archive.parent().unwrap(), &dest)
525 .await
526 .unwrap();
527 crate::cli::reset_hard(&dest).await.unwrap();
528
529 assert!(!marker.exists(), "{key} ran");
530 assert_eq!(
531 std::fs::read_to_string(dest.join("tracked.txt")).unwrap(),
532 "content\n"
533 );
534 }
535 }
536
537 #[tokio::test]
538 async fn only_the_history_of_the_archive_is_kept() {
539 let tmp = tempfile::tempdir().unwrap();
540 let (archive, _) = hostile_archive(tmp.path(), "core.fsmonitor");
541 let dest = tmp.path().join("dest");
542
543 extract_and_replace(&archive, archive.parent().unwrap(), &dest)
544 .await
545 .unwrap();
546
547 let git_dir = dest.join(".git");
548 assert_eq!(
549 std::fs::read_to_string(git_dir.join("config")).unwrap(),
550 TRUSTED_CONFIG
551 );
552 assert!(!git_dir.join("hooks").exists());
553 assert!(!git_dir.join("objects/info/alternates").exists());
554 assert!(git_dir.join("HEAD").is_file());
555 }
556
557 #[tokio::test]
558 async fn a_git_file_in_the_archive_is_rejected() {
559 let tmp = tempfile::tempdir().unwrap();
560 let work = tmp.path().join("work");
561 std::fs::create_dir_all(&work).unwrap();
562 let archive_path = work.join("repo.tar.gz");
563 write_tar_gz(&archive_path, "repo/.git", b"gitdir: /elsewhere/.git\n");
564 let dest = tmp.path().join("dest");
565
566 assert!(extract_and_replace(&archive_path, &work, &dest)
567 .await
568 .is_err());
569 assert!(!dest.exists());
570 }
571
572 #[test]
573 fn the_first_directory_is_taken_in_a_stable_order() {
574 let tmp = tempfile::tempdir().unwrap();
575 std::fs::write(tmp.path().join("repo.tar.gz"), b"").unwrap();
576 std::fs::create_dir_all(tmp.path().join("b_repo")).unwrap();
577 std::fs::create_dir_all(tmp.path().join("a_repo")).unwrap();
578
579 assert_eq!(
580 extracted_dir(tmp.path()).unwrap(),
581 tmp.path().join("a_repo")
582 );
583 }
584}