1use super::POCKET_IC_SERVER_VERSION;
8use ic_host_artifacts::artifact::{Sha256Digest, copy_gzip, hash_gzip, hash_reader};
9use ic_host_fs::path::validate_physical_directory_allow_missing;
10use ic_host_fs::{durable, read::read_file_no_follow};
11use ic_host_process::{
12 child::CleanupPolicy,
13 tool::{
14 AdmittedTool, ExecutionContext, OutputLimit, OutputLimits, VersionSpec,
15 capture_group_command_with_cancellation,
16 },
17};
18use std::{
19 error::Error,
20 ffi::OsString,
21 fs::{self, File},
22 io,
23 path::{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);
33pub 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 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 validate_physical_directory_allow_missing(&absolute)?;
82 Ok(absolute.components().collect())
83}
84
85fn bundle_path(root: &Path, asset: &Asset) -> PathBuf {
86 root.join(format!("{}-{}", POCKET_IC_SERVER_VERSION, asset.host))
87}
88
89fn authenticated_archive(archive: &Path, digest: Sha256Digest) -> Result<Vec<u8>> {
90 let bytes = read_file_no_follow(archive, ARCHIVE_LIMIT)?;
91 hash_reader(bytes.as_slice(), ARCHIVE_LIMIT as u64)?.verify(digest)?;
92 Ok(bytes)
93}
94
95fn admit(executable: &Path, digest: Sha256Digest) -> Result<()> {
96 let directory = executable.parent().ok_or("missing executable parent")?;
97 AdmittedTool::admit_no_follow(
98 &VersionSpec {
99 executable,
100 executable_bytes: EXECUTABLE_LIMIT as u64,
101 version_arguments: &[OsString::from("--version")],
102 },
103 digest,
104 &format!("pocket-ic-server {POCKET_IC_SERVER_VERSION}"),
105 &ExecutionContext {
106 current_dir: directory,
107 environment: &[],
108 },
109 OutputLimits {
110 stdout: OutputLimit::Terminate(4096),
111 stderr: OutputLimit::Terminate(4096),
112 timeout: Some(Duration::from_secs(10)),
113 },
114 )?;
115 Ok(())
116}
117
118fn check_bundle(bundle: &Path, asset: &Asset) -> Result<PathBuf> {
119 let metadata = fs::symlink_metadata(bundle)?;
120 if !metadata.is_dir() || metadata.is_symlink() {
121 return Err("PocketIC bundle must be a physical directory".into());
122 }
123 let bytes = authenticated_archive(&bundle.join("archive.gz"), asset.digest)?;
124 let digest = hash_gzip(&bytes, ARCHIVE_LIMIT, EXECUTABLE_LIMIT as u64)?.sha256;
125 drop(bytes);
126 let executable = bundle.join("pocket-ic");
127 admit(&executable, digest)?;
128 Ok(executable)
129}
130
131pub fn check(directory: &Path) -> Result<PathBuf> {
142 let asset = selected_asset()?;
143 check_bundle(&bundle_path(&root_path(directory)?, &asset), &asset)
144}
145
146pub fn setup(directory: &Path, mut interrupted: impl FnMut() -> bool) -> Result<PathBuf> {
157 let asset = selected_asset()?;
158 setup_with(directory, &asset, |archive| {
159 let url = format!(
160 "https://github.com/dfinity/pocketic/releases/download/{POCKET_IC_SERVER_VERSION}/pocket-ic-{}.gz",
161 asset.host
162 );
163 let mut command = Command::new("curl");
164 command
165 .args([
166 "--proto",
167 "=https",
168 "--proto-redir",
169 "=https",
170 "--tlsv1.2",
171 "--fail",
172 "--silent",
173 "--show-error",
174 "--location",
175 "--connect-timeout",
176 "15",
177 "--max-time",
178 "300",
179 "--max-filesize",
180 "83886080",
181 "--output",
182 ])
183 .arg(archive)
184 .arg(url)
185 .stdin(Stdio::null())
186 .stdout(Stdio::piped())
187 .stderr(Stdio::piped());
188 let outcome = capture_group_command_with_cancellation(
189 &mut command,
190 OutputLimits {
191 stdout: OutputLimit::Terminate(4096),
192 stderr: OutputLimit::Terminate(64 * 1024),
193 timeout: Some(Duration::from_secs(310)),
194 },
195 CleanupPolicy::KillAndWait,
196 None,
197 &mut interrupted,
198 )
199 .and_then(ic_host_process::tool::CompletedExecution::require_success);
200 if let Some(stderr) = outcome
201 .as_ref()
202 .ok()
203 .map(|completed| &completed.stderr)
204 .or_else(|| {
205 outcome
206 .as_ref()
207 .err()
208 .and_then(|error| error.evidence())
209 .map(|evidence| &evidence.stderr)
210 })
211 && let Err(record_error) = durable::write_bytes(
212 &archive
213 .parent()
214 .ok_or("missing archive parent")?
215 .join("download.stderr"),
216 durable::WriteOptions {
217 mode: durable::PublicationMode::Replace,
218 permissions: 0o666,
219 },
220 stderr,
221 )
222 {
223 if outcome.is_ok() {
224 return Err(record_error.into());
225 }
226 eprintln!("could not record download stderr: {record_error}");
228 }
229 outcome?;
230 Ok(())
231 })
232}
233
234fn setup_with(
235 directory: &Path,
236 asset: &Asset,
237 download: impl FnOnce(&Path) -> Result<()>,
238) -> Result<PathBuf> {
239 let root = root_path(directory)?;
240 let lock = durable::open_regular_lock_file_with_parents(&root.join(".setup-v1.lock"))?;
243 durable::lock_exclusive(&lock)?;
244 let bundle = bundle_path(&root, asset);
245 let attempt = root.join(format!(
246 ".setup-v1-{}-{}",
247 std::process::id(),
248 SEQUENCE.fetch_add(1, Ordering::Relaxed)
249 ));
250 let staging_directory = match fs::symlink_metadata(&bundle) {
251 Ok(_) => {
252 check_bundle(&bundle, asset)?;
253 None
254 }
255 Err(error) if error.kind() == io::ErrorKind::NotFound => {
256 let held = durable::create_private_directory(&attempt)?;
257 eprintln!("PocketIC setup evidence: {}", attempt.display());
258 if let Err(error) = install(&attempt, asset, download) {
259 if let Err(record_error) = durable::write_bytes(
260 &attempt.join("failure.txt"),
261 durable::WriteOptions {
262 mode: durable::PublicationMode::Replace,
263 permissions: 0o666,
264 },
265 error.to_string().as_bytes(),
266 ) {
267 eprintln!("could not record setup failure: {record_error}");
268 }
269 return Err(error);
270 }
271 Some(held)
272 }
273 Err(error) => return Err(error.into()),
274 };
275 durable::commit_existing_directory_with(
279 &attempt,
280 &bundle,
281 |selected, path| -> io::Result<()> {
282 if let Some(held) = &staging_directory {
283 durable::admit_directory(path, held)?;
284 }
285 selected.sync_all()?;
286 Ok(())
287 },
288 |_, _| Ok(()),
289 )?;
290 eprintln!("PocketIC setup evidence retained: {}", bundle.display());
291 Ok(bundle.join("pocket-ic"))
292}
293
294fn install(
295 attempt: &Path,
296 asset: &Asset,
297 download: impl FnOnce(&Path) -> Result<()>,
298) -> Result<()> {
299 let archive = attempt.join("archive.gz");
300 download(&archive)?;
301 let bytes = authenticated_archive(&archive, asset.digest)?;
302 File::open(&archive)?.sync_all()?;
303 let executable = attempt.join("pocket-ic");
304 let identity = durable::write_with(
305 &executable,
306 durable::WriteOptions {
307 mode: durable::PublicationMode::CreateNew,
308 permissions: 0o755,
309 },
310 |file| copy_gzip(&bytes, file, ARCHIVE_LIMIT, EXECUTABLE_LIMIT as u64),
311 )?;
312 drop(bytes);
313 if let Err(error) = admit(&executable, identity.sha256) {
316 if let Some(evidence) = error
317 .downcast_ref::<ic_host_process::tool::ToolError>()
318 .and_then(|error| error.evidence())
319 {
320 for (name, bytes) in [
321 ("version.stdout", &evidence.stdout),
322 ("version.stderr", &evidence.stderr),
323 ] {
324 if let Err(record_error) = durable::write_bytes(
325 &attempt.join(name),
326 durable::WriteOptions {
327 mode: durable::PublicationMode::Replace,
328 permissions: 0o666,
329 },
330 bytes,
331 ) {
332 eprintln!("could not record version evidence: {record_error}");
333 }
334 }
335 }
336 return Err(error);
337 }
338 Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use ic_host_artifacts::artifact::encode_gzip;
345 use ic_host_process::{
346 child::OwnedChild,
347 tool::{SuccessfulExit, communicate_child},
348 };
349 use std::os::unix::fs::symlink;
350
351 fn run_in_subprocess() -> bool {
352 let current = std::thread::current();
353 let name = current.name().expect("registered asset test name");
354 if std::env::var("IC_TESTKIT_ASSET_CASE").as_deref() == Ok(name) {
355 return false;
356 }
357 let mut child = OwnedChild::spawn(
360 Command::new(std::env::current_exe().expect("library test executable"))
361 .args(["--exact", name, "--nocapture"])
362 .env("IC_TESTKIT_ASSET_CASE", name)
363 .stdin(Stdio::null())
364 .stdout(Stdio::piped())
365 .stderr(Stdio::piped()),
366 CleanupPolicy::KillAndWait,
367 )
368 .expect("spawn isolated asset test");
369 let completed = communicate_child(
370 &mut child,
371 None,
372 OutputLimits {
373 stdout: OutputLimit::Terminate(64 * 1024),
374 stderr: OutputLimit::Terminate(64 * 1024),
375 timeout: Some(Duration::from_secs(120)),
376 },
377 SuccessfulExit::Cleanup,
378 &mut || false,
379 )
380 .expect("capture and reap isolated asset test");
381 assert!(
382 completed.status.success(),
383 "isolated asset test failed: stdout={} stderr={}",
384 String::from_utf8_lossy(&completed.stdout),
385 String::from_utf8_lossy(&completed.stderr),
386 );
387 true
388 }
389
390 struct Fixture {
391 root: ic_host_fs::testing::FixtureDirectory,
392 }
393 impl Fixture {
394 fn new() -> Self {
395 let root = ic_host_fs::testing::FixtureDirectory::new(
396 &std::env::temp_dir(),
397 "testkit-provision",
398 )
399 .unwrap();
400 Self { root }
401 }
402 }
403 fn archive(version: &str) -> (Asset, Vec<u8>) {
404 let script = format!("#!/bin/sh\nprintf 'pocket-ic-server {version}\\n'\n");
405 let mut bytes = Vec::new();
406 encode_gzip(script.as_bytes(), &mut bytes, 1, 65536).unwrap();
407 (
408 Asset {
409 host: "fixture",
410 digest: Sha256Digest::compute(&bytes),
411 },
412 bytes,
413 )
414 }
415
416 #[test]
417 fn authenticated_setup_reuses_offline_and_refuses_changed_bytes_before_execution() {
418 if run_in_subprocess() {
419 return;
420 }
421 let root = Fixture::new();
422 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
423 let executable = setup_with(root.root.path(), &asset, |path| {
424 fs::write(path, &bytes)?;
425 Ok(())
426 })
427 .unwrap();
428 let original = fs::read(&executable).unwrap();
429 assert_eq!(
430 setup_with(root.root.path(), &asset, |_| panic!(
431 "verified reuse must not download"
432 ))
433 .unwrap(),
434 executable
435 );
436 let entries = fs::read_dir(root.root.path()).unwrap().count();
437 assert_eq!(
438 check_bundle(&bundle_path(root.root.path(), &asset), &asset).unwrap(),
439 executable
440 );
441 assert_eq!(fs::read_dir(root.root.path()).unwrap().count(), entries);
442 let marker = root.root.path().join("executed");
443 fs::write(
444 &executable,
445 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
446 )
447 .unwrap();
448 assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
449 assert!(!marker.exists());
450 fs::write(&executable, original).unwrap();
451 assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_ok());
452 let saved = root.root.path().join("saved");
453 fs::rename(&executable, &saved).unwrap();
454 symlink(&saved, &executable).unwrap();
455 assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
456 }
457
458 #[test]
459 fn offline_check_authenticates_before_decoding_and_rejects_invalid_gzip() {
460 use ic_host_artifacts::artifact::{ArtifactError, GzipError};
461
462 if run_in_subprocess() {
463 return;
464 }
465
466 let root = Fixture::new();
467 let (mut asset, original) = archive(POCKET_IC_SERVER_VERSION);
468 let executable = setup_with(root.root.path(), &asset, |path| {
469 fs::write(path, &original)?;
470 Ok(())
471 })
472 .unwrap();
473 let installed = fs::read(&executable).unwrap();
474 let bundle = bundle_path(root.root.path(), &asset);
475 let path = bundle.join("archive.gz");
476
477 fs::write(&path, b"not gzip").unwrap();
479 let error = check_bundle(&bundle, &asset).unwrap_err();
480 assert!(matches!(
481 error.downcast_ref::<ArtifactError>(),
482 Some(ArtifactError::DigestMismatch { .. })
483 ));
484
485 let mut bad_crc = original.clone();
486 let crc = bad_crc.len() - 8;
487 bad_crc[crc] ^= 1;
488 let mut bad_length = original.clone();
489 let length = bad_length.len() - 4;
490 bad_length[length] ^= 1;
491 for bytes in [
492 b"not gzip".to_vec(),
493 original[..original.len() - 1].to_vec(),
494 bad_crc,
495 bad_length,
496 [original.as_slice(), b"trailing"].concat(),
497 [original.as_slice(), original.as_slice()].concat(),
498 ] {
499 fs::write(&path, &bytes).unwrap();
500 asset.digest = Sha256Digest::compute(&bytes);
501 let error = check_bundle(&bundle, &asset).unwrap_err();
502 assert!(error.downcast_ref::<GzipError>().is_some());
504 assert_eq!(fs::read(&executable).unwrap(), installed);
505 assert_eq!(fs::read(&path).unwrap(), bytes);
506 }
507 fs::write(&path, &original).unwrap();
508 asset.digest = Sha256Digest::compute(&original);
509 assert_eq!(check_bundle(&bundle, &asset).unwrap(), executable);
510 }
511
512 #[test]
513 fn setup_rejects_corrupt_payloads_without_execution_or_publication() {
514 use ic_host_artifacts::artifact::GzipError;
515
516 if run_in_subprocess() {
517 return;
518 }
519 let root = Fixture::new();
520 let marker = root.root.path().join("executed");
521 let script = format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display());
522 let mut original = Vec::new();
523 encode_gzip(script.as_bytes(), &mut original, 1, 65536).unwrap();
524 let mut bad_crc = original.clone();
525 let crc = bad_crc.len() - 8;
526 bad_crc[crc] ^= 1;
527 for bytes in [
528 bad_crc,
529 original[..original.len() - 1].to_vec(),
530 [original.as_slice(), b"trailing"].concat(),
531 ] {
532 let asset = Asset {
533 host: "fixture",
534 digest: Sha256Digest::compute(&bytes),
535 };
536 let error = setup_with(root.root.path(), &asset, |path| {
537 fs::write(path, &bytes)?;
538 Ok(())
539 })
540 .unwrap_err();
541 assert!(matches!(
542 error.downcast_ref::<durable::NamedWriteError<GzipError>>(),
543 Some(durable::NamedWriteError::Producer { .. })
544 ));
545 assert!(!marker.exists());
546 assert!(!bundle_path(root.root.path(), &asset).exists());
547 assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
548 let path = entry.unwrap().path();
549 path.join("failure.txt").is_file()
550 && fs::read(path.join("archive.gz")).ok().as_deref() == Some(bytes.as_slice())
551 }));
552 }
553 }
554
555 #[test]
556 fn failed_setup_retains_evidence_and_previous_bundles_then_can_retry() {
557 if run_in_subprocess() {
558 return;
559 }
560 let root = Fixture::new();
561 let previous = root.root.path().join("previous-version");
562 fs::create_dir(&previous).unwrap();
563 fs::write(previous.join("pocket-ic"), b"previous bytes").unwrap();
564 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
565 assert!(
566 setup_with(root.root.path(), &asset, |path| {
567 fs::write(path, b"partial download")?;
568 Err("interrupted download".into())
569 })
570 .is_err()
571 );
572 assert!(!bundle_path(root.root.path(), &asset).exists());
573 let failed = fs::read_dir(root.root.path())
574 .unwrap()
575 .find_map(|entry| {
576 let path = entry.unwrap().path();
577 path.join("failure.txt").is_file().then_some(path)
578 })
579 .unwrap();
580 assert_eq!(
581 fs::read(failed.join("archive.gz")).unwrap(),
582 b"partial download"
583 );
584 assert!(
585 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
586 let _ = setup_with(root.root.path(), &asset, |path| {
587 fs::write(path, b"interrupted candidate")?;
588 panic!("injected interruption before admission");
589 });
590 }))
591 .is_err()
592 );
593 assert!(!bundle_path(root.root.path(), &asset).exists());
594 assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
595 fs::read(entry.unwrap().path().join("archive.gz"))
596 .ok()
597 .as_deref()
598 == Some(b"interrupted candidate")
599 }));
600 assert!(
601 setup_with(root.root.path(), &asset, |path| {
602 fs::write(path, &bytes)?;
603 Ok(())
604 })
605 .is_ok()
606 );
607 assert!(failed.join("failure.txt").is_file());
608 assert_eq!(
609 fs::read(previous.join("pocket-ic")).unwrap(),
610 b"previous bytes"
611 );
612 }
613
614 #[test]
615 fn digest_decode_and_version_failures_never_publish() {
616 if run_in_subprocess() {
617 return;
618 }
619 for mode in [0, 1, 2] {
620 let root = Fixture::new();
621 let (mut asset, mut bytes) = archive("15.0.0");
622 if mode == 0 {
623 asset.digest = Sha256Digest::compute(b"wrong");
624 }
625 if mode == 1 {
626 bytes = b"not gzip".to_vec();
627 asset.digest = Sha256Digest::compute(&bytes);
628 }
629 assert!(
630 setup_with(root.root.path(), &asset, |path| {
631 fs::write(path, &bytes)?;
632 Ok(())
633 })
634 .is_err()
635 );
636 assert!(!bundle_path(root.root.path(), &asset).exists());
637 }
638 }
639
640 #[test]
641 fn redirected_paths_are_refused_without_touching_the_target() {
642 if run_in_subprocess() {
643 return;
644 }
645 let root = Fixture::new();
646 let (asset, _) = archive(POCKET_IC_SERVER_VERSION);
647 let target = root.root.path().join("target");
648 fs::create_dir(&target).unwrap();
649 let redirected = root.root.path().join("redirected");
650 symlink(&target, &redirected).unwrap();
651 assert!(
652 setup_with(&redirected.join("child"), &asset, |_| panic!(
653 "redirected ancestor reached downloader"
654 ))
655 .is_err()
656 );
657 assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
658 symlink(&target, root.root.path().join(".setup-v1.lock")).unwrap();
659 assert!(
660 setup_with(root.root.path(), &asset, |_| panic!(
661 "redirected lock reached downloader"
662 ))
663 .is_err()
664 );
665 }
666
667 #[test]
668 fn replaced_staging_directory_is_not_published() {
669 if run_in_subprocess() {
670 return;
671 }
672 let root = Fixture::new();
673 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
674 let retained = root.root.path().join("retained-original-stage");
675 let error = setup_with(root.root.path(), &asset, |archive| {
676 fs::write(archive, &bytes)?;
677 let stage = archive.parent().unwrap();
678 fs::rename(stage, &retained)?;
679 fs::create_dir(stage)?;
680 fs::write(archive, &bytes)?;
681 Ok(())
682 })
683 .expect_err("replacement must not inherit the held directory claim");
684 assert!(matches!(
685 error.downcast_ref::<durable::DirectoryCommitError<io::Error>>(),
686 Some(durable::DirectoryCommitError::Prepare(source))
687 if source.kind() == io::ErrorKind::InvalidInput
688 ));
689 assert!(!bundle_path(root.root.path(), &asset).exists());
690 assert_eq!(fs::read(retained.join("archive.gz")).unwrap(), bytes);
691 }
692
693 #[test]
694 fn occupied_destination_preserves_both_bundle_and_verified_staging() {
695 if run_in_subprocess() {
696 return;
697 }
698 let root = Fixture::new();
699 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
700 let bundle = bundle_path(root.root.path(), &asset);
701 let mut attempt = None;
702 let error = setup_with(root.root.path(), &asset, |archive| {
703 fs::write(archive, &bytes)?;
704 attempt = archive.parent().map(Path::to_owned);
705 fs::create_dir(&bundle)?;
706 fs::write(bundle.join("unrelated"), b"retained destination")?;
707 Ok(())
708 })
709 .unwrap_err();
710 assert!(matches!(
711 error.downcast_ref::<durable::DirectoryCommitError<io::Error>>(),
712 Some(durable::DirectoryCommitError::Conflict)
713 ));
714 assert_eq!(
715 fs::read(bundle.join("unrelated")).unwrap(),
716 b"retained destination"
717 );
718 let attempt = attempt.unwrap();
719 assert_eq!(fs::read(attempt.join("archive.gz")).unwrap(), bytes);
720 assert!(attempt.join("pocket-ic").is_file());
721 }
722
723 #[test]
724 fn concurrent_setups_download_once_and_recheck_under_the_lock() {
725 if run_in_subprocess() {
726 return;
727 }
728 let root = Fixture::new();
729 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
730 let calls = AtomicU64::new(0);
731 std::thread::scope(|scope| {
732 let handles = (0..2)
733 .map(|_| {
734 scope.spawn(|| {
735 setup_with(root.root.path(), &asset, |path| {
736 calls.fetch_add(1, Ordering::Relaxed);
737 fs::write(path, &bytes)?;
738 Ok(())
739 })
740 .unwrap()
741 })
742 })
743 .collect::<Vec<_>>();
744 for handle in handles {
745 assert_eq!(
746 handle.join().unwrap(),
747 bundle_path(root.root.path(), &asset).join("pocket-ic")
748 );
749 }
750 });
751 assert_eq!(calls.load(Ordering::Relaxed), 1);
752 }
753}