Skip to main content

fluidattacks_core_git/
download.rs

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"];
21// Inside `objects/`, these point git at object stores elsewhere on disk.
22const 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    // As the python's aiohttp, the environment's proxy is ignored.
100    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    // The producers pack a single top-level directory.
139    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
152// A `.git` file or link would redirect git to a repository outside the archive.
153async 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
204// One swap with no await inside, so `dest` holds the previous tree or the
205// new one, never neither.
206fn 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        // As the python, which could not remove a file or a link there.
211        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
221// What `download_and_extract_repo` was asked for, with the buffer size resolved.
222struct 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    // Beside the destination, so the repository swaps into place, and
265    // whatever fails or is cancelled leaves the destination as it was.
266    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
275/// Leaves the repository archived at `url` at `dest`, replacing what was there.
276///
277/// The archive holds only `.git`, so the working tree is restored with a
278/// `git reset --hard`. Pass `checkout = false` to keep just the history: peak
279/// disk stays at the size of the archive plus `.git`, which is what a consumer
280/// that walks commits needs. `git_ignore` prunes working-tree files and has no
281/// effect on a history-only download. `buffer_size` bounds a single write of
282/// the archive to disk; `None` takes 64 KiB, and anything above 16 MiB is
283/// taken as 16 MiB.
284///
285/// The archive is untrusted, so only its history is kept and its config is
286/// replaced. Everything is built in a directory beside `dest`, so a failed or
287/// cancelled call leaves `dest` as it was. Nothing is retried. A relative
288/// `dest` is resolved against the working directory once, when the call
289/// starts, and the working directory is never changed.
290pub 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    // Answers every request with `archive`, as a bucket would.
323    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    // Answers every connection with `raw` and keeps it open, so only a
406    // timeout of the client ends the request.
407    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        // The connect covers the TLS handshake, which this server never answers.
432        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        // A HEAD with no objects behind it, which git cannot reset to.
630        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}