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