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 match fs::symlink_metadata(&bundle) {
282 Ok(_) => return check_bundle(&bundle, asset),
283 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
284 Err(error) => return Err(error.into()),
285 }
286 let attempt = root.join(format!(
287 ".setup-v1-{}-{}",
288 std::process::id(),
289 SEQUENCE.fetch_add(1, Ordering::Relaxed)
290 ));
291 let staging_directory = durable::create_private_directory(&attempt)?;
292 eprintln!("PocketIC setup evidence: {}", attempt.display());
293 let result = install(&attempt, asset, download);
294 if let Err(error) = result {
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 durable::admit_directory(&attempt, &staging_directory)?;
310 staging_directory.sync_all()?;
311 fs::rename(&attempt, &bundle)?;
312 eprintln!("PocketIC setup evidence retained: {}", bundle.display());
313 File::open(&root)?.sync_all()?;
314 Ok(bundle.join("pocket-ic"))
315}
316
317fn install(
318 attempt: &Path,
319 asset: &Asset,
320 download: impl FnOnce(&Path) -> Result<()>,
321) -> Result<()> {
322 let archive = attempt.join("archive.gz");
323 download(&archive)?;
324 let bytes = payload(&archive, asset.digest)?;
325 File::open(&archive)?.sync_all()?;
326 let digest = Sha256Digest::compute(&bytes);
327 let executable = attempt.join("pocket-ic");
328 durable::write_with(
329 &executable,
330 durable::WriteOptions {
331 mode: durable::PublicationMode::CreateNew,
332 permissions: 0o755,
333 },
334 |file| -> io::Result<()> { file.write_all(&bytes) },
335 )?;
336 drop(bytes);
337 if let Err(error) = admit(&executable, digest) {
340 if let Some(evidence) = error
341 .downcast_ref::<ic_host_process::tool::ToolError>()
342 .and_then(|error| error.evidence())
343 {
344 for (name, bytes) in [
345 ("version.stdout", &evidence.stdout),
346 ("version.stderr", &evidence.stderr),
347 ] {
348 if let Err(record_error) = durable::write_bytes(
349 &attempt.join(name),
350 durable::WriteOptions {
351 mode: durable::PublicationMode::Replace,
352 permissions: 0o666,
353 },
354 bytes,
355 ) {
356 eprintln!("could not record version evidence: {record_error}");
357 }
358 }
359 }
360 return Err(error);
361 }
362 Ok(())
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use ic_host_artifacts::artifact::encode_gzip;
369 use std::os::unix::fs::{PermissionsExt as _, symlink};
370
371 fn run_in_subprocess() -> bool {
372 let current = std::thread::current();
373 let name = current.name().expect("registered asset test name");
374 if std::env::var("IC_TESTKIT_ASSET_CASE").as_deref() == Ok(name) {
375 return false;
376 }
377 let mut child = OwnedChild::spawn(
380 Command::new(std::env::current_exe().expect("library test executable"))
381 .args(["--exact", name, "--nocapture"])
382 .env("IC_TESTKIT_ASSET_CASE", name)
383 .stdin(Stdio::null())
384 .stdout(Stdio::piped())
385 .stderr(Stdio::piped()),
386 CleanupPolicy::KillAndWait,
387 )
388 .expect("spawn isolated asset test");
389 let completed = communicate_child(
390 &mut child,
391 None,
392 OutputLimits {
393 stdout: OutputLimit::Terminate(64 * 1024),
394 stderr: OutputLimit::Terminate(64 * 1024),
395 timeout: Some(Duration::from_secs(120)),
396 },
397 SuccessfulExit::Cleanup,
398 &mut || false,
399 )
400 .expect("capture and reap isolated asset test");
401 assert!(
402 completed.status.success(),
403 "isolated asset test failed: stdout={} stderr={}",
404 String::from_utf8_lossy(&completed.stdout),
405 String::from_utf8_lossy(&completed.stderr),
406 );
407 true
408 }
409
410 struct Fixture {
411 root: tempfile::TempDir,
412 }
413 impl Fixture {
414 fn new() -> Self {
415 let root = tempfile::Builder::new()
416 .prefix("testkit-provision-")
417 .permissions(fs::Permissions::from_mode(0o700))
418 .tempdir_in(std::env::temp_dir().canonicalize().unwrap())
419 .unwrap();
420 Self { root }
421 }
422 }
423 fn archive(version: &str) -> (Asset, Vec<u8>) {
424 let script = format!("#!/bin/sh\nprintf 'pocket-ic-server {version}\\n'\n");
425 let mut bytes = Vec::new();
426 encode_gzip(script.as_bytes(), &mut bytes, 1, 65536).unwrap();
427 (
428 Asset {
429 host: "fixture",
430 digest: Sha256Digest::compute(&bytes),
431 },
432 bytes,
433 )
434 }
435
436 #[test]
437 fn authenticated_setup_reuses_offline_and_refuses_changed_bytes_before_execution() {
438 if run_in_subprocess() {
439 return;
440 }
441 let root = Fixture::new();
442 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
443 let executable = setup_with(root.root.path(), &asset, |path| {
444 fs::write(path, &bytes)?;
445 Ok(())
446 })
447 .unwrap();
448 let original = fs::read(&executable).unwrap();
449 assert_eq!(
450 setup_with(root.root.path(), &asset, |_| panic!(
451 "verified reuse must not download"
452 ))
453 .unwrap(),
454 executable
455 );
456 let entries = fs::read_dir(root.root.path()).unwrap().count();
457 assert_eq!(
458 check_bundle(&bundle_path(root.root.path(), &asset), &asset).unwrap(),
459 executable
460 );
461 assert_eq!(fs::read_dir(root.root.path()).unwrap().count(), entries);
462 let marker = root.root.path().join("executed");
463 fs::write(
464 &executable,
465 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
466 )
467 .unwrap();
468 assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
469 assert!(!marker.exists());
470 fs::write(&executable, original).unwrap();
471 assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_ok());
472 let saved = root.root.path().join("saved");
473 fs::rename(&executable, &saved).unwrap();
474 symlink(&saved, &executable).unwrap();
475 assert!(check_bundle(&bundle_path(root.root.path(), &asset), &asset).is_err());
476 }
477
478 #[test]
479 fn offline_check_authenticates_before_decoding_and_rejects_invalid_gzip() {
480 use ic_host_artifacts::artifact::{ArtifactError, GzipError};
481
482 if run_in_subprocess() {
483 return;
484 }
485
486 let root = Fixture::new();
487 let (mut asset, original) = archive(POCKET_IC_SERVER_VERSION);
488 let executable = setup_with(root.root.path(), &asset, |path| {
489 fs::write(path, &original)?;
490 Ok(())
491 })
492 .unwrap();
493 let installed = fs::read(&executable).unwrap();
494 let bundle = bundle_path(root.root.path(), &asset);
495 let path = bundle.join("archive.gz");
496
497 fs::write(&path, b"not gzip").unwrap();
499 let error = check_bundle(&bundle, &asset).unwrap_err();
500 assert!(matches!(
501 error.downcast_ref::<ArtifactError>(),
502 Some(ArtifactError::DigestMismatch { .. })
503 ));
504
505 let mut bad_crc = original.clone();
506 let crc = bad_crc.len() - 8;
507 bad_crc[crc] ^= 1;
508 let mut bad_length = original.clone();
509 let length = bad_length.len() - 4;
510 bad_length[length] ^= 1;
511 for bytes in [
512 b"not gzip".to_vec(),
513 original[..original.len() - 1].to_vec(),
514 bad_crc,
515 bad_length,
516 [original.as_slice(), b"trailing"].concat(),
517 [original.as_slice(), original.as_slice()].concat(),
518 ] {
519 fs::write(&path, &bytes).unwrap();
520 asset.digest = Sha256Digest::compute(&bytes);
521 let error = check_bundle(&bundle, &asset).unwrap_err();
522 assert!(error.downcast_ref::<GzipError>().is_some());
524 assert_eq!(fs::read(&executable).unwrap(), installed);
525 assert_eq!(fs::read(&path).unwrap(), bytes);
526 }
527 fs::write(&path, &original).unwrap();
528 asset.digest = Sha256Digest::compute(&original);
529 assert_eq!(check_bundle(&bundle, &asset).unwrap(), executable);
530 }
531
532 #[test]
533 fn failed_setup_retains_evidence_and_previous_bundles_then_can_retry() {
534 if run_in_subprocess() {
535 return;
536 }
537 let root = Fixture::new();
538 let previous = root.root.path().join("previous-version");
539 fs::create_dir(&previous).unwrap();
540 fs::write(previous.join("pocket-ic"), b"previous bytes").unwrap();
541 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
542 assert!(
543 setup_with(root.root.path(), &asset, |path| {
544 fs::write(path, b"partial download")?;
545 Err("interrupted download".into())
546 })
547 .is_err()
548 );
549 assert!(!bundle_path(root.root.path(), &asset).exists());
550 let failed = fs::read_dir(root.root.path())
551 .unwrap()
552 .find_map(|entry| {
553 let path = entry.unwrap().path();
554 path.join("failure.txt").is_file().then_some(path)
555 })
556 .unwrap();
557 assert_eq!(
558 fs::read(failed.join("archive.gz")).unwrap(),
559 b"partial download"
560 );
561 assert!(
562 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
563 let _ = setup_with(root.root.path(), &asset, |path| {
564 fs::write(path, b"interrupted candidate")?;
565 panic!("injected interruption before admission");
566 });
567 }))
568 .is_err()
569 );
570 assert!(!bundle_path(root.root.path(), &asset).exists());
571 assert!(fs::read_dir(root.root.path()).unwrap().any(|entry| {
572 fs::read(entry.unwrap().path().join("archive.gz"))
573 .ok()
574 .as_deref()
575 == Some(b"interrupted candidate")
576 }));
577 assert!(
578 setup_with(root.root.path(), &asset, |path| {
579 fs::write(path, &bytes)?;
580 Ok(())
581 })
582 .is_ok()
583 );
584 assert!(failed.join("failure.txt").is_file());
585 assert_eq!(
586 fs::read(previous.join("pocket-ic")).unwrap(),
587 b"previous bytes"
588 );
589 }
590
591 #[test]
592 fn digest_decode_and_version_failures_never_publish() {
593 if run_in_subprocess() {
594 return;
595 }
596 for mode in [0, 1, 2] {
597 let root = Fixture::new();
598 let (mut asset, mut bytes) = archive("15.0.0");
599 if mode == 0 {
600 asset.digest = Sha256Digest::compute(b"wrong");
601 }
602 if mode == 1 {
603 bytes = b"not gzip".to_vec();
604 asset.digest = Sha256Digest::compute(&bytes);
605 }
606 assert!(
607 setup_with(root.root.path(), &asset, |path| {
608 fs::write(path, &bytes)?;
609 Ok(())
610 })
611 .is_err()
612 );
613 assert!(!bundle_path(root.root.path(), &asset).exists());
614 }
615 }
616
617 #[test]
618 fn redirected_paths_are_refused_without_touching_the_target() {
619 if run_in_subprocess() {
620 return;
621 }
622 let root = Fixture::new();
623 let (asset, _) = archive(POCKET_IC_SERVER_VERSION);
624 let target = root.root.path().join("target");
625 fs::create_dir(&target).unwrap();
626 let redirected = root.root.path().join("redirected");
627 symlink(&target, &redirected).unwrap();
628 assert!(
629 setup_with(&redirected.join("child"), &asset, |_| panic!(
630 "redirected ancestor reached downloader"
631 ))
632 .is_err()
633 );
634 assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
635 symlink(&target, root.root.path().join(".setup-v1.lock")).unwrap();
636 assert!(
637 setup_with(root.root.path(), &asset, |_| panic!(
638 "redirected lock reached downloader"
639 ))
640 .is_err()
641 );
642 }
643
644 #[test]
645 fn replaced_staging_directory_is_not_published() {
646 if run_in_subprocess() {
647 return;
648 }
649 let root = Fixture::new();
650 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
651 let retained = root.root.path().join("retained-original-stage");
652 let error = setup_with(root.root.path(), &asset, |archive| {
653 fs::write(archive, &bytes)?;
654 let stage = archive.parent().unwrap();
655 fs::rename(stage, &retained)?;
656 fs::create_dir(stage)?;
657 fs::write(archive, &bytes)?;
658 Ok(())
659 })
660 .expect_err("replacement must not inherit the held directory claim");
661 assert_eq!(
662 error
663 .downcast_ref::<io::Error>()
664 .unwrap_or_else(|| panic!("expected directory identity refusal, got {error:?}"))
665 .kind(),
666 io::ErrorKind::InvalidInput,
667 );
668 assert!(!bundle_path(root.root.path(), &asset).exists());
669 assert_eq!(fs::read(retained.join("archive.gz")).unwrap(), bytes);
670 }
671
672 #[test]
673 fn concurrent_setups_download_once_and_recheck_under_the_lock() {
674 if run_in_subprocess() {
675 return;
676 }
677 let root = Fixture::new();
678 let (asset, bytes) = archive(POCKET_IC_SERVER_VERSION);
679 let calls = AtomicU64::new(0);
680 std::thread::scope(|scope| {
681 let handles = (0..2)
682 .map(|_| {
683 scope.spawn(|| {
684 setup_with(root.root.path(), &asset, |path| {
685 calls.fetch_add(1, Ordering::Relaxed);
686 fs::write(path, &bytes)?;
687 Ok(())
688 })
689 .unwrap()
690 })
691 })
692 .collect::<Vec<_>>();
693 for handle in handles {
694 assert_eq!(
695 handle.join().unwrap(),
696 bundle_path(root.root.path(), &asset).join("pocket-ic")
697 );
698 }
699 });
700 assert_eq!(calls.load(Ordering::Relaxed), 1);
701 }
702}