1use rustix::fs::{renameat_with, RenameFlags, CWD};
2use std::io;
3use std::num::NonZeroUsize;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use tokio::io::{AsyncWriteExt, BufWriter};
7use tracing::warn;
8
9use crate::errors::{DownloadError, ExtractError, FetchError};
10
11const TIMEOUTS: Timeouts = Timeouts {
12 connect: Duration::from_secs(30),
13 total: Duration::from_hours(1),
14};
15const DEFAULT_DOWNLOAD_BUFFER_SIZE: usize = 64 * 1024;
16const MAX_DOWNLOAD_BUFFER_SIZE: usize = 16 * 1024 * 1024;
17const WORK_PREFIX: &str = ".fluidattacks_";
18const ARCHIVE_NAME: &str = "repo.tar.gz";
19const EXTRACTED_NAME: &str = "extracted";
20const GIT_DIR_DATA: [&str; 6] = ["HEAD", "index", "objects", "packed-refs", "refs", "shallow"];
21const OBJECT_STORE_POINTERS: [&str; 2] = ["alternates", "http-alternates"];
23const TRUSTED_CONFIG: &str = "[core]\nrepositoryformatversion = 0\nbare = false\n";
24
25struct Timeouts {
26 connect: Duration,
27 total: Duration,
28}
29
30fn resolve_buffer_size(buffer_size: Option<NonZeroUsize>) -> usize {
31 buffer_size.map_or(DEFAULT_DOWNLOAD_BUFFER_SIZE, |size| {
32 size.get().min(MAX_DOWNLOAD_BUFFER_SIZE)
33 })
34}
35
36fn parse_url(url: &str) -> Result<url::Url, FetchError> {
37 let parsed = url::Url::parse(url).map_err(|error| FetchError::InvalidUrl {
38 url: url.to_owned(),
39 reason: error.to_string(),
40 })?;
41 if !matches!(parsed.scheme(), "http" | "https") {
42 return Err(FetchError::NonHttpUrl(url.to_owned()));
43 }
44 Ok(parsed)
45}
46
47fn request_error(error: reqwest::Error) -> FetchError {
48 if error.is_connect() {
49 return if error.is_timeout() {
50 FetchError::ConnectTimeout(error)
51 } else {
52 FetchError::Connect(error)
53 };
54 }
55 if error.is_timeout() {
56 return FetchError::Timeout(error);
57 }
58 let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error);
59 let mut socket = None;
60 while let Some(current) = cause {
61 if let Some(hyper) = current.downcast_ref::<hyper::Error>() {
62 if hyper.is_incomplete_message() {
63 return FetchError::Disconnected(error);
64 }
65 if hyper.is_parse() {
66 return FetchError::Malformed(error);
67 }
68 }
69 if let Some(io) = current.downcast_ref::<io::Error>() {
70 socket = Some(io);
71 }
72 cause = current.source();
73 }
74 match socket {
75 Some(io) => FetchError::Socket {
76 reset: io.kind() == io::ErrorKind::ConnectionReset,
77 errno: io.raw_os_error(),
78 source: error,
79 },
80 None => FetchError::Other(error),
81 }
82}
83
84fn body_error(error: reqwest::Error) -> FetchError {
85 if error.is_timeout() {
86 FetchError::BodyTimeout(error)
87 } else {
88 FetchError::BodyCut(error)
89 }
90}
91
92async fn fetch_to(
93 url: &str,
94 path: &Path,
95 buffer_size: usize,
96 timeouts: Timeouts,
97) -> Result<(), DownloadError> {
98 let url = parse_url(url)?;
99 let client = reqwest::Client::builder()
101 .no_proxy()
102 .timeout(timeouts.total)
103 .connect_timeout(timeouts.connect)
104 .build()
105 .map_err(FetchError::Other)?;
106 let mut response = client.get(url).send().await.map_err(request_error)?;
107 let status = response.status();
108 if status != reqwest::StatusCode::OK {
109 return Err(FetchError::Status(status).into());
110 }
111
112 let file = tokio::fs::File::create(path)
113 .await
114 .map_err(DownloadError::Write)?;
115 let mut writer = BufWriter::with_capacity(buffer_size, file);
116 while let Some(chunk) = response.chunk().await.map_err(body_error)? {
117 for slice in chunk.chunks(buffer_size) {
118 writer
119 .write_all(slice)
120 .await
121 .map_err(DownloadError::Write)?;
122 }
123 }
124 writer.flush().await.map_err(DownloadError::Write)?;
125 Ok(())
126}
127
128async fn extracted_dir(extracted: &Path) -> Result<PathBuf, ExtractError> {
129 let mut entries = tokio::fs::read_dir(extracted)
130 .await
131 .map_err(ExtractError::Io)?;
132 let mut dirs = Vec::new();
133 while let Some(entry) = entries.next_entry().await.map_err(ExtractError::Io)? {
134 if entry.file_type().await.is_ok_and(|kind| kind.is_dir()) {
135 dirs.push(entry.path());
136 }
137 }
138 dirs.sort();
140
141 if dirs.len() > 1 {
142 warn!(
143 count = dirs.len(),
144 "multiple directories found in archive, using the first one"
145 );
146 }
147 dirs.into_iter().next().ok_or_else(|| {
148 ExtractError::NoRepository("no directory found in the extracted archive".to_owned())
149 })
150}
151
152async fn keep_only_history(repo: &Path) -> Result<(), ExtractError> {
154 let git_dir = repo.join(".git");
155 let is_dir = tokio::fs::symlink_metadata(&git_dir)
156 .await
157 .is_ok_and(|meta| meta.is_dir());
158 if !is_dir {
159 return Err(ExtractError::NoRepository(
160 "no .git directory found in the extracted archive".to_owned(),
161 ));
162 }
163
164 let mut entries = tokio::fs::read_dir(&git_dir)
165 .await
166 .map_err(ExtractError::Io)?;
167 while let Some(entry) = entries.next_entry().await.map_err(ExtractError::Io)? {
168 if GIT_DIR_DATA.iter().any(|kept| entry.file_name() == *kept) {
169 continue;
170 }
171 let path = entry.path();
172 let removed = if entry.file_type().await.is_ok_and(|kind| kind.is_dir()) {
173 tokio::fs::remove_dir_all(&path).await
174 } else {
175 tokio::fs::remove_file(&path).await
176 };
177 removed.map_err(ExtractError::Io)?;
178 }
179 for pointer in OBJECT_STORE_POINTERS {
180 match tokio::fs::remove_file(git_dir.join("objects/info").join(pointer)).await {
181 Err(error) if error.kind() != io::ErrorKind::NotFound => {
182 return Err(ExtractError::Io(error));
183 }
184 _ => {}
185 }
186 }
187
188 tokio::fs::write(git_dir.join("config"), TRUSTED_CONFIG)
189 .await
190 .map_err(ExtractError::Io)
191}
192
193fn prune(repo: &Path, git_ignore: &[String]) -> Result<(), DownloadError> {
194 super::fs::remove_symlinks(repo)
195 .and_then(|()| super::fs::delete_gitignored_files(repo, git_ignore))
196 .map_err(|error| DownloadError::Prune {
197 message: format!("{error:#}"),
198 errno: error
199 .chain()
200 .find_map(|cause| cause.downcast_ref::<io::Error>()?.raw_os_error()),
201 })
202}
203
204fn place(repo: &Path, dest: &Path) -> Result<(), DownloadError> {
207 match std::fs::symlink_metadata(dest) {
208 Ok(meta) if meta.is_dir() => renameat_with(CWD, repo, CWD, dest, RenameFlags::EXCHANGE)
209 .map_err(|errno| DownloadError::Place(errno.into())),
210 Ok(_) => Err(DownloadError::Place(io::Error::from(
212 io::ErrorKind::NotADirectory,
213 ))),
214 Err(error) if error.kind() == io::ErrorKind::NotFound => {
215 std::fs::rename(repo, dest).map_err(DownloadError::Place)
216 }
217 Err(error) => Err(DownloadError::Place(error)),
218 }
219}
220
221struct Request<'a> {
223 url: &'a str,
224 git_ignore: &'a [String],
225 buffer_size: usize,
226 checkout: bool,
227 timeouts: Timeouts,
228}
229
230async fn build_repo(request: Request<'_>, work: &Path) -> Result<PathBuf, DownloadError> {
231 let archive = work.join(ARCHIVE_NAME);
232 fetch_to(request.url, &archive, request.buffer_size, request.timeouts).await?;
233
234 let extracted = work.join(EXTRACTED_NAME);
235 tokio::fs::create_dir(&extracted)
236 .await
237 .map_err(ExtractError::Io)?;
238 let file = tokio::fs::File::open(&archive)
239 .await
240 .map_err(ExtractError::Io)?;
241 super::tar::extract(file, &extracted).await?;
242 let _ = tokio::fs::remove_file(&archive).await;
243
244 let repo = extracted_dir(&extracted).await?;
245 keep_only_history(&repo).await?;
246 if request.checkout {
247 super::cli::reset_hard(&repo).await?;
248 prune(&repo, request.git_ignore)?;
249 }
250 Ok(repo)
251}
252
253async fn download_with(request: Request<'_>, dest: &Path) -> Result<(), DownloadError> {
254 let dest = &std::path::absolute(dest).map_err(DownloadError::Write)?;
255 let parent = dest.parent().ok_or_else(|| {
256 DownloadError::Write(io::Error::new(
257 io::ErrorKind::InvalidInput,
258 "the destination has no parent directory",
259 ))
260 })?;
261 tokio::fs::create_dir_all(parent)
262 .await
263 .map_err(DownloadError::Write)?;
264 let work = tempfile::Builder::new()
267 .prefix(WORK_PREFIX)
268 .tempdir_in(parent)
269 .map_err(DownloadError::Place)?;
270
271 let repo = build_repo(request, work.path()).await?;
272 place(&repo, dest)
273}
274
275pub async fn download_and_extract_repo(
291 url: &str,
292 dest: &Path,
293 git_ignore: &[String],
294 buffer_size: Option<NonZeroUsize>,
295 checkout: bool,
296) -> Result<(), DownloadError> {
297 let request = Request {
298 url,
299 git_ignore,
300 buffer_size: resolve_buffer_size(buffer_size),
301 checkout,
302 timeouts: TIMEOUTS,
303 };
304 download_with(request, dest).await
305}
306
307#[cfg(test)]
308mod tests {
309 use super::{
310 download_and_extract_repo, extracted_dir, fetch_to, place, resolve_buffer_size, Timeouts,
311 DEFAULT_DOWNLOAD_BUFFER_SIZE, MAX_DOWNLOAD_BUFFER_SIZE, TRUSTED_CONFIG,
312 };
313 use crate::errors::{DownloadError, ExtractError, FetchError};
314 use flate2::write::GzEncoder;
315 use flate2::Compression;
316 use std::num::NonZeroUsize;
317 use std::path::{Path, PathBuf};
318 use std::time::Duration;
319 use tokio::io::{AsyncReadExt, AsyncWriteExt};
320 use tokio::net::TcpListener;
321
322 async fn serve(archive: Vec<u8>) -> String {
324 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
325 let address = listener.local_addr().unwrap();
326 tokio::spawn(async move {
327 while let Ok((mut stream, _)) = listener.accept().await {
328 let mut request = [0u8; 1024];
329 let _ = stream.read(&mut request).await;
330 let head = format!(
331 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
332 archive.len()
333 );
334 let _ = stream.write_all(head.as_bytes()).await;
335 let _ = stream.write_all(&archive).await;
336 }
337 });
338 format!("http://{address}/repo.tar.gz")
339 }
340
341 async fn download(archive: Vec<u8>, dest: &Path, checkout: bool) -> Result<(), DownloadError> {
342 let url = serve(archive).await;
343 download_and_extract_repo(&url, dest, &[], None, checkout).await
344 }
345
346 fn tar_gz(name: &str, contents: &[u8]) -> Vec<u8> {
347 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
348 let mut header = tar::Header::new_gnu();
349 header.set_size(u64::try_from(contents.len()).unwrap());
350 header.set_mode(0o644);
351 builder.append_data(&mut header, name, contents).unwrap();
352 builder.into_inner().unwrap().finish().unwrap()
353 }
354
355 #[tokio::test]
356 async fn the_archive_replaces_an_existing_destination_and_leaves_nothing_beside_it() {
357 let tmp = tempfile::tempdir().unwrap();
358 let dest = tmp.path().join("dest");
359 std::fs::create_dir_all(&dest).unwrap();
360 std::fs::write(dest.join("stale"), b"old").unwrap();
361
362 download(
363 tar_gz("repo/.git/HEAD", b"ref: refs/heads/trunk\n"),
364 &dest,
365 false,
366 )
367 .await
368 .unwrap();
369
370 assert_eq!(
371 std::fs::read_to_string(dest.join(".git/HEAD")).unwrap(),
372 "ref: refs/heads/trunk\n"
373 );
374 assert!(!dest.join("stale").exists());
375 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
376 }
377
378 #[tokio::test]
379 async fn an_archive_without_a_directory_is_rejected() {
380 let tmp = tempfile::tempdir().unwrap();
381 let dest = tmp.path().join("dest");
382
383 let outcome = download(tar_gz("HEAD", b"ref: refs/heads/trunk\n"), &dest, false).await;
384
385 assert!(
386 matches!(
387 outcome,
388 Err(DownloadError::Extract(ExtractError::NoRepository(_)))
389 ),
390 "{outcome:?}"
391 );
392 assert!(!dest.exists());
393 }
394
395 const SHORT: Duration = Duration::from_secs(1);
396 const LONG: Duration = Duration::from_secs(30);
397
398 async fn answer_and_stall(mut stream: tokio::net::TcpStream, raw: &'static [u8]) {
399 let mut request = [0u8; 1024];
400 let _ = stream.read(&mut request).await;
401 let _ = stream.write_all(raw).await;
402 while stream.read(&mut request).await.is_ok_and(|read| read > 0) {}
403 }
404
405 async fn serve_and_stall(raw: &'static [u8]) -> String {
408 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
409 let address = listener.local_addr().unwrap();
410 tokio::spawn(async move {
411 while let Ok((stream, _)) = listener.accept().await {
412 tokio::spawn(answer_and_stall(stream, raw));
413 }
414 });
415 format!("http://{address}/repo.tar.gz")
416 }
417
418 async fn fetch_with(url: &str, timeouts: Timeouts) -> Result<(), DownloadError> {
419 let dir = tempfile::tempdir().unwrap();
420 fetch_to(
421 url,
422 &dir.path().join("repo.tar.gz"),
423 DEFAULT_DOWNLOAD_BUFFER_SIZE,
424 timeouts,
425 )
426 .await
427 }
428
429 #[tokio::test]
430 async fn a_connect_that_never_completes_is_a_connect_timeout() {
431 let url = serve_and_stall(b"")
433 .await
434 .replacen("http://", "https://", 1);
435
436 let outcome = fetch_with(
437 &url,
438 Timeouts {
439 connect: SHORT,
440 total: LONG,
441 },
442 )
443 .await;
444
445 assert!(
446 matches!(
447 outcome,
448 Err(DownloadError::Fetch(FetchError::ConnectTimeout(_)))
449 ),
450 "{outcome:?}"
451 );
452 }
453
454 #[tokio::test]
455 async fn a_response_that_never_starts_is_a_timeout_before_the_headers() {
456 let url = serve_and_stall(b"").await;
457
458 let outcome = fetch_with(
459 &url,
460 Timeouts {
461 connect: LONG,
462 total: SHORT,
463 },
464 )
465 .await;
466
467 assert!(
468 matches!(outcome, Err(DownloadError::Fetch(FetchError::Timeout(_)))),
469 "{outcome:?}"
470 );
471 }
472
473 #[tokio::test]
474 async fn a_body_that_stops_arriving_is_a_timeout_reading_the_body() {
475 let url =
476 serve_and_stall(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n\x1f\x8b\x08\x00")
477 .await;
478
479 let outcome = fetch_with(
480 &url,
481 Timeouts {
482 connect: LONG,
483 total: SHORT,
484 },
485 )
486 .await;
487
488 assert!(
489 matches!(
490 outcome,
491 Err(DownloadError::Fetch(FetchError::BodyTimeout(_)))
492 ),
493 "{outcome:?}"
494 );
495 }
496
497 #[test]
498 fn an_absent_buffer_size_takes_the_default_of_the_python() {
499 assert_eq!(resolve_buffer_size(None), DEFAULT_DOWNLOAD_BUFFER_SIZE);
500 assert_eq!(resolve_buffer_size(NonZeroUsize::new(8 * 1024)), 8 * 1024);
501 }
502
503 #[test]
504 fn a_buffer_size_above_the_maximum_is_clamped() {
505 assert_eq!(
506 resolve_buffer_size(NonZeroUsize::new(MAX_DOWNLOAD_BUFFER_SIZE + 1)),
507 MAX_DOWNLOAD_BUFFER_SIZE
508 );
509 }
510
511 fn git(repo: &Path, args: &[&str]) {
512 let status = std::process::Command::new("git")
513 .arg("-C")
514 .arg(repo)
515 .args([
516 "-c",
517 "user.email=dev@fluidattacks.com",
518 "-c",
519 "user.name=dev",
520 ])
521 .args(args)
522 .status()
523 .unwrap();
524 assert!(status.success());
525 }
526
527 fn hostile_archive(dir: &Path, key: &str) -> (Vec<u8>, PathBuf) {
528 let marker = dir.join("pwned");
529 let payload = dir.join("payload.sh");
530 std::fs::write(
531 &payload,
532 format!("#!/bin/sh\ntouch {}\ncat\n", marker.display()),
533 )
534 .unwrap();
535 #[cfg(unix)]
536 {
537 use std::os::unix::fs::PermissionsExt;
538 std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
539 }
540
541 let source = dir.join("source");
542 std::fs::create_dir_all(&source).unwrap();
543 git(&source, &["init", "-q"]);
544 std::fs::write(source.join(".gitattributes"), b"* filter=pwn\n").unwrap();
545 std::fs::write(source.join("tracked.txt"), b"content\n").unwrap();
546 git(&source, &["add", "-A"]);
547 git(&source, &["commit", "-q", "-m", "add tracked file"]);
548 git(&source, &["config", key, &payload.to_string_lossy()]);
549 std::fs::create_dir_all(source.join(".git/objects/info")).unwrap();
550 std::fs::write(
551 source.join(".git/objects/info/alternates"),
552 format!("{}\n", dir.display()),
553 )
554 .unwrap();
555
556 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
557 builder
558 .append_dir_all("source/.git", source.join(".git"))
559 .unwrap();
560 let archive = builder.into_inner().unwrap().finish().unwrap();
561 std::fs::remove_dir_all(&source).unwrap();
562 (archive, marker)
563 }
564
565 #[tokio::test]
566 async fn a_restored_archive_runs_nothing_it_configures() {
567 for key in ["filter.pwn.smudge", "filter.pwn.process", "core.fsmonitor"] {
568 let tmp = tempfile::tempdir().unwrap();
569 let (archive, marker) = hostile_archive(tmp.path(), key);
570 let dest = tmp.path().join("dest");
571
572 download(archive, &dest, true).await.unwrap();
573
574 assert!(!marker.exists(), "{key} ran");
575 assert_eq!(
576 std::fs::read_to_string(dest.join("tracked.txt")).unwrap(),
577 "content\n"
578 );
579 }
580 }
581
582 #[tokio::test]
583 async fn only_the_history_of_the_archive_is_kept() {
584 let tmp = tempfile::tempdir().unwrap();
585 let (archive, _) = hostile_archive(tmp.path(), "core.fsmonitor");
586 let dest = tmp.path().join("dest");
587
588 download(archive, &dest, false).await.unwrap();
589
590 let git_dir = dest.join(".git");
591 assert_eq!(
592 std::fs::read_to_string(git_dir.join("config")).unwrap(),
593 TRUSTED_CONFIG
594 );
595 assert!(!git_dir.join("hooks").exists());
596 assert!(!git_dir.join("objects/info/alternates").exists());
597 assert!(git_dir.join("HEAD").is_file());
598 }
599
600 #[tokio::test]
601 async fn a_git_file_in_the_archive_is_rejected() {
602 let tmp = tempfile::tempdir().unwrap();
603 let dest = tmp.path().join("dest");
604
605 let outcome = download(
606 tar_gz("repo/.git", b"gitdir: /elsewhere/.git\n"),
607 &dest,
608 false,
609 )
610 .await;
611
612 assert!(
613 matches!(
614 outcome,
615 Err(DownloadError::Extract(ExtractError::NoRepository(_)))
616 ),
617 "{outcome:?}"
618 );
619 assert!(!dest.exists());
620 }
621
622 #[tokio::test]
623 async fn a_failed_reset_keeps_the_previous_destination() {
624 let tmp = tempfile::tempdir().unwrap();
625 let dest = tmp.path().join("dest");
626 std::fs::create_dir_all(&dest).unwrap();
627 std::fs::write(dest.join("previous"), b"old").unwrap();
628
629 let outcome = download(
631 tar_gz("repo/.git/HEAD", b"ref: refs/heads/trunk\n"),
632 &dest,
633 true,
634 )
635 .await;
636
637 assert!(
638 matches!(outcome, Err(DownloadError::Reset(_))),
639 "{outcome:?}"
640 );
641 assert_eq!(std::fs::read(dest.join("previous")).unwrap(), b"old");
642 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
643 }
644
645 #[test]
646 fn a_failed_swap_keeps_the_previous_destination() {
647 let tmp = tempfile::tempdir().unwrap();
648 let dest = tmp.path().join("dest");
649 std::fs::create_dir_all(&dest).unwrap();
650 std::fs::write(dest.join("previous"), b"old").unwrap();
651
652 let outcome = place(&tmp.path().join("missing"), &dest);
653
654 assert!(
655 matches!(outcome, Err(DownloadError::Place(_))),
656 "{outcome:?}"
657 );
658 assert_eq!(std::fs::read(dest.join("previous")).unwrap(), b"old");
659 }
660
661 #[tokio::test]
662 async fn the_first_directory_is_taken_in_a_stable_order() {
663 let tmp = tempfile::tempdir().unwrap();
664 std::fs::write(tmp.path().join("stray"), b"").unwrap();
665 std::fs::create_dir_all(tmp.path().join("b_repo")).unwrap();
666 std::fs::create_dir_all(tmp.path().join("a_repo")).unwrap();
667
668 assert_eq!(
669 extracted_dir(tmp.path()).await.unwrap(),
670 tmp.path().join("a_repo")
671 );
672 }
673}