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