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