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, PruneError};
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
47// A presigned url carries its signature in the query, so no error keeps it.
48fn 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    // As the python's aiohttp, the environment's proxy is ignored.
103    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    // The producers pack a single top-level directory.
142    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
155// A `.git` file or link would redirect git to a repository outside the archive.
156async 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).map_err(PruneError::from)?;
198    super::fs::delete_gitignored_files(repo, git_ignore)?;
199    Ok(())
200}
201
202// One swap with no await inside, so `dest` holds the previous tree or the
203// new one, never neither.
204fn place(repo: &Path, dest: &Path) -> Result<(), DownloadError> {
205    match std::fs::symlink_metadata(dest) {
206        Ok(meta) if meta.is_dir() => renameat_with(CWD, repo, CWD, dest, RenameFlags::EXCHANGE)
207            .map_err(|errno| DownloadError::Place(errno.into())),
208        // As the python, which could not remove a file or a link there.
209        Ok(_) => Err(DownloadError::Place(io::Error::from(
210            io::ErrorKind::NotADirectory,
211        ))),
212        Err(error) if error.kind() == io::ErrorKind::NotFound => {
213            std::fs::rename(repo, dest).map_err(DownloadError::Place)
214        }
215        Err(error) => Err(DownloadError::Place(error)),
216    }
217}
218
219// What `download_and_extract_repo` was asked for, with the buffer size resolved.
220struct Request<'a> {
221    url: &'a str,
222    git_ignore: &'a [String],
223    buffer_size: usize,
224    checkout: bool,
225    timeouts: Timeouts,
226}
227
228async fn build_repo(request: Request<'_>, work: &Path) -> Result<PathBuf, DownloadError> {
229    let archive = work.join(ARCHIVE_NAME);
230    fetch_to(request.url, &archive, request.buffer_size, request.timeouts).await?;
231
232    let extracted = work.join(EXTRACTED_NAME);
233    tokio::fs::create_dir(&extracted)
234        .await
235        .map_err(ExtractError::Io)?;
236    let file = tokio::fs::File::open(&archive)
237        .await
238        .map_err(ExtractError::Io)?;
239    super::tar::extract(file, &extracted).await?;
240    let _ = tokio::fs::remove_file(&archive).await;
241
242    let repo = extracted_dir(&extracted).await?;
243    keep_only_history(&repo).await?;
244    if request.checkout {
245        super::cli::reset_hard(&repo).await?;
246        prune(&repo, request.git_ignore)?;
247    }
248    Ok(repo)
249}
250
251async fn download_with(request: Request<'_>, dest: &Path) -> Result<(), DownloadError> {
252    let dest = &std::path::absolute(dest).map_err(DownloadError::Write)?;
253    let parent = dest.parent().ok_or_else(|| {
254        DownloadError::Write(io::Error::new(
255            io::ErrorKind::InvalidInput,
256            "the destination has no parent directory",
257        ))
258    })?;
259    tokio::fs::create_dir_all(parent)
260        .await
261        .map_err(DownloadError::Write)?;
262    // Beside the destination, so the repository swaps into place, and
263    // whatever fails or is cancelled leaves the destination as it was.
264    let work = tempfile::Builder::new()
265        .prefix(WORK_PREFIX)
266        .tempdir_in(parent)
267        .map_err(DownloadError::Write)?;
268
269    let repo = build_repo(request, work.path()).await?;
270    place(&repo, dest)
271}
272
273/// Leaves the repository archived at `url` at `dest`, replacing what was there.
274///
275/// The archive holds only `.git`, so the working tree is restored with a
276/// `git reset --hard`. Pass `checkout = false` to keep just the history: peak
277/// disk stays at the size of the archive plus `.git`, which is what a consumer
278/// that walks commits needs. `git_ignore` prunes working-tree files and has no
279/// effect on a history-only download. `buffer_size` bounds a single write of
280/// the archive to disk; `None` takes 64 KiB, and anything above 16 MiB is
281/// taken as 16 MiB.
282///
283/// The archive is untrusted, so only its history is kept and its config is
284/// replaced. Everything is built in a directory beside `dest`, so a failed or
285/// cancelled call leaves `dest` as it was. Nothing is retried. A relative
286/// `dest` is resolved against the working directory once, when the call
287/// starts, and the working directory is never changed.
288pub async fn download_and_extract_repo(
289    url: &str,
290    dest: &Path,
291    git_ignore: &[String],
292    buffer_size: Option<NonZeroUsize>,
293    checkout: bool,
294) -> Result<(), DownloadError> {
295    let request = Request {
296        url,
297        git_ignore,
298        buffer_size: resolve_buffer_size(buffer_size),
299        checkout,
300        timeouts: TIMEOUTS,
301    };
302    download_with(request, dest).await
303}
304
305#[cfg(test)]
306mod tests {
307    use super::{
308        download_and_extract_repo, extracted_dir, fetch_to, place, resolve_buffer_size, Timeouts,
309        DEFAULT_DOWNLOAD_BUFFER_SIZE, MAX_DOWNLOAD_BUFFER_SIZE, TRUSTED_CONFIG,
310    };
311    use crate::errors::{DownloadError, ExtractError, FetchError};
312    use flate2::write::GzEncoder;
313    use flate2::Compression;
314    use std::num::NonZeroUsize;
315    use std::path::{Path, PathBuf};
316    use std::time::Duration;
317    use tokio::io::{AsyncReadExt, AsyncWriteExt};
318    use tokio::net::TcpListener;
319
320    // Answers every request with `archive`, as a bucket would.
321    async fn serve(archive: Vec<u8>) -> String {
322        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
323        let address = listener.local_addr().unwrap();
324        tokio::spawn(async move {
325            while let Ok((mut stream, _)) = listener.accept().await {
326                let mut request = [0u8; 1024];
327                let _ = stream.read(&mut request).await;
328                let head = format!(
329                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
330                    archive.len()
331                );
332                let _ = stream.write_all(head.as_bytes()).await;
333                let _ = stream.write_all(&archive).await;
334            }
335        });
336        format!("http://{address}/repo.tar.gz")
337    }
338
339    async fn download(archive: Vec<u8>, dest: &Path, checkout: bool) -> Result<(), DownloadError> {
340        let url = serve(archive).await;
341        download_and_extract_repo(&url, dest, &[], None, checkout).await
342    }
343
344    fn tar_gz(name: &str, contents: &[u8]) -> Vec<u8> {
345        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
346        let mut header = tar::Header::new_gnu();
347        header.set_size(u64::try_from(contents.len()).unwrap());
348        header.set_mode(0o644);
349        builder.append_data(&mut header, name, contents).unwrap();
350        builder.into_inner().unwrap().finish().unwrap()
351    }
352
353    #[tokio::test]
354    async fn the_archive_replaces_an_existing_destination_and_leaves_nothing_beside_it() {
355        let tmp = tempfile::tempdir().unwrap();
356        let dest = tmp.path().join("dest");
357        std::fs::create_dir_all(&dest).unwrap();
358        std::fs::write(dest.join("stale"), b"old").unwrap();
359
360        download(
361            tar_gz("repo/.git/HEAD", b"ref: refs/heads/trunk\n"),
362            &dest,
363            false,
364        )
365        .await
366        .unwrap();
367
368        assert_eq!(
369            std::fs::read_to_string(dest.join(".git/HEAD")).unwrap(),
370            "ref: refs/heads/trunk\n"
371        );
372        assert!(!dest.join("stale").exists());
373        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
374    }
375
376    #[tokio::test]
377    async fn an_archive_without_a_directory_is_rejected() {
378        let tmp = tempfile::tempdir().unwrap();
379        let dest = tmp.path().join("dest");
380
381        let outcome = download(tar_gz("HEAD", b"ref: refs/heads/trunk\n"), &dest, false).await;
382
383        assert!(
384            matches!(
385                outcome,
386                Err(DownloadError::Extract(ExtractError::NoRepository(_)))
387            ),
388            "{outcome:?}"
389        );
390        assert!(!dest.exists());
391    }
392
393    const SHORT: Duration = Duration::from_secs(1);
394    const LONG: Duration = Duration::from_secs(30);
395
396    async fn answer_and_stall(mut stream: tokio::net::TcpStream, raw: &'static [u8]) {
397        let mut request = [0u8; 1024];
398        let _ = stream.read(&mut request).await;
399        let _ = stream.write_all(raw).await;
400        while stream.read(&mut request).await.is_ok_and(|read| read > 0) {}
401    }
402
403    // Answers every connection with `raw` and keeps it open, so only a
404    // timeout of the client ends the request.
405    async fn serve_and_stall(raw: &'static [u8]) -> String {
406        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
407        let address = listener.local_addr().unwrap();
408        tokio::spawn(async move {
409            while let Ok((stream, _)) = listener.accept().await {
410                tokio::spawn(answer_and_stall(stream, raw));
411            }
412        });
413        format!("http://{address}/repo.tar.gz")
414    }
415
416    async fn fetch_with(url: &str, timeouts: Timeouts) -> Result<(), DownloadError> {
417        let dir = tempfile::tempdir().unwrap();
418        fetch_to(
419            url,
420            &dir.path().join("repo.tar.gz"),
421            DEFAULT_DOWNLOAD_BUFFER_SIZE,
422            timeouts,
423        )
424        .await
425    }
426
427    #[tokio::test]
428    async fn a_connect_that_never_completes_is_a_connect_timeout() {
429        // The connect covers the TLS handshake, which this server never answers.
430        let url = serve_and_stall(b"")
431            .await
432            .replacen("http://", "https://", 1);
433
434        let outcome = fetch_with(
435            &url,
436            Timeouts {
437                connect: SHORT,
438                total: LONG,
439            },
440        )
441        .await;
442
443        assert!(
444            matches!(
445                outcome,
446                Err(DownloadError::Fetch(FetchError::ConnectTimeout(_)))
447            ),
448            "{outcome:?}"
449        );
450    }
451
452    #[tokio::test]
453    async fn a_response_that_never_starts_is_a_timeout_before_the_headers() {
454        let url = serve_and_stall(b"").await;
455
456        let outcome = fetch_with(
457            &url,
458            Timeouts {
459                connect: LONG,
460                total: SHORT,
461            },
462        )
463        .await;
464
465        assert!(
466            matches!(outcome, Err(DownloadError::Fetch(FetchError::Timeout(_)))),
467            "{outcome:?}"
468        );
469    }
470
471    #[tokio::test]
472    async fn a_body_that_stops_arriving_is_a_timeout_reading_the_body() {
473        let url =
474            serve_and_stall(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n\x1f\x8b\x08\x00")
475                .await;
476
477        let outcome = fetch_with(
478            &url,
479            Timeouts {
480                connect: LONG,
481                total: SHORT,
482            },
483        )
484        .await;
485
486        assert!(
487            matches!(
488                outcome,
489                Err(DownloadError::Fetch(FetchError::BodyTimeout(_)))
490            ),
491            "{outcome:?}"
492        );
493    }
494
495    #[tokio::test]
496    async fn a_failed_fetch_never_carries_the_query_of_the_url() {
497        let refused = {
498            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
499            format!("http://{}/repo.tar.gz", listener.local_addr().unwrap())
500        };
501        let silent = serve_and_stall(b"").await;
502        let cut = serve_and_stall(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\nx").await;
503
504        for url in [refused, silent, cut] {
505            let outcome = fetch_with(
506                &format!("{url}?X-Amz-Signature=secret"),
507                Timeouts {
508                    connect: SHORT,
509                    total: SHORT,
510                },
511            )
512            .await;
513
514            assert!(!format!("{outcome:?}").contains("secret"), "{outcome:?}");
515            let error = outcome.unwrap_err();
516            assert!(!error.to_string().contains("secret"), "{error}");
517        }
518    }
519
520    #[test]
521    fn an_absent_buffer_size_takes_the_default_of_the_python() {
522        assert_eq!(resolve_buffer_size(None), DEFAULT_DOWNLOAD_BUFFER_SIZE);
523        assert_eq!(resolve_buffer_size(NonZeroUsize::new(8 * 1024)), 8 * 1024);
524    }
525
526    #[test]
527    fn a_buffer_size_above_the_maximum_is_clamped() {
528        assert_eq!(
529            resolve_buffer_size(NonZeroUsize::new(MAX_DOWNLOAD_BUFFER_SIZE + 1)),
530            MAX_DOWNLOAD_BUFFER_SIZE
531        );
532    }
533
534    fn git(repo: &Path, args: &[&str]) {
535        let status = std::process::Command::new("git")
536            .arg("-C")
537            .arg(repo)
538            .args([
539                "-c",
540                "user.email=dev@fluidattacks.com",
541                "-c",
542                "user.name=dev",
543            ])
544            .args(args)
545            .status()
546            .unwrap();
547        assert!(status.success());
548    }
549
550    fn hostile_archive(dir: &Path, key: &str) -> (Vec<u8>, PathBuf) {
551        let marker = dir.join("pwned");
552        let payload = dir.join("payload.sh");
553        std::fs::write(
554            &payload,
555            format!("#!/bin/sh\ntouch {}\ncat\n", marker.display()),
556        )
557        .unwrap();
558        #[cfg(unix)]
559        {
560            use std::os::unix::fs::PermissionsExt;
561            std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
562        }
563
564        let source = dir.join("source");
565        std::fs::create_dir_all(&source).unwrap();
566        git(&source, &["init", "-q"]);
567        std::fs::write(source.join(".gitattributes"), b"* filter=pwn\n").unwrap();
568        std::fs::write(source.join("tracked.txt"), b"content\n").unwrap();
569        git(&source, &["add", "-A"]);
570        git(&source, &["commit", "-q", "-m", "add tracked file"]);
571        git(&source, &["config", key, &payload.to_string_lossy()]);
572        std::fs::create_dir_all(source.join(".git/objects/info")).unwrap();
573        std::fs::write(
574            source.join(".git/objects/info/alternates"),
575            format!("{}\n", dir.display()),
576        )
577        .unwrap();
578
579        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
580        builder
581            .append_dir_all("source/.git", source.join(".git"))
582            .unwrap();
583        let archive = builder.into_inner().unwrap().finish().unwrap();
584        std::fs::remove_dir_all(&source).unwrap();
585        (archive, marker)
586    }
587
588    #[tokio::test]
589    async fn a_restored_archive_runs_nothing_it_configures() {
590        for key in ["filter.pwn.smudge", "filter.pwn.process", "core.fsmonitor"] {
591            let tmp = tempfile::tempdir().unwrap();
592            let (archive, marker) = hostile_archive(tmp.path(), key);
593            let dest = tmp.path().join("dest");
594
595            download(archive, &dest, true).await.unwrap();
596
597            assert!(!marker.exists(), "{key} ran");
598            assert_eq!(
599                std::fs::read_to_string(dest.join("tracked.txt")).unwrap(),
600                "content\n"
601            );
602        }
603    }
604
605    #[tokio::test]
606    async fn only_the_history_of_the_archive_is_kept() {
607        let tmp = tempfile::tempdir().unwrap();
608        let (archive, _) = hostile_archive(tmp.path(), "core.fsmonitor");
609        let dest = tmp.path().join("dest");
610
611        download(archive, &dest, false).await.unwrap();
612
613        let git_dir = dest.join(".git");
614        assert_eq!(
615            std::fs::read_to_string(git_dir.join("config")).unwrap(),
616            TRUSTED_CONFIG
617        );
618        assert!(!git_dir.join("hooks").exists());
619        assert!(!git_dir.join("objects/info/alternates").exists());
620        assert!(git_dir.join("HEAD").is_file());
621    }
622
623    #[tokio::test]
624    async fn a_git_file_in_the_archive_is_rejected() {
625        let tmp = tempfile::tempdir().unwrap();
626        let dest = tmp.path().join("dest");
627
628        let outcome = download(
629            tar_gz("repo/.git", b"gitdir: /elsewhere/.git\n"),
630            &dest,
631            false,
632        )
633        .await;
634
635        assert!(
636            matches!(
637                outcome,
638                Err(DownloadError::Extract(ExtractError::NoRepository(_)))
639            ),
640            "{outcome:?}"
641        );
642        assert!(!dest.exists());
643    }
644
645    #[tokio::test]
646    async fn a_failed_reset_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        // A HEAD with no objects behind it, which git cannot reset to.
653        let outcome = download(
654            tar_gz("repo/.git/HEAD", b"ref: refs/heads/trunk\n"),
655            &dest,
656            true,
657        )
658        .await;
659
660        assert!(
661            matches!(outcome, Err(DownloadError::Reset(_))),
662            "{outcome:?}"
663        );
664        assert_eq!(std::fs::read(dest.join("previous")).unwrap(), b"old");
665        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
666    }
667
668    #[test]
669    fn a_failed_swap_keeps_the_previous_destination() {
670        let tmp = tempfile::tempdir().unwrap();
671        let dest = tmp.path().join("dest");
672        std::fs::create_dir_all(&dest).unwrap();
673        std::fs::write(dest.join("previous"), b"old").unwrap();
674
675        let outcome = place(&tmp.path().join("missing"), &dest);
676
677        assert!(
678            matches!(outcome, Err(DownloadError::Place(_))),
679            "{outcome:?}"
680        );
681        assert_eq!(std::fs::read(dest.join("previous")).unwrap(), b"old");
682    }
683
684    #[tokio::test]
685    async fn a_work_directory_that_cannot_be_made_is_a_write_failure() {
686        use std::os::unix::fs::PermissionsExt;
687        let tmp = tempfile::tempdir().unwrap();
688        let parent = tmp.path().join("read-only");
689        std::fs::create_dir(&parent).unwrap();
690        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap();
691        // Root writes in a read-only directory, so there is nothing to refuse.
692        if std::fs::create_dir(parent.join("probe")).is_ok() {
693            return;
694        }
695
696        let outcome =
697            download_and_extract_repo("http://127.0.0.1:9/", &parent.join("root"), &[], None, true)
698                .await;
699
700        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
701        assert!(
702            matches!(outcome, Err(DownloadError::Write(_))),
703            "{outcome:?}"
704        );
705    }
706
707    #[tokio::test]
708    async fn the_first_directory_is_taken_in_a_stable_order() {
709        let tmp = tempfile::tempdir().unwrap();
710        std::fs::write(tmp.path().join("stray"), b"").unwrap();
711        std::fs::create_dir_all(tmp.path().join("b_repo")).unwrap();
712        std::fs::create_dir_all(tmp.path().join("a_repo")).unwrap();
713
714        assert_eq!(
715            extracted_dir(tmp.path()).await.unwrap(),
716            tmp.path().join("a_repo")
717        );
718    }
719}