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::owned_directory::OwnedDirectory,
410    }
411    impl Fixture {
412        fn new() -> Self {
413            let root = ic_host_fs::owned_directory::OwnedDirectory::new(
414                ic_host_fs::directory::create_unique_directory(
415                    &std::env::temp_dir().canonicalize().unwrap(),
416                    "testkit-provision",
417                    0o700,
418                    None,
419                )
420                .unwrap(),
421            );
422            Self { root }
423        }
424    }
425    fn archive(version: &str) -> (Asset, Vec<u8>) {
426        let script = format!("#!/bin/sh\nprintf 'pocket-ic-server {version}\\n'\n");
427        let mut bytes = Vec::new();
428        encode_gzip(script.as_bytes(), &mut bytes, 1, 65536).unwrap();
429        (
430            Asset {
431                host: "fixture",
432                digest: Sha256Digest::compute(&bytes),
433            },
434            bytes,
435        )
436    }
437
438    #[test]
439    fn authenticated_setup_reuses_offline_and_refuses_changed_bytes_before_execution() {
440        if run_in_subprocess() {
441            return;
442        }
443        let root = Fixture::new();
444        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
445        let executable = setup_with(root.root.path(), &asset, |path| {
446            fs::write(path, &bytes)?;
447            Ok(())
448        })
449        .unwrap();
450        let original = fs::read(&executable).unwrap();
451        assert_eq!(
452            setup_with(root.root.path(), &asset, |_| panic!(
453                "verified reuse must not download"
454            ))
455            .unwrap(),
456            executable
457        );
458        let entries = fs::read_dir(root.root.path()).unwrap().count();
459        assert_eq!(
460            check_bundle(&bundle_path(root.root.path(), &asset), &asset).unwrap(),
461            executable
462        );
463        assert_eq!(fs::read_dir(root.root.path()).unwrap().count(), entries);
464        let marker = root.root.path().join("executed");
465        fs::write(
466            &executable,
467            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
468        )
469        .unwrap();
470        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
471        assert!(!marker.exists());
472        fs::write(&executable, original).unwrap();
473        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_ok());
474        let saved = root.root.path().join("saved");
475        fs::rename(&executable, &saved).unwrap();
476        symlink(&saved, &executable).unwrap();
477        assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
478    }
479
480    #[test]
481    fn offline_check_authenticates_before_decoding_and_rejects_invalid_gzip() {
482        use ic_host_artifacts::artifact::{ArtifactError, GzipError};
483
484        if run_in_subprocess() {
485            return;
486        }
487
488        let root = Fixture::new();
489        let (mut asset, original) = archive(POCKET_IC_SERVER_VERSION);
490        let executable = setup_with(root.root.path(), &asset, |path| {
491            fs::write(path, &original)?;
492            Ok(())
493        })
494        .unwrap();
495        let installed = fs::read(&executable).unwrap();
496        let bundle = bundle_path(root.root.path(), &asset);
497        let path = bundle.join("archive.gz");
498
499        // Both authentication and decoding would fail; authentication wins.
500        fs::write(&path, b"not gzip").unwrap();
501        let error = check_bundle(&bundle, &asset).unwrap_err();
502        assert!(matches!(
503            error.downcast_ref::<ArtifactError>(),
504            Some(ArtifactError::DigestMismatch { .. })
505        ));
506
507        let mut bad_crc = original.clone();
508        let crc = bad_crc.len() - 8;
509        bad_crc[crc] ^= 1;
510        let mut bad_length = original.clone();
511        let length = bad_length.len() - 4;
512        bad_length[length] ^= 1;
513        for bytes in [
514            b"not gzip".to_vec(),
515            original[..original.len() - 1].to_vec(),
516            bad_crc,
517            bad_length,
518            [original.as_slice(), b"trailing"].concat(),
519            [original.as_slice(), original.as_slice()].concat(),
520        ] {
521            fs::write(&path, &bytes).unwrap();
522            asset.digest = Sha256Digest::compute(&bytes);
523            let error = check_bundle(&bundle, &asset).unwrap_err();
524            // Gzip refusal occurs before executable admission/version execution.
525            assert!(error.downcast_ref::<GzipError>().is_some());
526            assert_eq!(fs::read(&executable).unwrap(), installed);
527            assert_eq!(fs::read(&path).unwrap(), bytes);
528        }
529        fs::write(&path, &original).unwrap();
530        asset.digest = Sha256Digest::compute(&original);
531        assert_eq!(check_bundle(&bundle, &asset).unwrap(), executable);
532    }
533
534    #[test]
535    fn setup_rejects_corrupt_payloads_without_execution_or_publication() {
536        use ic_host_artifacts::artifact::GzipError;
537
538        if run_in_subprocess() {
539            return;
540        }
541        let root = Fixture::new();
542        let marker = root.root.path().join("executed");
543        let script = format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display());
544        let mut original = Vec::new();
545        encode_gzip(script.as_bytes(), &mut original, 1, 65536).unwrap();
546        let mut bad_crc = original.clone();
547        let crc = bad_crc.len() - 8;
548        bad_crc[crc] ^= 1;
549        for bytes in [
550            bad_crc,
551            original[..original.len() - 1].to_vec(),
552            [original.as_slice(), b"trailing"].concat(),
553        ] {
554            let asset = Asset {
555                host: "fixture",
556                digest: Sha256Digest::compute(&bytes),
557            };
558            let error = setup_with(root.root.path(), &asset, |path| {
559                fs::write(path, &bytes)?;
560                Ok(())
561            })
562            .unwrap_err();
563            assert!(matches!(
564                error.downcast_ref::<durable::NamedWriteError<GzipError>>(),
565                Some(durable::NamedWriteError::Producer { .. })
566            ));
567            assert!(!marker.exists());
568            assert!(!bundle_path(root.root.path(), &asset).exists());
569            assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
570                let path = entry.unwrap().path();
571                path.join("failure.txt").is_file()
572                    && fs::read(path.join("archive.gz")).ok().as_deref() == Some(bytes.as_slice())
573            }));
574        }
575    }
576
577    #[test]
578    fn failed_setup_retains_evidence_and_previous_bundles_then_can_retry() {
579        if run_in_subprocess() {
580            return;
581        }
582        let root = Fixture::new();
583        let previous = root.root.path().join("previous-version");
584        fs::create_dir(&previous).unwrap();
585        fs::write(previous.join("pocket-ic"), b"previous bytes").unwrap();
586        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
587        assert!(
588            setup_with(root.root.path(), &asset, |path| {
589                fs::write(path, b"partial download")?;
590                Err("interrupted download".into())
591            })
592            .is_err()
593        );
594        assert!(!bundle_path(root.root.path(), &asset).exists());
595        let failed = fs::read_dir(root.root.path())
596            .unwrap()
597            .find_map(|entry| {
598                let path = entry.unwrap().path();
599                path.join("failure.txt").is_file().then_some(path)
600            })
601            .unwrap();
602        assert_eq!(
603            fs::read(failed.join("archive.gz")).unwrap(),
604            b"partial download"
605        );
606        assert!(
607            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
608                let _ = setup_with(root.root.path(), &asset, |path| {
609                    fs::write(path, b"interrupted candidate")?;
610                    panic!("injected interruption before admission");
611                });
612            }))
613            .is_err()
614        );
615        assert!(!bundle_path(root.root.path(), &asset).exists());
616        assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
617            fs::read(entry.unwrap().path().join("archive.gz"))
618                .ok()
619                .as_deref()
620                == Some(b"interrupted candidate")
621        }));
622        assert!(
623            setup_with(root.root.path(), &asset, |path| {
624                fs::write(path, &bytes)?;
625                Ok(())
626            })
627            .is_ok()
628        );
629        assert!(failed.join("failure.txt").is_file());
630        assert_eq!(
631            fs::read(previous.join("pocket-ic")).unwrap(),
632            b"previous bytes"
633        );
634    }
635
636    #[test]
637    fn digest_decode_and_version_failures_never_publish() {
638        if run_in_subprocess() {
639            return;
640        }
641        for mode in [0, 1, 2] {
642            let root = Fixture::new();
643            let (mut asset, mut bytes) = archive("15.0.0");
644            if mode == 0 {
645                asset.digest = Sha256Digest::compute(b"wrong");
646            }
647            if mode == 1 {
648                bytes = b"not gzip".to_vec();
649                asset.digest = Sha256Digest::compute(&bytes);
650            }
651            assert!(
652                setup_with(root.root.path(), &asset, |path| {
653                    fs::write(path, &bytes)?;
654                    Ok(())
655                })
656                .is_err()
657            );
658            assert!(!bundle_path(root.root.path(), &asset).exists());
659        }
660    }
661
662    #[test]
663    fn redirected_paths_are_refused_without_touching_the_target() {
664        if run_in_subprocess() {
665            return;
666        }
667        let root = Fixture::new();
668        let (asset, _) = archive(POCKET_IC_SERVER_VERSION);
669        let target = root.root.path().join("target");
670        fs::create_dir(&target).unwrap();
671        let redirected = root.root.path().join("redirected");
672        symlink(&target, &redirected).unwrap();
673        assert!(
674            setup_with(&redirected.join("child"), &asset, |_| panic!(
675                "redirected ancestor reached downloader"
676            ))
677            .is_err()
678        );
679        assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
680        symlink(&target, root.root.path().join(".setup-v1.lock")).unwrap();
681        assert!(
682            setup_with(root.root.path(), &asset, |_| panic!(
683                "redirected lock reached downloader"
684            ))
685            .is_err()
686        );
687    }
688
689    #[test]
690    fn replaced_staging_directory_is_not_published() {
691        if run_in_subprocess() {
692            return;
693        }
694        let root = Fixture::new();
695        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
696        let retained = root.root.path().join("retained-original-stage");
697        let error = setup_with(root.root.path(), &asset, |archive| {
698            fs::write(archive, &bytes)?;
699            let stage = archive.parent().unwrap();
700            fs::rename(stage, &retained)?;
701            fs::create_dir(stage)?;
702            fs::write(archive, &bytes)?;
703            Ok(())
704        })
705        .expect_err("replacement must not inherit the held directory claim");
706        assert!(matches!(
707            error.downcast_ref::<durable::DirectoryCommitError<io::Error>>(),
708            Some(durable::DirectoryCommitError::Prepare(source))
709                if source.kind() == io::ErrorKind::InvalidInput
710        ));
711        assert!(!bundle_path(root.root.path(), &asset).exists());
712        assert_eq!(fs::read(retained.join("archive.gz")).unwrap(), bytes);
713    }
714
715    #[test]
716    fn occupied_destination_preserves_both_bundle_and_verified_staging() {
717        if run_in_subprocess() {
718            return;
719        }
720        let root = Fixture::new();
721        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
722        let bundle = bundle_path(root.root.path(), &asset);
723        let mut attempt = None;
724        let error = setup_with(root.root.path(), &asset, |archive| {
725            fs::write(archive, &bytes)?;
726            attempt = archive.parent().map(Path::to_owned);
727            fs::create_dir(&bundle)?;
728            fs::write(bundle.join("unrelated"), b"retained destination")?;
729            Ok(())
730        })
731        .unwrap_err();
732        assert!(matches!(
733            error.downcast_ref::<durable::DirectoryCommitError<io::Error>>(),
734            Some(durable::DirectoryCommitError::Conflict)
735        ));
736        assert_eq!(
737            fs::read(bundle.join("unrelated")).unwrap(),
738            b"retained destination"
739        );
740        let attempt = attempt.unwrap();
741        assert_eq!(fs::read(attempt.join("archive.gz")).unwrap(), bytes);
742        assert!(attempt.join("pocket-ic").is_file());
743    }
744
745    #[test]
746    fn concurrent_setups_download_once_and_recheck_under_the_lock() {
747        if run_in_subprocess() {
748            return;
749        }
750        let root = Fixture::new();
751        let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
752        let calls = AtomicU64::new(0);
753        std::thread::scope(|scope| {
754            let handles = (0..2)
755                .map(|_| {
756                    scope.spawn(|| {
757                        setup_with(root.root.path(), &asset, |path| {
758                            calls.fetch_add(1, Ordering::Relaxed);
759                            fs::write(path, &bytes)?;
760                            Ok(())
761                        })
762                        .unwrap()
763                    })
764                })
765                .collect::<Vec<_>>();
766            for handle in handles {
767                assert_eq!(
768                    handle.join().unwrap(),
769                    bundle_path(root.root.path(), &asset).join("pocket-ic")
770                );
771            }
772        });
773        assert_eq!(calls.load(Ordering::Relaxed), 1);
774    }
775}