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    let attempt = root.join(format!(
282        ".setup-v1-{}-{}",
283        std::process::id(),
284        SEQUENCE.fetch_add(1, Ordering::Relaxed)
285    ));
286    let staging_directory = match fs::symlink_metadata(&bundle) {
287        Ok(_) => {
288            check_bundle(&bundle, asset)?;
289            None
290        }
291        Err(error) if error.kind() == io::ErrorKind::NotFound => {
292            let held = durable::create_private_directory(&attempt)?;
293            eprintln!("PocketIC setup evidence: {}", attempt.display());
294            if let Err(error) = install(&attempt, asset, download) {
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            Some(held)
308        }
309        Err(error) => return Err(error.into()),
310    };
311    // Host owns no-replace publication and phase-aware durability failures.
312    // Fresh staging must retain its original directory identity. Recovery admits
313    // the installed bundle again before Host synchronizes its parent.
314    durable::commit_existing_directory_with(
315        &attempt,
316        &bundle,
317        |selected, path| -> io::Result<()> {
318            if let Some(held) = &staging_directory {
319                durable::admit_directory(path, held)?;
320            }
321            selected.sync_all()?;
322            Ok(())
323        },
324        |_, _| Ok(()),
325    )?;
326    eprintln!("PocketIC setup evidence retained: {}", bundle.display());
327    Ok(bundle.join("pocket-ic"))
328}
329
330fn install(
331    attempt: &Path,
332    asset: &Asset,
333    download: impl FnOnce(&Path) -> Result<()>,
334) -> Result<()> {
335    let archive = attempt.join("archive.gz");
336    download(&archive)?;
337    let bytes = payload(&archive, asset.digest)?;
338    File::open(&archive)?.sync_all()?;
339    let digest = Sha256Digest::compute(&bytes);
340    let executable = attempt.join("pocket-ic");
341    durable::write_with(
342        &executable,
343        durable::WriteOptions {
344            mode: durable::PublicationMode::CreateNew,
345            permissions: 0o755,
346        },
347        |file| -> io::Result<()> { file.write_all(&bytes) },
348    )?;
349    drop(bytes);
350    // PocketIC requires its canonical basename. The whole directory is private
351    // and unselected until this admission succeeds; no existing bundle is replaced.
352    if let Err(error) = admit(&executable, digest) {
353        if let Some(evidence) = error
354            .downcast_ref::<ic_host_process::tool::ToolError>()
355            .and_then(|error| error.evidence())
356        {
357            for (name, bytes) in [
358                ("version.stdout", &evidence.stdout),
359                ("version.stderr", &evidence.stderr),
360            ] {
361                if let Err(record_error) = durable::write_bytes(
362                    &attempt.join(name),
363                    durable::WriteOptions {
364                        mode: durable::PublicationMode::Replace,
365                        permissions: 0o666,
366                    },
367                    bytes,
368                ) {
369                    eprintln!("could not record version evidence: {record_error}");
370                }
371            }
372        }
373        return Err(error);
374    }
375    Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use ic_host_artifacts::artifact::encode_gzip;
382    use std::os::unix::fs::{PermissionsExt as _, symlink};
383
384    fn run_in_subprocess() -> bool {
385        let current = std::thread::current();
386        let name = current.name().expect("registered asset test name");
387        if std::env::var("IC_TESTKIT_ASSET_CASE").as_deref() == Ok(name) {
388            return false;
389        }
390        // A library sibling's fork can retain a writable executable descriptor.
391        // Re-exec before writing fixtures so only this case owns their lifetime.
392        let mut child = OwnedChild::spawn(
393            Command::new(std::env::current_exe().expect("library test executable"))
394                .args(["--exact", name, "--nocapture"])
395                .env("IC_TESTKIT_ASSET_CASE", name)
396                .stdin(Stdio::null())
397                .stdout(Stdio::piped())
398                .stderr(Stdio::piped()),
399            CleanupPolicy::KillAndWait,
400        )
401        .expect("spawn isolated asset test");
402        let completed = communicate_child(
403            &mut child,
404            None,
405            OutputLimits {
406                stdout: OutputLimit::Terminate(64 * 1024),
407                stderr: OutputLimit::Terminate(64 * 1024),
408                timeout: Some(Duration::from_secs(120)),
409            },
410            SuccessfulExit::Cleanup,
411            &mut || false,
412        )
413        .expect("capture and reap isolated asset test");
414        assert!(
415            completed.status.success(),
416            "isolated asset test failed: stdout={} stderr={}",
417            String::from_utf8_lossy(&completed.stdout),
418            String::from_utf8_lossy(&completed.stderr),
419        );
420        true
421    }
422
423    struct Fixture {
424        root: tempfile::TempDir,
425    }
426    impl Fixture {
427        fn new() -> Self {
428            let root = tempfile::Builder::new()
429                .prefix("testkit-provision-")
430                .permissions(fs::Permissions::from_mode(0o700))
431                .tempdir_in(std::env::temp_dir().canonicalize().unwrap())
432                .unwrap();
433            Self { root }
434        }
435    }
436    fn archive(version: &str) -> (Asset, Vec<u8>) {
437        let script = format!("#!/bin/sh\nprintf 'pocket-ic-server {version}\\n'\n");
438        let mut bytes = Vec::new();
439        encode_gzip(script.as_bytes(), &mut bytes, 1, 65536).unwrap();
440        (
441            Asset {
442                host: "fixture",
443                digest: Sha256Digest::compute(&bytes),
444            },
445            bytes,
446        )
447    }
448
449    #[test]
450    fn authenticated_setup_reuses_offline_and_refuses_changed_bytes_before_execution() {
451        if run_in_subprocess() {
452            return;
453        }
454        let root = Fixture::new();
455        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
456        let executable = setup_with(root.root.path(), &asset, |path| {
457            fs::write(path, &bytes)?;
458            Ok(())
459        })
460        .unwrap();
461        let original = fs::read(&executable).unwrap();
462        assert_eq!(
463            setup_with(root.root.path(), &asset, |_| panic!(
464                "verified reuse must not download"
465            ))
466            .unwrap(),
467            executable
468        );
469        let entries = fs::read_dir(root.root.path()).unwrap().count();
470        assert_eq!(
471            check_bundle(&bundle_path(root.root.path(), &asset), &asset).unwrap(),
472            executable
473        );
474        assert_eq!(fs::read_dir(root.root.path()).unwrap().count(), entries);
475        let marker = root.root.path().join("executed");
476        fs::write(
477            &executable,
478            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
479        )
480        .unwrap();
481        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
482        assert!(!marker.exists());
483        fs::write(&executable, original).unwrap();
484        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_ok());
485        let saved = root.root.path().join("saved");
486        fs::rename(&executable, &saved).unwrap();
487        symlink(&saved, &executable).unwrap();
488        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
489    }
490
491    #[test]
492    fn offline_check_authenticates_before_decoding_and_rejects_invalid_gzip() {
493        use ic_host_artifacts::artifact::{ArtifactError, GzipError};
494
495        if run_in_subprocess() {
496            return;
497        }
498
499        let root = Fixture::new();
500        let (mut asset, original) = archive(POCKET_IC_SERVER_VERSION);
501        let executable = setup_with(root.root.path(), &asset, |path| {
502            fs::write(path, &original)?;
503            Ok(())
504        })
505        .unwrap();
506        let installed = fs::read(&executable).unwrap();
507        let bundle = bundle_path(root.root.path(), &asset);
508        let path = bundle.join("archive.gz");
509
510        // Both authentication and decoding would fail; authentication wins.
511        fs::write(&path, b"not gzip").unwrap();
512        let error = check_bundle(&bundle, &asset).unwrap_err();
513        assert!(matches!(
514            error.downcast_ref::<ArtifactError>(),
515            Some(ArtifactError::DigestMismatch { .. })
516        ));
517
518        let mut bad_crc = original.clone();
519        let crc = bad_crc.len() - 8;
520        bad_crc[crc] ^= 1;
521        let mut bad_length = original.clone();
522        let length = bad_length.len() - 4;
523        bad_length[length] ^= 1;
524        for bytes in [
525            b"not gzip".to_vec(),
526            original[..original.len() - 1].to_vec(),
527            bad_crc,
528            bad_length,
529            [original.as_slice(), b"trailing"].concat(),
530            [original.as_slice(), original.as_slice()].concat(),
531        ] {
532            fs::write(&path, &bytes).unwrap();
533            asset.digest = Sha256Digest::compute(&bytes);
534            let error = check_bundle(&bundle, &asset).unwrap_err();
535            // Gzip refusal occurs before executable admission/version execution.
536            assert!(error.downcast_ref::<GzipError>().is_some());
537            assert_eq!(fs::read(&executable).unwrap(), installed);
538            assert_eq!(fs::read(&path).unwrap(), bytes);
539        }
540        fs::write(&path, &original).unwrap();
541        asset.digest = Sha256Digest::compute(&original);
542        assert_eq!(check_bundle(&bundle, &asset).unwrap(), executable);
543    }
544
545    #[test]
546    fn failed_setup_retains_evidence_and_previous_bundles_then_can_retry() {
547        if run_in_subprocess() {
548            return;
549        }
550        let root = Fixture::new();
551        let previous = root.root.path().join("previous-version");
552        fs::create_dir(&previous).unwrap();
553        fs::write(previous.join("pocket-ic"), b"previous bytes").unwrap();
554        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
555        assert!(
556            setup_with(root.root.path(), &asset, |path| {
557                fs::write(path, b"partial download")?;
558                Err("interrupted download".into())
559            })
560            .is_err()
561        );
562        assert!(!bundle_path(root.root.path(), &asset).exists());
563        let failed = fs::read_dir(root.root.path())
564            .unwrap()
565            .find_map(|entry| {
566                let path = entry.unwrap().path();
567                path.join("failure.txt").is_file().then_some(path)
568            })
569            .unwrap();
570        assert_eq!(
571            fs::read(failed.join("archive.gz")).unwrap(),
572            b"partial download"
573        );
574        assert!(
575            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
576                let _ = setup_with(root.root.path(), &asset, |path| {
577                    fs::write(path, b"interrupted candidate")?;
578                    panic!("injected interruption before admission");
579                });
580            }))
581            .is_err()
582        );
583        assert!(!bundle_path(root.root.path(), &asset).exists());
584        assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
585            fs::read(entry.unwrap().path().join("archive.gz"))
586                .ok()
587                .as_deref()
588                == Some(b"interrupted candidate")
589        }));
590        assert!(
591            setup_with(root.root.path(), &asset, |path| {
592                fs::write(path, &bytes)?;
593                Ok(())
594            })
595            .is_ok()
596        );
597        assert!(failed.join("failure.txt").is_file());
598        assert_eq!(
599            fs::read(previous.join("pocket-ic")).unwrap(),
600            b"previous bytes"
601        );
602    }
603
604    #[test]
605    fn digest_decode_and_version_failures_never_publish() {
606        if run_in_subprocess() {
607            return;
608        }
609        for mode in [0, 1, 2] {
610            let root = Fixture::new();
611            let (mut asset, mut bytes) = archive("15.0.0");
612            if mode == 0 {
613                asset.digest = Sha256Digest::compute(b"wrong");
614            }
615            if mode == 1 {
616                bytes = b"not gzip".to_vec();
617                asset.digest = Sha256Digest::compute(&bytes);
618            }
619            assert!(
620                setup_with(root.root.path(), &asset, |path| {
621                    fs::write(path, &bytes)?;
622                    Ok(())
623                })
624                .is_err()
625            );
626            assert!(!bundle_path(root.root.path(), &asset).exists());
627        }
628    }
629
630    #[test]
631    fn redirected_paths_are_refused_without_touching_the_target() {
632        if run_in_subprocess() {
633            return;
634        }
635        let root = Fixture::new();
636        let (asset, _) = archive(POCKET_IC_SERVER_VERSION);
637        let target = root.root.path().join("target");
638        fs::create_dir(&target).unwrap();
639        let redirected = root.root.path().join("redirected");
640        symlink(&target, &redirected).unwrap();
641        assert!(
642            setup_with(&redirected.join("child"), &asset, |_| panic!(
643                "redirected ancestor reached downloader"
644            ))
645            .is_err()
646        );
647        assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
648        symlink(&target, root.root.path().join(".setup-v1.lock")).unwrap();
649        assert!(
650            setup_with(root.root.path(), &asset, |_| panic!(
651                "redirected lock reached downloader"
652            ))
653            .is_err()
654        );
655    }
656
657    #[test]
658    fn replaced_staging_directory_is_not_published() {
659        if run_in_subprocess() {
660            return;
661        }
662        let root = Fixture::new();
663        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
664        let retained = root.root.path().join("retained-original-stage");
665        let error = setup_with(root.root.path(), &asset, |archive| {
666            fs::write(archive, &bytes)?;
667            let stage = archive.parent().unwrap();
668            fs::rename(stage, &retained)?;
669            fs::create_dir(stage)?;
670            fs::write(archive, &bytes)?;
671            Ok(())
672        })
673        .expect_err("replacement must not inherit the held directory claim");
674        assert!(matches!(
675            error.downcast_ref::<durable::DirectoryCommitError<io::Error>>(),
676            Some(durable::DirectoryCommitError::Prepare(source))
677                if source.kind() == io::ErrorKind::InvalidInput
678        ));
679        assert!(!bundle_path(root.root.path(), &asset).exists());
680        assert_eq!(fs::read(retained.join("archive.gz")).unwrap(), bytes);
681    }
682
683    #[test]
684    fn occupied_destination_preserves_both_bundle_and_verified_staging() {
685        if run_in_subprocess() {
686            return;
687        }
688        let root = Fixture::new();
689        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
690        let bundle = bundle_path(root.root.path(), &asset);
691        let mut attempt = None;
692        let error = setup_with(root.root.path(), &asset, |archive| {
693            fs::write(archive, &bytes)?;
694            attempt = archive.parent().map(Path::to_owned);
695            fs::create_dir(&bundle)?;
696            fs::write(bundle.join("unrelated"), b"retained destination")?;
697            Ok(())
698        })
699        .unwrap_err();
700        assert!(matches!(
701            error.downcast_ref::<durable::DirectoryCommitError<io::Error>>(),
702            Some(durable::DirectoryCommitError::Conflict)
703        ));
704        assert_eq!(
705            fs::read(bundle.join("unrelated")).unwrap(),
706            b"retained destination"
707        );
708        let attempt = attempt.unwrap();
709        assert_eq!(fs::read(attempt.join("archive.gz")).unwrap(), bytes);
710        assert!(attempt.join("pocket-ic").is_file());
711    }
712
713    #[test]
714    fn concurrent_setups_download_once_and_recheck_under_the_lock() {
715        if run_in_subprocess() {
716            return;
717        }
718        let root = Fixture::new();
719        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
720        let calls = AtomicU64::new(0);
721        std::thread::scope(|scope| {
722            let handles = (0..2)
723                .map(|_| {
724                    scope.spawn(|| {
725                        setup_with(root.root.path(), &asset, |path| {
726                            calls.fetch_add(1, Ordering::Relaxed);
727                            fs::write(path, &bytes)?;
728                            Ok(())
729                        })
730                        .unwrap()
731                    })
732                })
733                .collect::<Vec<_>>();
734            for handle in handles {
735                assert_eq!(
736                    handle.join().unwrap(),
737                    bundle_path(root.root.path(), &asset).join("pocket-ic")
738                );
739            }
740        });
741        assert_eq!(calls.load(Ordering::Relaxed), 1);
742    }
743}