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