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