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