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