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