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
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)
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
207// One swap with no await inside, so `dest` holds the previous tree or the
208// new one, never neither.
209fn 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        // As the python, which could not remove a file or a link there.
214        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
224// What `download_and_extract_repo` was asked for, with the buffer size resolved.
225struct 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    // Beside the destination, so the repository swaps into place, and
268    // whatever fails or is cancelled leaves the destination as it was.
269    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
278/// Leaves the repository archived at `url` at `dest`, replacing what was there.
279///
280/// The archive holds only `.git`, so the working tree is restored with a
281/// `git reset --hard`. Pass `checkout = false` to keep just the history: peak
282/// disk stays at the size of the archive plus `.git`, which is what a consumer
283/// that walks commits needs. `git_ignore` prunes working-tree files and has no
284/// effect on a history-only download. `buffer_size` bounds a single write of
285/// the archive to disk; `None` takes 64 KiB, and anything above 16 MiB is
286/// taken as 16 MiB.
287///
288/// The archive is untrusted, so only its history is kept and its config is
289/// replaced. Everything is built in a directory beside `dest`, so a failed or
290/// cancelled call leaves `dest` as it was. Nothing is retried. A relative
291/// `dest` is resolved against the working directory once, when the call
292/// starts, and the working directory is never changed.
293pub 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    // Answers every request with `archive`, as a bucket would.
326    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    // Answers every connection with `raw` and keeps it open, so only a
409    // timeout of the client ends the request.
410    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        // The connect covers the TLS handshake, which this server never answers.
435        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        // A HEAD with no objects behind it, which git cannot reset to.
658        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        // Root writes in a read-only directory, so there is nothing to refuse.
697        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}