Skip to main content

ic_testkit/pic/server_assets/
mod.rs

1//! Explicit, release-bound PocketIC assets for Rust fixtures and the CLI.
2//!
3//! [`check`] is offline and never sets up a missing bundle. [`setup`] explicitly
4//! provisions it. Callers retain namespace custody and select the bundle root;
5//! managed startup and application safety remain separate responsibilities.
6
7use super::POCKET_IC_SERVER_VERSION;
8use ic_host_artifacts::artifact::{Sha256Digest, decode_gzip, hash_gzip, hash_reader};
9use ic_host_fs::{durable, read::read_file_no_follow};
10use ic_host_process::{
11    child::{CleanupPolicy, OwnedChild},
12    tool::{
13        AdmittedTool, ExecutionContext, ExecutionError, ExecutionEvidence, ExecutionFailure,
14        ExecutionOperation, OutputLimit, OutputLimits, SuccessfulExit, ToolError, VersionSpec,
15        communicate_child,
16    },
17};
18use std::{
19    error::Error,
20    ffi::OsString,
21    fs::{self, File},
22    io::{self, Write as _},
23    path::{Component, Path, PathBuf},
24    process::{Command, Stdio},
25    sync::atomic::{AtomicU64, Ordering},
26    time::Duration,
27};
28
29type Result<T> = std::result::Result<T, Box<dyn Error>>;
30const ARCHIVE_LIMIT: usize = 80 * 1024 * 1024;
31const EXECUTABLE_LIMIT: usize = 512 * 1024 * 1024;
32static SEQUENCE: AtomicU64 = AtomicU64::new(0);
33/// Default bundle root used by `ic-testkit-server`.
34pub const DEFAULT_DIRECTORY: &str = ".tools/ic-testkit-server";
35
36struct Asset {
37    host: &'static str,
38    digest: Sha256Digest,
39}
40
41fn selected_asset() -> Result<Asset> {
42    // Official GitHub release asset digests, reviewed 2026-10-09.
43    let (host, digest) = match (std::env::consts::OS, std::env::consts::ARCH) {
44        ("linux", "x86_64") => (
45            "x86_64-linux",
46            "131219d90dcf9bf6f3ed8ee02d8f55504ea24a6db382f5680675b7bd6b7ed3bf",
47        ),
48        ("macos", "x86_64") => (
49            "x86_64-darwin",
50            "af9ad2d781530a43556ef2d1c8f93db99a2425c6cabdc78520f61922399ed530",
51        ),
52        ("macos", "aarch64") => (
53            "arm64-darwin",
54            "9ae843fbb7ae6c6eb30137671c8629a80ef53b3a3652eb85b344847ac39f9fcd",
55        ),
56        _ => return Err("no reviewed PocketIC server asset for this host".into()),
57    };
58    Ok(Asset {
59        host,
60        digest: digest.parse()?,
61    })
62}
63
64fn root_path(directory: &Path) -> Result<PathBuf> {
65    if directory.as_os_str().is_empty() {
66        return Err("server directory must not be empty".into());
67    }
68    let absolute = if directory.is_absolute() {
69        directory.to_owned()
70    } else {
71        std::env::current_dir()?.join(directory)
72    };
73    if absolute
74        .as_os_str()
75        .as_encoded_bytes()
76        .iter()
77        .any(|byte| matches!(byte, b'\n' | b'\r'))
78    {
79        return Err("server directory and its ancestors must not contain line breaks".into());
80    }
81    let mut path = PathBuf::new();
82    for component in absolute.components() {
83        match component {
84            Component::RootDir | Component::Normal(_) => path.push(component),
85            Component::CurDir => continue,
86            _ => return Err("server directory must not contain parent traversal".into()),
87        }
88        match fs::symlink_metadata(&path) {
89            Ok(metadata) if !metadata.is_dir() || metadata.is_symlink() => {
90                return Err(format!(
91                    "server directory component is not a physical directory: {}",
92                    path.display()
93                )
94                .into());
95            }
96            Ok(_) => {}
97            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
98            Err(error) => return Err(error.into()),
99        }
100    }
101    Ok(path)
102}
103
104fn bundle_path(root: &Path, asset: &Asset) -> PathBuf {
105    root.join(format!("{}-{}", POCKET_IC_SERVER_VERSION, asset.host))
106}
107
108fn authenticated_archive(archive: &Path, digest: Sha256Digest) -> Result<Vec<u8>> {
109    let bytes = read_file_no_follow(archive, ARCHIVE_LIMIT)?;
110    hash_reader(bytes.as_slice(), ARCHIVE_LIMIT as u64)?.verify(digest)?;
111    Ok(bytes)
112}
113
114fn payload(archive: &Path, digest: Sha256Digest) -> Result<Vec<u8>> {
115    let bytes = authenticated_archive(archive, digest)?;
116    Ok(decode_gzip(&bytes, ARCHIVE_LIMIT, EXECUTABLE_LIMIT)?)
117}
118
119fn admit(executable: &Path, digest: Sha256Digest) -> Result<()> {
120    let directory = executable.parent().ok_or("missing executable parent")?;
121    AdmittedTool::admit_no_follow(
122        &VersionSpec {
123            executable,
124            executable_bytes: EXECUTABLE_LIMIT as u64,
125            version_arguments: &[OsString::from("--version")],
126        },
127        digest,
128        &format!("pocket-ic-server {POCKET_IC_SERVER_VERSION}"),
129        &ExecutionContext {
130            current_dir: directory,
131            environment: &[],
132        },
133        OutputLimits {
134            stdout: OutputLimit::Terminate(4096),
135            stderr: OutputLimit::Terminate(4096),
136            timeout: Some(Duration::from_secs(10)),
137        },
138    )?;
139    Ok(())
140}
141
142fn check_bundle(bundle: &Path, asset: &Asset) -> Result<PathBuf> {
143    if !fs::symlink_metadata(bundle)?.is_dir() || fs::symlink_metadata(bundle)?.is_symlink() {
144        return Err("PocketIC bundle must be a physical directory".into());
145    }
146    let bytes = authenticated_archive(&bundle.join("archive.gz"), asset.digest)?;
147    let digest = hash_gzip(&bytes, ARCHIVE_LIMIT, EXECUTABLE_LIMIT as u64)?.sha256;
148    drop(bytes);
149    let executable = bundle.join("pocket-ic");
150    admit(&executable, digest)?;
151    Ok(executable)
152}
153
154/// Admit an existing reviewed bundle without downloading or changing it.
155///
156/// Authenticates the bounded archive before decoding, compares the executable's
157/// exact bytes, then runs bounded `--version` admission with an empty environment.
158/// Returns the absolute executable path, not ongoing custody of its namespace.
159/// Callers must prevent replacement between admission and managed startup.
160///
161/// # Errors
162/// Rejects unsupported hosts, redirected paths, missing or modified bundles,
163/// invalid archives, or failed executable/version admission.
164pub fn check(directory: &Path) -> Result<PathBuf> {
165    let asset = selected_asset()?;
166    check_bundle(&bundle_path(&root_path(directory)?, &asset), &asset)
167}
168
169/// Explicitly prepare or admit the reviewed bundle, retaining failed attempts.
170///
171/// Downloads only when the selected bundle is absent. Cooperating setups share
172/// one lock; existing bundles must pass the same offline [`check`] admission.
173/// `interrupted` lets the caller request bounded downloader cancellation without
174/// this library installing process-global signal handlers.
175///
176/// # Errors
177/// Returns path, lock, download, authentication, decoding, publication or
178/// executable/version failures. Retained evidence is never automatically removed.
179pub fn setup(directory: &Path, mut interrupted: impl FnMut() -> bool) -> Result<PathBuf> {
180    let asset = selected_asset()?;
181    setup_with(directory, &asset, |archive| {
182        let url = format!(
183            "https://github.com/dfinity/pocketic/releases/download/{POCKET_IC_SERVER_VERSION}/pocket-ic-{}.gz",
184            asset.host
185        );
186        let mut command = Command::new("curl");
187        command
188            .args([
189                "--proto",
190                "=https",
191                "--proto-redir",
192                "=https",
193                "--tlsv1.2",
194                "--fail",
195                "--silent",
196                "--show-error",
197                "--location",
198                "--connect-timeout",
199                "15",
200                "--max-time",
201                "300",
202                "--max-filesize",
203                "83886080",
204                "--output",
205            ])
206            .arg(archive)
207            .arg(url)
208            .stdin(Stdio::null())
209            .stdout(Stdio::piped())
210            .stderr(Stdio::piped());
211        let outcome = OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait)
212            .map_err(|source| {
213                ToolError::Execution(Box::new(ExecutionError {
214                    failure: ExecutionFailure::Io {
215                        operation: ExecutionOperation::Spawn,
216                        source,
217                    },
218                    evidence: ExecutionEvidence::default(),
219                    cleanup: None,
220                }))
221            })
222            .and_then(|mut child| {
223                communicate_child(
224                    &mut child,
225                    None,
226                    OutputLimits {
227                        stdout: OutputLimit::Terminate(4096),
228                        stderr: OutputLimit::Terminate(64 * 1024),
229                        timeout: Some(Duration::from_secs(310)),
230                    },
231                    SuccessfulExit::Cleanup,
232                    &mut interrupted,
233                )
234            })
235            .and_then(ic_host_process::tool::CompletedExecution::require_success);
236        if let Some(stderr) = outcome
237            .as_ref()
238            .ok()
239            .map(|completed| &completed.stderr)
240            .or_else(|| {
241                outcome
242                    .as_ref()
243                    .err()
244                    .and_then(|error| error.evidence())
245                    .map(|evidence| &evidence.stderr)
246            })
247            && let Err(record_error) = durable::write_bytes(
248                &archive
249                    .parent()
250                    .ok_or("missing archive parent")?
251                    .join("download.stderr"),
252                durable::WriteOptions {
253                    mode: durable::PublicationMode::Replace,
254                    permissions: 0o666,
255                },
256                stderr,
257            )
258        {
259            if outcome.is_ok() {
260                return Err(record_error.into());
261            }
262            // Recording evidence must not replace the failed download's cause.
263            eprintln!("could not record download stderr: {record_error}");
264        }
265        outcome?;
266        Ok(())
267    })
268}
269
270fn setup_with(
271    directory: &Path,
272    asset: &Asset,
273    download: impl FnOnce(&Path) -> Result<()>,
274) -> Result<PathBuf> {
275    let root = root_path(directory)?;
276    // Namespace custody remains with the caller. All cooperating setups serialize
277    // through Host's admitted regular lock; checks are read-only.
278    let lock = durable::open_regular_lock_file_with_parents(&root.join(".setup-v1.lock"))?;
279    durable::lock_exclusive(&lock)?;
280    let bundle = bundle_path(&root, asset);
281    match fs::symlink_metadata(&bundle) {
282        Ok(_) => return check_bundle(&bundle, asset),
283        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
284        Err(error) => return Err(error.into()),
285    }
286    let attempt = root.join(format!(
287        ".setup-v1-{}-{}",
288        std::process::id(),
289        SEQUENCE.fetch_add(1, Ordering::Relaxed)
290    ));
291    let staging_directory = durable::create_private_directory(&attempt)?;
292    eprintln!("PocketIC setup evidence: {}", attempt.display());
293    let result = install(&attempt, asset, download);
294    if let Err(error) = result {
295        if let Err(record_error) = durable::write_bytes(
296            &attempt.join("failure.txt"),
297            durable::WriteOptions {
298                mode: durable::PublicationMode::Replace,
299                permissions: 0o666,
300            },
301            error.to_string().as_bytes(),
302        ) {
303            eprintln!("could not record setup failure: {record_error}");
304        }
305        return Err(error);
306    }
307    // A complete immutable bundle is visible only after all admission succeeds.
308    // No previous version, failed attempt or installed selection is removed.
309    durable::admit_directory(&attempt, &staging_directory)?;
310    staging_directory.sync_all()?;
311    fs::rename(&attempt, &bundle)?;
312    eprintln!("PocketIC setup evidence retained: {}", bundle.display());
313    File::open(&root)?.sync_all()?;
314    Ok(bundle.join("pocket-ic"))
315}
316
317fn install(
318    attempt: &Path,
319    asset: &Asset,
320    download: impl FnOnce(&Path) -> Result<()>,
321) -> Result<()> {
322    let archive = attempt.join("archive.gz");
323    download(&archive)?;
324    let bytes = payload(&archive, asset.digest)?;
325    File::open(&archive)?.sync_all()?;
326    let digest = Sha256Digest::compute(&bytes);
327    let executable = attempt.join("pocket-ic");
328    durable::write_with(
329        &executable,
330        durable::WriteOptions {
331            mode: durable::PublicationMode::CreateNew,
332            permissions: 0o755,
333        },
334        |file| -> io::Result<()> { file.write_all(&bytes) },
335    )?;
336    drop(bytes);
337    // PocketIC requires its canonical basename. The whole directory is private
338    // and unselected until this admission succeeds; no existing bundle is replaced.
339    if let Err(error) = admit(&executable, digest) {
340        if let Some(evidence) = error
341            .downcast_ref::<ic_host_process::tool::ToolError>()
342            .and_then(|error| error.evidence())
343        {
344            for (name, bytes) in [
345                ("version.stdout", &evidence.stdout),
346                ("version.stderr", &evidence.stderr),
347            ] {
348                if let Err(record_error) = durable::write_bytes(
349                    &attempt.join(name),
350                    durable::WriteOptions {
351                        mode: durable::PublicationMode::Replace,
352                        permissions: 0o666,
353                    },
354                    bytes,
355                ) {
356                    eprintln!("could not record version evidence: {record_error}");
357                }
358            }
359        }
360        return Err(error);
361    }
362    Ok(())
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use ic_host_artifacts::artifact::encode_gzip;
369    use std::os::unix::fs::{PermissionsExt as _, symlink};
370
371    fn run_in_subprocess() -> bool {
372        let current = std::thread::current();
373        let name = current.name().expect("registered asset test name");
374        if std::env::var("IC_TESTKIT_ASSET_CASE").as_deref() == Ok(name) {
375            return false;
376        }
377        // A library sibling's fork can retain a writable executable descriptor.
378        // Re-exec before writing fixtures so only this case owns their lifetime.
379        let mut child = OwnedChild::spawn(
380            Command::new(std::env::current_exe().expect("library test executable"))
381                .args(["--exact", name, "--nocapture"])
382                .env("IC_TESTKIT_ASSET_CASE", name)
383                .stdin(Stdio::null())
384                .stdout(Stdio::piped())
385                .stderr(Stdio::piped()),
386            CleanupPolicy::KillAndWait,
387        )
388        .expect("spawn isolated asset test");
389        let completed = communicate_child(
390            &mut child,
391            None,
392            OutputLimits {
393                stdout: OutputLimit::Terminate(64 * 1024),
394                stderr: OutputLimit::Terminate(64 * 1024),
395                timeout: Some(Duration::from_secs(120)),
396            },
397            SuccessfulExit::Cleanup,
398            &mut || false,
399        )
400        .expect("capture and reap isolated asset test");
401        assert!(
402            completed.status.success(),
403            "isolated asset test failed: stdout={} stderr={}",
404            String::from_utf8_lossy(&completed.stdout),
405            String::from_utf8_lossy(&completed.stderr),
406        );
407        true
408    }
409
410    struct Fixture {
411        root: tempfile::TempDir,
412    }
413    impl Fixture {
414        fn new() -> Self {
415            let root = tempfile::Builder::new()
416                .prefix("testkit-provision-")
417                .permissions(fs::Permissions::from_mode(0o700))
418                .tempdir_in(std::env::temp_dir().canonicalize().unwrap())
419                .unwrap();
420            Self { root }
421        }
422    }
423    fn archive(version: &str) -> (Asset, Vec<u8>) {
424        let script = format!("#!/bin/sh\nprintf 'pocket-ic-server {version}\\n'\n");
425        let mut bytes = Vec::new();
426        encode_gzip(script.as_bytes(), &mut bytes, 1, 65536).unwrap();
427        (
428            Asset {
429                host: "fixture",
430                digest: Sha256Digest::compute(&bytes),
431            },
432            bytes,
433        )
434    }
435
436    #[test]
437    fn authenticated_setup_reuses_offline_and_refuses_changed_bytes_before_execution() {
438        if run_in_subprocess() {
439            return;
440        }
441        let root = Fixture::new();
442        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
443        let executable = setup_with(root.root.path(), &asset, |path| {
444            fs::write(path, &bytes)?;
445            Ok(())
446        })
447        .unwrap();
448        let original = fs::read(&executable).unwrap();
449        assert_eq!(
450            setup_with(root.root.path(), &asset, |_| panic!(
451                "verified reuse must not download"
452            ))
453            .unwrap(),
454            executable
455        );
456        let entries = fs::read_dir(root.root.path()).unwrap().count();
457        assert_eq!(
458            check_bundle(&bundle_path(root.root.path(), &asset), &asset).unwrap(),
459            executable
460        );
461        assert_eq!(fs::read_dir(root.root.path()).unwrap().count(), entries);
462        let marker = root.root.path().join("executed");
463        fs::write(
464            &executable,
465            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
466        )
467        .unwrap();
468        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
469        assert!(!marker.exists());
470        fs::write(&executable, original).unwrap();
471        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_ok());
472        let saved = root.root.path().join("saved");
473        fs::rename(&executable, &saved).unwrap();
474        symlink(&saved, &executable).unwrap();
475        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
476    }
477
478    #[test]
479    fn offline_check_authenticates_before_decoding_and_rejects_invalid_gzip() {
480        use ic_host_artifacts::artifact::{ArtifactError, GzipError};
481
482        if run_in_subprocess() {
483            return;
484        }
485
486        let root = Fixture::new();
487        let (mut asset, original) = archive(POCKET_IC_SERVER_VERSION);
488        let executable = setup_with(root.root.path(), &asset, |path| {
489            fs::write(path, &original)?;
490            Ok(())
491        })
492        .unwrap();
493        let installed = fs::read(&executable).unwrap();
494        let bundle = bundle_path(root.root.path(), &asset);
495        let path = bundle.join("archive.gz");
496
497        // Both authentication and decoding would fail; authentication wins.
498        fs::write(&path, b"not gzip").unwrap();
499        let error = check_bundle(&bundle, &asset).unwrap_err();
500        assert!(matches!(
501            error.downcast_ref::<ArtifactError>(),
502            Some(ArtifactError::DigestMismatch { .. })
503        ));
504
505        let mut bad_crc = original.clone();
506        let crc = bad_crc.len() - 8;
507        bad_crc[crc] ^= 1;
508        let mut bad_length = original.clone();
509        let length = bad_length.len() - 4;
510        bad_length[length] ^= 1;
511        for bytes in [
512            b"not gzip".to_vec(),
513            original[..original.len() - 1].to_vec(),
514            bad_crc,
515            bad_length,
516            [original.as_slice(), b"trailing"].concat(),
517            [original.as_slice(), original.as_slice()].concat(),
518        ] {
519            fs::write(&path, &bytes).unwrap();
520            asset.digest = Sha256Digest::compute(&bytes);
521            let error = check_bundle(&bundle, &asset).unwrap_err();
522            // Gzip refusal occurs before executable admission/version execution.
523            assert!(error.downcast_ref::<GzipError>().is_some());
524            assert_eq!(fs::read(&executable).unwrap(), installed);
525            assert_eq!(fs::read(&path).unwrap(), bytes);
526        }
527        fs::write(&path, &original).unwrap();
528        asset.digest = Sha256Digest::compute(&original);
529        assert_eq!(check_bundle(&bundle, &asset).unwrap(), executable);
530    }
531
532    #[test]
533    fn failed_setup_retains_evidence_and_previous_bundles_then_can_retry() {
534        if run_in_subprocess() {
535            return;
536        }
537        let root = Fixture::new();
538        let previous = root.root.path().join("previous-version");
539        fs::create_dir(&previous).unwrap();
540        fs::write(previous.join("pocket-ic"), b"previous bytes").unwrap();
541        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
542        assert!(
543            setup_with(root.root.path(), &asset, |path| {
544                fs::write(path, b"partial download")?;
545                Err("interrupted download".into())
546            })
547            .is_err()
548        );
549        assert!(!bundle_path(root.root.path(), &asset).exists());
550        let failed = fs::read_dir(root.root.path())
551            .unwrap()
552            .find_map(|entry| {
553                let path = entry.unwrap().path();
554                path.join("failure.txt").is_file().then_some(path)
555            })
556            .unwrap();
557        assert_eq!(
558            fs::read(failed.join("archive.gz")).unwrap(),
559            b"partial download"
560        );
561        assert!(
562            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
563                let _ = setup_with(root.root.path(), &asset, |path| {
564                    fs::write(path, b"interrupted candidate")?;
565                    panic!("injected interruption before admission");
566                });
567            }))
568            .is_err()
569        );
570        assert!(!bundle_path(root.root.path(), &asset).exists());
571        assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
572            fs::read(entry.unwrap().path().join("archive.gz"))
573                .ok()
574                .as_deref()
575                == Some(b"interrupted candidate")
576        }));
577        assert!(
578            setup_with(root.root.path(), &asset, |path| {
579                fs::write(path, &bytes)?;
580                Ok(())
581            })
582            .is_ok()
583        );
584        assert!(failed.join("failure.txt").is_file());
585        assert_eq!(
586            fs::read(previous.join("pocket-ic")).unwrap(),
587            b"previous bytes"
588        );
589    }
590
591    #[test]
592    fn digest_decode_and_version_failures_never_publish() {
593        if run_in_subprocess() {
594            return;
595        }
596        for mode in [0, 1, 2] {
597            let root = Fixture::new();
598            let (mut asset, mut bytes) = archive("15.0.0");
599            if mode == 0 {
600                asset.digest = Sha256Digest::compute(b"wrong");
601            }
602            if mode == 1 {
603                bytes = b"not gzip".to_vec();
604                asset.digest = Sha256Digest::compute(&bytes);
605            }
606            assert!(
607                setup_with(root.root.path(), &asset, |path| {
608                    fs::write(path, &bytes)?;
609                    Ok(())
610                })
611                .is_err()
612            );
613            assert!(!bundle_path(root.root.path(), &asset).exists());
614        }
615    }
616
617    #[test]
618    fn redirected_paths_are_refused_without_touching_the_target() {
619        if run_in_subprocess() {
620            return;
621        }
622        let root = Fixture::new();
623        let (asset, _) = archive(POCKET_IC_SERVER_VERSION);
624        let target = root.root.path().join("target");
625        fs::create_dir(&target).unwrap();
626        let redirected = root.root.path().join("redirected");
627        symlink(&target, &redirected).unwrap();
628        assert!(
629            setup_with(&redirected.join("child"), &asset, |_| panic!(
630                "redirected ancestor reached downloader"
631            ))
632            .is_err()
633        );
634        assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
635        symlink(&target, root.root.path().join(".setup-v1.lock")).unwrap();
636        assert!(
637            setup_with(root.root.path(), &asset, |_| panic!(
638                "redirected lock reached downloader"
639            ))
640            .is_err()
641        );
642    }
643
644    #[test]
645    fn replaced_staging_directory_is_not_published() {
646        if run_in_subprocess() {
647            return;
648        }
649        let root = Fixture::new();
650        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
651        let retained = root.root.path().join("retained-original-stage");
652        let error = setup_with(root.root.path(), &asset, |archive| {
653            fs::write(archive, &bytes)?;
654            let stage = archive.parent().unwrap();
655            fs::rename(stage, &retained)?;
656            fs::create_dir(stage)?;
657            fs::write(archive, &bytes)?;
658            Ok(())
659        })
660        .expect_err("replacement must not inherit the held directory claim");
661        assert_eq!(
662            error
663                .downcast_ref::<io::Error>()
664                .unwrap_or_else(|| panic!("expected directory identity refusal, got {error:?}"))
665                .kind(),
666            io::ErrorKind::InvalidInput,
667        );
668        assert!(!bundle_path(root.root.path(), &asset).exists());
669        assert_eq!(fs::read(retained.join("archive.gz")).unwrap(), bytes);
670    }
671
672    #[test]
673    fn concurrent_setups_download_once_and_recheck_under_the_lock() {
674        if run_in_subprocess() {
675            return;
676        }
677        let root = Fixture::new();
678        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
679        let calls = AtomicU64::new(0);
680        std::thread::scope(|scope| {
681            let handles = (0..2)
682                .map(|_| {
683                    scope.spawn(|| {
684                        setup_with(root.root.path(), &asset, |path| {
685                            calls.fetch_add(1, Ordering::Relaxed);
686                            fs::write(path, &bytes)?;
687                            Ok(())
688                        })
689                        .unwrap()
690                    })
691                })
692                .collect::<Vec<_>>();
693            for handle in handles {
694                assert_eq!(
695                    handle.join().unwrap(),
696                    bundle_path(root.root.path(), &asset).join("pocket-ic")
697                );
698            }
699        });
700        assert_eq!(calls.load(Ordering::Relaxed), 1);
701    }
702}