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