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