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