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