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