1use fs2::FileExt as _;
2use serde_json::Value;
3use std::{
4 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
5 ffi::{OsStr, OsString},
6 fs::{self, File, OpenOptions},
7 io,
8 path::{Path, PathBuf},
9 process::{Command, ExitStatus, Output},
10 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
11};
12
13use super::{
14 digest::{
15 InputDigest, InputHasher, digest_bytes, digest_labeled_paths, os_bytes, write_atomic,
16 },
17 wasm::wasm_path,
18};
19
20const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
21const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
22const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
23# This file is a cache directory tag created by ic-testkit.\n\
24# For information about cache directory tags see https://bford.info/cachedir/\n";
25const CACHE_DIRECTORY_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55\n";
26const LAST_USED_FILE: &str = ".ic-testkit-last-used";
27const AUTOMATIC_ENVIRONMENT: &[&str] = &[
28 "CARGO_BUILD_RUSTC",
29 "CARGO_ENCODED_RUSTFLAGS",
30 "RUSTC",
31 "RUSTC_WRAPPER",
32 "RUSTC_WORKSPACE_WRAPPER",
33 "RUSTFLAGS",
34 "RUSTUP_TOOLCHAIN",
35];
36
37#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct WasmBuildSpec {
45 workspace_root: PathBuf,
46 target_dir: PathBuf,
47 packages: Vec<String>,
48 profile_target_dir: String,
49 cargo_profile_args: Vec<OsString>,
50 extra_env: BTreeMap<OsString, OsString>,
51 inherited_env: BTreeSet<OsString>,
52 additional_inputs: Vec<PathBuf>,
53 target: String,
54 cargo_program: OsString,
55 rustc_program: OsString,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum WasmBuildOutcome {
61 Built(WasmBuildRecord),
63 Reused(WasmBuildRecord),
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct WasmBuildRecord {
70 fingerprint: InputDigest,
71 input_digest: InputDigest,
72 artifacts: Vec<PathBuf>,
73 timings: WasmBuildTimings,
74}
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct WasmBuildTimings {
79 lock_wait: Duration,
80 input_resolution: Duration,
81 cargo_build: Option<Duration>,
82 total: Duration,
83}
84
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
90pub struct WasmBuildCachePrunePolicy {
91 max_age: Option<Duration>,
92 max_size_bytes: Option<u64>,
93}
94
95#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
97pub struct WasmBuildCachePruneReport {
98 entries_scanned: usize,
99 entries_removed: usize,
100 bytes_before: u64,
101 bytes_removed: u64,
102}
103
104#[non_exhaustive]
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum WasmBuildPhase {
108 CargoMetadata,
110 CargoIdentity,
112 RustcIdentity,
114 CargoBuild,
116}
117
118#[non_exhaustive]
120#[derive(Debug)]
121pub enum WasmBuildError {
122 InvalidSpec { message: String },
124 Io {
126 operation: &'static str,
127 path: PathBuf,
128 source: io::Error,
129 },
130 CommandSpawn {
132 phase: WasmBuildPhase,
133 program: OsString,
134 source: io::Error,
135 },
136 CommandFailed {
138 phase: WasmBuildPhase,
139 status: ExitStatus,
140 stdout: String,
141 stderr: String,
142 },
143 InvalidMetadata { message: String },
145 MissingArtifacts { paths: Vec<PathBuf> },
147 InputsChangedDuringBuild {
149 before: InputDigest,
150 after: InputDigest,
151 },
152 FailedBuildCleanup {
154 build_error: Box<Self>,
155 path: PathBuf,
156 source: io::Error,
157 },
158}
159
160impl WasmBuildSpec {
161 #[must_use]
166 pub fn new(
167 workspace_root: &Path,
168 target_dir: &Path,
169 packages: &[&str],
170 profile_target_dir: &str,
171 ) -> Self {
172 Self {
173 workspace_root: workspace_root.to_owned(),
174 target_dir: target_dir.to_owned(),
175 packages: packages
176 .iter()
177 .map(|package| (*package).to_owned())
178 .collect(),
179 profile_target_dir: profile_target_dir.to_owned(),
180 cargo_profile_args: Vec::new(),
181 extra_env: BTreeMap::new(),
182 inherited_env: BTreeSet::new(),
183 additional_inputs: Vec::new(),
184 target: DEFAULT_TARGET.to_owned(),
185 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
186 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
187 }
188 }
189
190 #[must_use]
192 pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
193 self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
194 self
195 }
196
197 #[must_use]
199 pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
200 self.extra_env = environment
201 .iter()
202 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
203 .collect();
204 self
205 }
206
207 #[must_use]
212 pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
213 self.inherited_env.extend(names.iter().map(OsString::from));
214 self
215 }
216
217 #[must_use]
222 pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
223 self.additional_inputs
224 .extend(paths.iter().map(PathBuf::from));
225 self
226 }
227
228 #[must_use]
230 pub fn with_target(mut self, target: &str) -> Self {
231 target.clone_into(&mut self.target);
232 self
233 }
234
235 #[must_use]
237 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
238 self.cargo_program = program.into();
239 self
240 }
241
242 #[must_use]
244 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
245 self.rustc_program = program.into();
246 self
247 }
248
249 #[must_use]
251 pub fn workspace_root(&self) -> &Path {
252 &self.workspace_root
253 }
254
255 #[must_use]
257 pub fn target_dir(&self) -> &Path {
258 &self.target_dir
259 }
260
261 #[must_use]
263 pub fn packages(&self) -> &[String] {
264 &self.packages
265 }
266}
267
268impl WasmBuildOutcome {
269 #[must_use]
271 pub const fn record(&self) -> &WasmBuildRecord {
272 match self {
273 Self::Built(record) | Self::Reused(record) => record,
274 }
275 }
276
277 #[must_use]
279 pub const fn is_reused(&self) -> bool {
280 matches!(self, Self::Reused(_))
281 }
282}
283
284impl WasmBuildRecord {
285 #[must_use]
287 pub const fn fingerprint(&self) -> InputDigest {
288 self.fingerprint
289 }
290
291 #[must_use]
293 pub const fn input_digest(&self) -> InputDigest {
294 self.input_digest
295 }
296
297 #[must_use]
299 pub fn artifacts(&self) -> &[PathBuf] {
300 &self.artifacts
301 }
302
303 #[must_use]
305 pub const fn timings(&self) -> WasmBuildTimings {
306 self.timings
307 }
308}
309
310impl WasmBuildTimings {
311 #[must_use]
313 pub const fn lock_wait(self) -> Duration {
314 self.lock_wait
315 }
316
317 #[must_use]
319 pub const fn input_resolution(self) -> Duration {
320 self.input_resolution
321 }
322
323 #[must_use]
325 pub const fn cargo_build(self) -> Option<Duration> {
326 self.cargo_build
327 }
328
329 #[must_use]
331 pub const fn total(self) -> Duration {
332 self.total
333 }
334}
335
336impl WasmBuildCachePrunePolicy {
337 #[must_use]
339 pub const fn new() -> Self {
340 Self {
341 max_age: None,
342 max_size_bytes: None,
343 }
344 }
345
346 #[must_use]
348 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
349 self.max_age = Some(max_age);
350 self
351 }
352
353 #[must_use]
355 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
356 self.max_size_bytes = Some(bytes);
357 self
358 }
359
360 #[must_use]
362 pub const fn max_age(self) -> Option<Duration> {
363 self.max_age
364 }
365
366 #[must_use]
368 pub const fn max_size_bytes(self) -> Option<u64> {
369 self.max_size_bytes
370 }
371}
372
373impl WasmBuildCachePruneReport {
374 #[must_use]
376 pub const fn entries_scanned(self) -> usize {
377 self.entries_scanned
378 }
379
380 #[must_use]
382 pub const fn entries_removed(self) -> usize {
383 self.entries_removed
384 }
385
386 #[must_use]
388 pub const fn entries_retained(self) -> usize {
389 self.entries_scanned - self.entries_removed
390 }
391
392 #[must_use]
394 pub const fn bytes_before(self) -> u64 {
395 self.bytes_before
396 }
397
398 #[must_use]
400 pub const fn bytes_removed(self) -> u64 {
401 self.bytes_removed
402 }
403
404 #[must_use]
406 pub const fn bytes_retained(self) -> u64 {
407 self.bytes_before - self.bytes_removed
408 }
409}
410
411pub fn build_wasm_canisters_cached(
418 spec: &WasmBuildSpec,
419) -> Result<WasmBuildOutcome, WasmBuildError> {
420 let total_started = Instant::now();
421 validate_spec(spec)?;
422 let (_lock_file, lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
423 ensure_cache_directory_tag(&spec.target_dir)?;
424
425 let input_started = Instant::now();
426 let resolved = build_fingerprint(spec)?;
427 let mut input_resolution = input_started.elapsed();
428 let fingerprint = resolved.fingerprint;
429 let artifacts = expected_artifacts(spec, &spec.target_dir);
430 let build_target_dir = spec
431 .target_dir
432 .join(".ic-testkit/wasm-targets")
433 .join(fingerprint.to_hex());
434
435 if artifact_set_matches(&artifacts, fingerprint) {
436 record_cache_entry_use_if_present(&build_target_dir)?;
437 return Ok(WasmBuildOutcome::Reused(WasmBuildRecord {
438 fingerprint,
439 input_digest: resolved.input_digest,
440 artifacts,
441 timings: WasmBuildTimings {
442 lock_wait,
443 input_resolution,
444 cargo_build: None,
445 total: total_started.elapsed(),
446 },
447 }));
448 }
449
450 let cached_artifacts = expected_artifacts(spec, &build_target_dir);
451 if artifact_set_matches(&cached_artifacts, fingerprint) {
452 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
453 record_cache_entry_use(&build_target_dir)?;
454 return Ok(WasmBuildOutcome::Reused(WasmBuildRecord {
455 fingerprint,
456 input_digest: resolved.input_digest,
457 artifacts,
458 timings: WasmBuildTimings {
459 lock_wait,
460 input_resolution,
461 cargo_build: None,
462 total: total_started.elapsed(),
463 },
464 }));
465 }
466
467 remove_directory_if_present(&build_target_dir)?;
468 create_dir_all(
469 &build_target_dir,
470 "create content-addressed Cargo target directory",
471 )?;
472 let incomplete_directory = IncompleteBuildDirectory::new(build_target_dir.clone());
473 let build_result = (|| {
474 let build_started = Instant::now();
475 run_cargo_build(spec, &build_target_dir)?;
476 let cargo_build = build_started.elapsed();
477 let missing = missing_artifacts(&cached_artifacts);
478 if !missing.is_empty() {
479 return Err(WasmBuildError::MissingArtifacts { paths: missing });
480 }
481
482 let verification_started = Instant::now();
483 let verified = build_fingerprint(spec)?;
484 input_resolution += verification_started.elapsed();
485 if fingerprint != verified.fingerprint {
486 return Err(WasmBuildError::InputsChangedDuringBuild {
487 before: fingerprint,
488 after: verified.fingerprint,
489 });
490 }
491
492 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
493 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
494 record_cache_entry_use(&build_target_dir)?;
495
496 Ok(WasmBuildOutcome::Built(WasmBuildRecord {
497 fingerprint,
498 input_digest: resolved.input_digest,
499 artifacts,
500 timings: WasmBuildTimings {
501 lock_wait,
502 input_resolution,
503 cargo_build: Some(cargo_build),
504 total: total_started.elapsed(),
505 },
506 }))
507 })();
508 finish_fingerprint_build(build_result, incomplete_directory)
509}
510
511pub fn prune_wasm_build_cache(
519 target_dir: &Path,
520 policy: WasmBuildCachePrunePolicy,
521) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
522 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
523 ensure_cache_directory_tag(target_dir)?;
524
525 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
526 let mut entries = cache_entries(&cache_root)?;
527 let bytes_before = entries
528 .iter()
529 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
530 let entries_scanned = entries.len();
531 let now = SystemTime::now();
532 let mut report = WasmBuildCachePruneReport {
533 entries_scanned,
534 entries_removed: 0,
535 bytes_before,
536 bytes_removed: 0,
537 };
538
539 if let Some(max_age) = policy.max_age {
540 for entry in &mut entries {
541 let age = now.duration_since(entry.last_used).unwrap_or_default();
542 if age > max_age {
543 remove_cache_entry(entry, &mut report)?;
544 }
545 }
546 }
547
548 if let Some(max_size_bytes) = policy.max_size_bytes {
549 entries.sort_by(|left, right| {
550 left.last_used
551 .cmp(&right.last_used)
552 .then_with(|| left.path.cmp(&right.path))
553 });
554 for entry in &mut entries {
555 if report.bytes_retained() <= max_size_bytes {
556 break;
557 }
558 remove_cache_entry(entry, &mut report)?;
559 }
560 }
561
562 Ok(report)
563}
564
565struct CacheEntry {
566 path: PathBuf,
567 bytes: u64,
568 last_used: SystemTime,
569 removed: bool,
570}
571
572struct IncompleteBuildDirectory {
573 path: PathBuf,
574 armed: bool,
575}
576
577impl IncompleteBuildDirectory {
578 const fn new(path: PathBuf) -> Self {
579 Self { path, armed: true }
580 }
581
582 fn preserve(mut self) {
583 self.armed = false;
584 }
585
586 fn cleanup(mut self) -> io::Result<()> {
587 let result = remove_dir_all_if_present(&self.path);
588 if result.is_ok() {
589 self.armed = false;
590 }
591 result
592 }
593}
594
595impl Drop for IncompleteBuildDirectory {
596 fn drop(&mut self) {
597 if self.armed {
598 let _ = remove_dir_all_if_present(&self.path);
599 }
600 }
601}
602
603fn finish_fingerprint_build(
604 result: Result<WasmBuildOutcome, WasmBuildError>,
605 incomplete_directory: IncompleteBuildDirectory,
606) -> Result<WasmBuildOutcome, WasmBuildError> {
607 match result {
608 Ok(outcome) => {
609 incomplete_directory.preserve();
610 Ok(outcome)
611 }
612 Err(build_error) => {
613 let path = incomplete_directory.path.clone();
614 match incomplete_directory.cleanup() {
615 Ok(()) => Err(build_error),
616 Err(source) => Err(WasmBuildError::FailedBuildCleanup {
617 build_error: Box::new(build_error),
618 path,
619 source,
620 }),
621 }
622 }
623 }
624}
625
626fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
627 create_dir_all(target_dir, "create Cargo target directory")?;
628 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
629 let lock_file = open_lock_file(&lock_path)?;
630 let lock_started = Instant::now();
631 lock_file
632 .lock_exclusive()
633 .map_err(|source| WasmBuildError::Io {
634 operation: "lock Wasm build cache",
635 path: lock_path,
636 source,
637 })?;
638 Ok((lock_file, lock_started.elapsed()))
639}
640
641fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
642 let path = target_dir.join("CACHEDIR.TAG");
643 if fs::read_to_string(&path)
644 .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
645 {
646 return Ok(());
647 }
648 write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| WasmBuildError::Io {
649 operation: "write Cargo cache directory tag",
650 path,
651 source,
652 })
653}
654
655fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
656 if path.is_dir() {
657 record_cache_entry_use(path)?;
658 }
659 Ok(())
660}
661
662fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
663 write_last_used(path, SystemTime::now())
664}
665
666fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), WasmBuildError> {
667 let elapsed = last_used
668 .duration_since(UNIX_EPOCH)
669 .map_err(|source| WasmBuildError::Io {
670 operation: "record Wasm build cache use time",
671 path: path.join(LAST_USED_FILE),
672 source: io::Error::new(io::ErrorKind::InvalidInput, source),
673 })?;
674 let timestamp = elapsed.as_nanos().to_string();
675 let marker = path.join(LAST_USED_FILE);
676 write_atomic(&marker, timestamp.as_bytes()).map_err(|source| WasmBuildError::Io {
677 operation: "record Wasm build cache use time",
678 path: marker,
679 source,
680 })
681}
682
683fn cache_entries(cache_root: &Path) -> Result<Vec<CacheEntry>, WasmBuildError> {
684 let read_dir = match fs::read_dir(cache_root) {
685 Ok(read_dir) => read_dir,
686 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
687 Err(source) => {
688 return Err(WasmBuildError::Io {
689 operation: "read Wasm build cache directory",
690 path: cache_root.to_owned(),
691 source,
692 });
693 }
694 };
695 let mut entries = Vec::new();
696 for directory_entry in read_dir {
697 let directory_entry = directory_entry.map_err(|source| WasmBuildError::Io {
698 operation: "read Wasm build cache entry",
699 path: cache_root.to_owned(),
700 source,
701 })?;
702 let path = directory_entry.path();
703 let file_type = directory_entry
704 .file_type()
705 .map_err(|source| WasmBuildError::Io {
706 operation: "inspect Wasm build cache entry",
707 path: path.clone(),
708 source,
709 })?;
710 if !file_type.is_dir() || !is_fingerprint_directory(&path) {
711 continue;
712 }
713 let bytes = directory_logical_size(&path).map_err(|source| WasmBuildError::Io {
714 operation: "measure Wasm build cache entry",
715 path: path.clone(),
716 source,
717 })?;
718 let last_used = cache_entry_last_used(&path).map_err(|source| WasmBuildError::Io {
719 operation: "read Wasm build cache use time",
720 path: path.clone(),
721 source,
722 })?;
723 entries.push(CacheEntry {
724 path,
725 bytes,
726 last_used,
727 removed: false,
728 });
729 }
730 Ok(entries)
731}
732
733fn is_fingerprint_directory(path: &Path) -> bool {
734 path.file_name().is_some_and(|name| {
735 let bytes = name.as_encoded_bytes();
736 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
737 })
738}
739
740fn directory_logical_size(path: &Path) -> io::Result<u64> {
741 let mut total = 0_u64;
742 let mut pending = vec![path.to_owned()];
743 while let Some(current) = pending.pop() {
744 let metadata = fs::symlink_metadata(¤t)?;
745 if metadata.is_dir() {
746 for entry in fs::read_dir(¤t)? {
747 pending.push(entry?.path());
748 }
749 } else {
750 total = total.saturating_add(metadata.len());
751 }
752 }
753 Ok(total)
754}
755
756fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
757 let marker = path.join(LAST_USED_FILE);
758 if let Ok(contents) = fs::read_to_string(&marker)
759 && let Ok(nanoseconds) = contents.parse::<u128>()
760 {
761 let seconds = nanoseconds / 1_000_000_000;
762 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
763 if let Ok(seconds) = u64::try_from(seconds)
764 && let Some(timestamp) = UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
765 {
766 return Ok(timestamp);
767 }
768 }
769 fs::metadata(path)?.modified()
770}
771
772fn remove_cache_entry(
773 entry: &mut CacheEntry,
774 report: &mut WasmBuildCachePruneReport,
775) -> Result<(), WasmBuildError> {
776 if entry.removed {
777 return Ok(());
778 }
779 remove_dir_all_if_present(&entry.path).map_err(|source| WasmBuildError::Io {
780 operation: "prune Wasm build cache entry",
781 path: entry.path.clone(),
782 source,
783 })?;
784 entry.removed = true;
785 report.entries_removed += 1;
786 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
787 Ok(())
788}
789
790fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
791 if spec.packages.is_empty() {
792 return Err(WasmBuildError::InvalidSpec {
793 message: "at least one Cargo package is required".to_owned(),
794 });
795 }
796 if spec.profile_target_dir.is_empty() {
797 return Err(WasmBuildError::InvalidSpec {
798 message: "Cargo profile target directory must not be empty".to_owned(),
799 });
800 }
801 if spec.target.is_empty() {
802 return Err(WasmBuildError::InvalidSpec {
803 message: "Cargo compilation target must not be empty".to_owned(),
804 });
805 }
806 Ok(())
807}
808
809struct ResolvedFingerprint {
810 fingerprint: InputDigest,
811 input_digest: InputDigest,
812}
813
814fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedFingerprint, WasmBuildError> {
815 let cargo_identity = command_identity(
816 spec,
817 WasmBuildPhase::CargoIdentity,
818 &spec.cargo_program,
819 &["--version", "--verbose"],
820 )?;
821 let rustc_program = spec
822 .extra_env
823 .get(OsStr::new("RUSTC"))
824 .unwrap_or(&spec.rustc_program);
825 let rustc_identity =
826 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
827 let metadata = cargo_metadata(spec)?;
828 let inputs = resolve_local_inputs(spec, &metadata)?;
829 let exclusions = source_exclusions(spec, &inputs);
830 let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
831 .map_err(|source| WasmBuildError::Io {
832 operation: "hash Wasm build inputs",
833 path: spec.workspace_root.clone(),
834 source,
835 })?;
836
837 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
838 let mut packages = spec.packages.clone();
839 packages.sort();
840 packages.dedup();
841 for package in packages {
842 hasher.field("package", package.as_bytes());
843 }
844 hasher.field("target", spec.target.as_bytes());
845 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
846 for argument in &spec.cargo_profile_args {
847 hasher.field("cargo-argument", &os_bytes(argument));
848 }
849 for (key, value) in effective_environment(spec) {
850 hasher.field("environment-key", &os_bytes(&key));
851 if let Some(value) = value {
852 hasher.field("environment-value", &os_bytes(&value));
853 } else {
854 hasher.field("environment-unset", b"");
855 }
856 }
857 hasher.field("cargo-identity", &cargo_identity);
858 hasher.field("rustc-identity", &rustc_identity);
859 hasher.field("source-input-digest", input_digest.as_bytes());
860 Ok(ResolvedFingerprint {
861 fingerprint: hasher.finish(),
862 input_digest,
863 })
864}
865
866fn command_identity(
867 spec: &WasmBuildSpec,
868 phase: WasmBuildPhase,
869 program: &OsStr,
870 arguments: &[&str],
871) -> Result<Vec<u8>, WasmBuildError> {
872 let mut command = Command::new(program);
873 command.current_dir(&spec.workspace_root).args(arguments);
874 apply_command_environment(&mut command, spec);
875 let output = command
876 .output()
877 .map_err(|source| WasmBuildError::CommandSpawn {
878 phase,
879 program: program.to_owned(),
880 source,
881 })?;
882 ensure_command_success(phase, output).map(|output| {
883 let mut identity = output.stdout;
884 identity.extend_from_slice(&output.stderr);
885 identity
886 })
887}
888
889fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
890 let mut command = Command::new(&spec.cargo_program);
891 command
892 .current_dir(&spec.workspace_root)
893 .args(["metadata", "--format-version", "1"]);
894 for argument in metadata_arguments(&spec.cargo_profile_args) {
895 command.arg(argument);
896 }
897 apply_command_environment(&mut command, spec);
898 let output = command
899 .output()
900 .map_err(|source| WasmBuildError::CommandSpawn {
901 phase: WasmBuildPhase::CargoMetadata,
902 program: spec.cargo_program.clone(),
903 source,
904 })?;
905 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
906 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
907 message: format!("Cargo metadata was not valid JSON: {error}"),
908 })
909}
910
911fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
912 let mut selected = Vec::new();
913 let mut arguments = arguments.iter();
914 while let Some(argument) = arguments.next() {
915 let argument_text = argument.to_string_lossy();
916 match argument_text.as_ref() {
917 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
918 selected.push(argument.clone());
919 }
920 "--features" | "-F" | "--filter-platform" => {
921 selected.push(argument.clone());
922 if let Some(value) = arguments.next() {
923 selected.push(value.clone());
924 }
925 }
926 _ if argument_text.starts_with("--features=")
927 || argument_text.starts_with("--filter-platform=") =>
928 {
929 selected.push(argument.clone());
930 }
931 _ => {}
932 }
933 }
934 selected
935}
936
937#[derive(Clone)]
938struct MetadataPackage {
939 id: String,
940 name: String,
941 version: String,
942 manifest_path: PathBuf,
943 is_local: bool,
944}
945
946fn resolve_local_inputs(
947 spec: &WasmBuildSpec,
948 metadata: &Value,
949) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
950 let packages = metadata_packages(metadata)?;
951 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
952 let dependencies = metadata_dependencies(metadata)?;
953 let mut closure = BTreeSet::new();
954 while let Some(id) = selected_ids.pop_front() {
955 if !closure.insert(id.clone()) {
956 continue;
957 }
958 if let Some(deps) = dependencies.get(&id) {
959 selected_ids.extend(deps.iter().cloned());
960 }
961 }
962
963 let workspace_root = metadata
964 .get("workspace_root")
965 .and_then(Value::as_str)
966 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
967 let mut inputs = workspace_configuration_inputs(&workspace_root);
968 append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
969 append_additional_inputs(&mut inputs, spec, &workspace_root);
970 Ok(inputs)
971}
972
973fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
974 let packages_value = metadata
975 .get("packages")
976 .and_then(Value::as_array)
977 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
978 let mut packages = HashMap::new();
979 for value in packages_value {
980 let package = MetadataPackage {
981 id: required_string(value, "id")?,
982 name: required_string(value, "name")?,
983 version: required_string(value, "version")?,
984 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
985 is_local: value.get("source").is_some_and(Value::is_null),
986 };
987 packages.insert(package.id.clone(), package);
988 }
989 Ok(packages)
990}
991
992fn selected_package_ids(
993 spec: &WasmBuildSpec,
994 metadata: &Value,
995 packages: &HashMap<String, MetadataPackage>,
996) -> Result<VecDeque<String>, WasmBuildError> {
997 let workspace_members = metadata
998 .get("workspace_members")
999 .and_then(Value::as_array)
1000 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
1001 .iter()
1002 .filter_map(Value::as_str)
1003 .collect::<HashSet<_>>();
1004 let mut selected_ids = VecDeque::new();
1005 for requested in &spec.packages {
1006 let matches = packages
1007 .values()
1008 .filter(|package| {
1009 package.name == *requested && workspace_members.contains(package.id.as_str())
1010 })
1011 .map(|package| package.id.clone())
1012 .collect::<Vec<_>>();
1013 match matches.as_slice() {
1014 [id] => selected_ids.push_back(id.clone()),
1015 [] => {
1016 return Err(WasmBuildError::InvalidSpec {
1017 message: format!("Cargo workspace contains no package named `{requested}`"),
1018 });
1019 }
1020 _ => {
1021 return Err(WasmBuildError::InvalidSpec {
1022 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
1023 });
1024 }
1025 }
1026 }
1027 Ok(selected_ids)
1028}
1029
1030fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
1031 let mut dependencies = HashMap::<String, Vec<String>>::new();
1032 let nodes = metadata
1033 .pointer("/resolve/nodes")
1034 .and_then(Value::as_array)
1035 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
1036 for node in nodes {
1037 let id = required_string(node, "id")?;
1038 let deps = node
1039 .get("deps")
1040 .and_then(Value::as_array)
1041 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
1042 .iter()
1043 .map(|dependency| required_string(dependency, "pkg"))
1044 .collect::<Result<Vec<_>, _>>()?;
1045 dependencies.insert(id, deps);
1046 }
1047 Ok(dependencies)
1048}
1049
1050fn workspace_configuration_inputs(workspace_root: &Path) -> Vec<(PathBuf, PathBuf)> {
1051 let mut inputs = Vec::new();
1052 add_if_present(
1053 &mut inputs,
1054 "workspace/Cargo.toml",
1055 workspace_root.join("Cargo.toml"),
1056 );
1057 add_if_present(
1058 &mut inputs,
1059 "workspace/Cargo.lock",
1060 workspace_root.join("Cargo.lock"),
1061 );
1062 add_if_present(
1063 &mut inputs,
1064 "workspace/.cargo/config.toml",
1065 workspace_root.join(".cargo/config.toml"),
1066 );
1067 add_if_present(
1068 &mut inputs,
1069 "workspace/.cargo/config",
1070 workspace_root.join(".cargo/config"),
1071 );
1072 add_if_present(
1073 &mut inputs,
1074 "workspace/rust-toolchain.toml",
1075 workspace_root.join("rust-toolchain.toml"),
1076 );
1077 add_if_present(
1078 &mut inputs,
1079 "workspace/rust-toolchain",
1080 workspace_root.join("rust-toolchain"),
1081 );
1082 inputs
1083}
1084
1085fn append_package_inputs(
1086 inputs: &mut Vec<(PathBuf, PathBuf)>,
1087 packages: &HashMap<String, MetadataPackage>,
1088 closure: BTreeSet<String>,
1089 workspace_root: &Path,
1090) -> Result<(), WasmBuildError> {
1091 for id in closure {
1092 let Some(package) = packages.get(&id) else {
1093 return Err(invalid_metadata(&format!(
1094 "resolved package `{id}` is missing"
1095 )));
1096 };
1097 if !package.is_local {
1098 continue;
1099 }
1100 let root = package.manifest_path.parent().ok_or_else(|| {
1101 invalid_metadata(&format!(
1102 "package `{}` manifest has no parent",
1103 package.name
1104 ))
1105 })?;
1106 let relative_manifest = package
1107 .manifest_path
1108 .strip_prefix(workspace_root)
1109 .unwrap_or(&package.manifest_path);
1110 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
1111 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
1112 inputs.push((label, root.to_owned()));
1113 }
1114 Ok(())
1115}
1116
1117fn append_additional_inputs(
1118 inputs: &mut Vec<(PathBuf, PathBuf)>,
1119 spec: &WasmBuildSpec,
1120 workspace_root: &Path,
1121) {
1122 for additional in &spec.additional_inputs {
1123 let path = if additional.is_absolute() {
1124 additional.clone()
1125 } else {
1126 workspace_root.join(additional)
1127 };
1128 inputs.push((PathBuf::from("additional").join(additional), path));
1129 }
1130}
1131
1132fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
1133 let mut exclusions = vec![
1134 spec.target_dir.clone(),
1135 spec.workspace_root.join("target"),
1136 spec.workspace_root.join(".git"),
1137 ];
1138 for (_, path) in inputs {
1139 if path.is_dir() {
1140 exclusions.push(path.join("target"));
1141 exclusions.push(path.join(".git"));
1142 }
1143 }
1144 exclusions
1145}
1146
1147fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
1148 let mut names = spec.inherited_env.clone();
1149 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
1150 let mut environment = names
1151 .into_iter()
1152 .map(|name| {
1153 let value = std::env::var_os(&name);
1154 (name, value)
1155 })
1156 .collect::<BTreeMap<_, _>>();
1157 for (key, value) in &spec.extra_env {
1158 environment.insert(key.clone(), Some(value.clone()));
1159 }
1160 environment
1161}
1162
1163fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
1164 for (key, value) in &spec.extra_env {
1165 command.env(key, value);
1166 }
1167}
1168
1169fn run_cargo_build(spec: &WasmBuildSpec, build_target_dir: &Path) -> Result<(), WasmBuildError> {
1170 let mut command = Command::new(&spec.cargo_program);
1171 command
1172 .current_dir(&spec.workspace_root)
1173 .env("CARGO_TARGET_DIR", build_target_dir)
1174 .args(["build", "--target", &spec.target])
1175 .args(&spec.cargo_profile_args);
1176 apply_command_environment(&mut command, spec);
1177 for package in &spec.packages {
1178 command.args(["-p", package]);
1179 }
1180
1181 let output = command
1182 .output()
1183 .map_err(|source| WasmBuildError::CommandSpawn {
1184 phase: WasmBuildPhase::CargoBuild,
1185 program: spec.cargo_program.clone(),
1186 source,
1187 })?;
1188 ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ())
1189}
1190
1191fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
1192 if output.status.success() {
1193 return Ok(output);
1194 }
1195 Err(WasmBuildError::CommandFailed {
1196 phase,
1197 status: output.status,
1198 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1199 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1200 })
1201}
1202
1203fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
1204 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
1205 packages.sort_unstable();
1206 packages.dedup();
1207 packages
1208 .into_iter()
1209 .map(|package| {
1210 if spec.target == DEFAULT_TARGET {
1211 wasm_path(target_dir, package, &spec.profile_target_dir)
1212 } else {
1213 target_dir
1214 .join(&spec.target)
1215 .join(&spec.profile_target_dir)
1216 .join(format!("{package}.wasm"))
1217 }
1218 })
1219 .collect()
1220}
1221
1222fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
1223 artifacts.iter().all(|path| {
1224 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
1225 && cache_stamp_matches(path, fingerprint)
1226 })
1227}
1228
1229fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
1230 artifacts
1231 .iter()
1232 .filter(|path| {
1233 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
1234 })
1235 .cloned()
1236 .collect()
1237}
1238
1239fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
1240 let stamp_path = artifact_stamp_path(artifact);
1241 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
1242 return false;
1243 };
1244 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
1245}
1246
1247fn artifact_stamp_path(artifact: &Path) -> PathBuf {
1248 let mut name = artifact
1249 .file_name()
1250 .map_or_else(|| OsString::from("artifact"), OsString::from);
1251 name.push(".ic-testkit-build");
1252 artifact.with_file_name(name)
1253}
1254
1255fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
1256 let artifact_digest = digest_bytes("wasm-artifact-v1", &fs::read(artifact)?);
1257 Ok(format!(
1258 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
1259 ))
1260}
1261
1262fn publish_artifact_stamps(
1263 artifacts: &[PathBuf],
1264 fingerprint: InputDigest,
1265) -> Result<(), WasmBuildError> {
1266 for artifact in artifacts {
1267 let stamp_path = artifact_stamp_path(artifact);
1268 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
1269 WasmBuildError::Io {
1270 operation: "hash built Wasm artifact",
1271 path: artifact.clone(),
1272 source,
1273 }
1274 })?;
1275 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
1276 operation: "publish Wasm build stamp",
1277 path: stamp_path,
1278 source,
1279 })?;
1280 }
1281 Ok(())
1282}
1283
1284fn materialize_artifacts(
1285 cached_artifacts: &[PathBuf],
1286 artifacts: &[PathBuf],
1287 fingerprint: InputDigest,
1288) -> Result<(), WasmBuildError> {
1289 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
1290 let contents = fs::read(cached).map_err(|source| WasmBuildError::Io {
1291 operation: "read content-addressed Wasm artifact",
1292 path: cached.clone(),
1293 source,
1294 })?;
1295 write_atomic(artifact, &contents).map_err(|source| WasmBuildError::Io {
1296 operation: "publish Wasm artifact",
1297 path: artifact.clone(),
1298 source,
1299 })?;
1300 }
1301 publish_artifact_stamps(artifacts, fingerprint)
1302}
1303
1304fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
1305 remove_dir_all_if_present(path).map_err(|source| WasmBuildError::Io {
1306 operation: "remove incomplete content-addressed Cargo target directory",
1307 path: path.to_owned(),
1308 source,
1309 })
1310}
1311
1312fn remove_dir_all_if_present(path: &Path) -> io::Result<()> {
1313 match fs::remove_dir_all(path) {
1314 Ok(()) => Ok(()),
1315 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1316 Err(error) => Err(error),
1317 }
1318}
1319
1320fn open_lock_file(path: &Path) -> Result<File, WasmBuildError> {
1321 if let Some(parent) = path.parent() {
1322 create_dir_all(parent, "create Wasm build lock directory")?;
1323 }
1324 OpenOptions::new()
1325 .create(true)
1326 .read(true)
1327 .write(true)
1328 .truncate(false)
1329 .open(path)
1330 .map_err(|source| WasmBuildError::Io {
1331 operation: "open Wasm build lock",
1332 path: path.to_owned(),
1333 source,
1334 })
1335}
1336
1337fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
1338 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
1339 operation,
1340 path: path.to_owned(),
1341 source,
1342 })
1343}
1344
1345fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
1346 if path.exists() {
1347 inputs.push((PathBuf::from(label), path));
1348 }
1349}
1350
1351fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
1352 value
1353 .get(field)
1354 .and_then(Value::as_str)
1355 .map(str::to_owned)
1356 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
1357}
1358
1359fn invalid_metadata(message: &str) -> WasmBuildError {
1360 WasmBuildError::InvalidMetadata {
1361 message: message.to_owned(),
1362 }
1363}
1364
1365impl std::fmt::Display for WasmBuildPhase {
1366 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1367 formatter.write_str(match self {
1368 Self::CargoMetadata => "cargo metadata",
1369 Self::CargoIdentity => "Cargo identity",
1370 Self::RustcIdentity => "Rust compiler identity",
1371 Self::CargoBuild => "cargo build",
1372 })
1373 }
1374}
1375
1376impl std::fmt::Display for WasmBuildError {
1377 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1378 match self {
1379 Self::InvalidSpec { message } => {
1380 write!(formatter, "invalid Wasm build spec: {message}")
1381 }
1382 Self::Io {
1383 operation,
1384 path,
1385 source,
1386 } => write!(
1387 formatter,
1388 "failed to {operation} at {}: {source}",
1389 path.display()
1390 ),
1391 Self::CommandSpawn {
1392 phase,
1393 program,
1394 source,
1395 } => write!(
1396 formatter,
1397 "failed to launch {phase} using `{}`: {source}",
1398 program.to_string_lossy(),
1399 ),
1400 Self::CommandFailed {
1401 phase,
1402 status,
1403 stdout,
1404 stderr,
1405 } => write!(
1406 formatter,
1407 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
1408 ),
1409 Self::InvalidMetadata { message } => {
1410 write!(formatter, "invalid Cargo metadata: {message}")
1411 }
1412 Self::MissingArtifacts { paths } => write!(
1413 formatter,
1414 "cargo build succeeded without producing: {}",
1415 paths
1416 .iter()
1417 .map(|path| path.display().to_string())
1418 .collect::<Vec<_>>()
1419 .join(", "),
1420 ),
1421 Self::InputsChangedDuringBuild { before, after } => write!(
1422 formatter,
1423 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
1424 ),
1425 Self::FailedBuildCleanup {
1426 build_error,
1427 path,
1428 source,
1429 } => write!(
1430 formatter,
1431 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
1432 path.display(),
1433 ),
1434 }
1435 }
1436}
1437
1438impl std::error::Error for WasmBuildError {
1439 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1440 match self {
1441 Self::Io { source, .. }
1442 | Self::CommandSpawn { source, .. }
1443 | Self::FailedBuildCleanup { source, .. } => Some(source),
1444 _ => None,
1445 }
1446 }
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451 use super::{
1452 CACHE_DIRECTORY_TAG_SIGNATURE, IncompleteBuildDirectory, WasmBuildCachePrunePolicy,
1453 WasmBuildError, WasmBuildOutcome, WasmBuildSpec, directory_logical_size,
1454 ensure_cache_directory_tag, finish_fingerprint_build, metadata_arguments,
1455 prune_wasm_build_cache, validate_spec, write_last_used,
1456 };
1457 use std::{
1458 ffi::OsString,
1459 fs,
1460 path::{Path, PathBuf},
1461 sync::atomic::{AtomicU64, Ordering},
1462 time::{Duration, SystemTime, UNIX_EPOCH},
1463 };
1464
1465 static TEMP_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1466
1467 #[test]
1468 fn metadata_receives_only_resolution_arguments() {
1469 let arguments = [
1470 OsString::from("--profile"),
1471 OsString::from("fast"),
1472 OsString::from("--locked"),
1473 OsString::from("--features=alpha,beta"),
1474 ];
1475 assert_eq!(
1476 metadata_arguments(&arguments),
1477 [
1478 OsString::from("--locked"),
1479 OsString::from("--features=alpha,beta"),
1480 ]
1481 );
1482 }
1483
1484 #[test]
1485 fn build_spec_requires_at_least_one_package() {
1486 let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
1487 assert!(matches!(
1488 validate_spec(&spec),
1489 Err(WasmBuildError::InvalidSpec { .. })
1490 ));
1491 }
1492
1493 #[test]
1494 fn cache_directory_tag_is_created_at_target_root() {
1495 let target_dir = unique_temp_directory("cache-directory-tag");
1496 fs::write(target_dir.join("CACHEDIR.TAG"), "not a cache tag")
1497 .expect("write invalid cache tag");
1498
1499 ensure_cache_directory_tag(&target_dir).expect("write valid cache tag");
1500
1501 let contents =
1502 fs::read_to_string(target_dir.join("CACHEDIR.TAG")).expect("read cache directory tag");
1503 assert!(contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE));
1504 fs::remove_dir_all(target_dir).expect("remove tag test directory");
1505 }
1506
1507 #[test]
1508 fn failed_build_removes_its_incomplete_fingerprint_directory() {
1509 let target_dir = unique_temp_directory("failed-build-cleanup");
1510 let fingerprint_dir = target_dir.join("a".repeat(64));
1511 fs::create_dir_all(&fingerprint_dir).expect("create incomplete target directory");
1512 fs::write(fingerprint_dir.join("partial-output"), b"partial")
1513 .expect("write incomplete output");
1514 let failure: Result<WasmBuildOutcome, WasmBuildError> = Err(WasmBuildError::InvalidSpec {
1515 message: "synthetic build failure".to_owned(),
1516 });
1517
1518 let result = finish_fingerprint_build(
1519 failure,
1520 IncompleteBuildDirectory::new(fingerprint_dir.clone()),
1521 );
1522
1523 assert!(matches!(result, Err(WasmBuildError::InvalidSpec { .. })));
1524 assert!(!fingerprint_dir.exists());
1525 fs::remove_dir_all(target_dir).expect("remove cleanup test directory");
1526 }
1527
1528 #[test]
1529 fn age_pruning_removes_only_stale_fingerprint_directories() {
1530 let target_dir = unique_temp_directory("age-pruning");
1531 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1532 let old = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1533 let current = create_cache_entry(&cache_root, 'b', 10, SystemTime::now());
1534 let unrelated = cache_root.join("not-a-fingerprint");
1535 fs::create_dir_all(&unrelated).expect("create unrelated directory");
1536
1537 let report = prune_wasm_build_cache(
1538 &target_dir,
1539 WasmBuildCachePrunePolicy::new().with_max_age(Duration::from_secs(60)),
1540 )
1541 .expect("prune old cache entry");
1542
1543 assert_eq!(report.entries_scanned(), 2);
1544 assert_eq!(report.entries_removed(), 1);
1545 assert_eq!(report.entries_retained(), 1);
1546 assert!(!old.exists());
1547 assert!(current.exists());
1548 assert!(unrelated.exists());
1549 assert!(target_dir.join("CACHEDIR.TAG").is_file());
1550 fs::remove_dir_all(target_dir).expect("remove age-pruning test directory");
1551 }
1552
1553 #[test]
1554 fn size_pruning_removes_least_recently_used_entries_first() {
1555 let target_dir = unique_temp_directory("size-pruning");
1556 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1557 let oldest = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1558 let middle = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
1559 let newest = create_cache_entry(&cache_root, 'c', 10, UNIX_EPOCH + Duration::from_secs(3));
1560 let newest_bytes = directory_logical_size(&newest).expect("measure newest entry");
1561
1562 let report = prune_wasm_build_cache(
1563 &target_dir,
1564 WasmBuildCachePrunePolicy::new().with_max_size_bytes(newest_bytes),
1565 )
1566 .expect("prune cache to size");
1567
1568 assert_eq!(report.entries_scanned(), 3);
1569 assert_eq!(report.entries_removed(), 2);
1570 assert_eq!(report.entries_retained(), 1);
1571 assert!(report.bytes_retained() <= newest_bytes);
1572 assert!(!oldest.exists());
1573 assert!(!middle.exists());
1574 assert!(newest.exists());
1575 fs::remove_dir_all(target_dir).expect("remove size-pruning test directory");
1576 }
1577
1578 fn create_cache_entry(
1579 cache_root: &Path,
1580 fingerprint_digit: char,
1581 payload_bytes: usize,
1582 last_used: SystemTime,
1583 ) -> PathBuf {
1584 let path = cache_root.join(fingerprint_digit.to_string().repeat(64));
1585 fs::create_dir_all(&path).expect("create cache entry");
1586 fs::write(path.join("payload"), vec![0; payload_bytes]).expect("write cache payload");
1587 write_last_used(&path, last_used).expect("write cache use time");
1588 path
1589 }
1590
1591 fn unique_temp_directory(label: &str) -> PathBuf {
1592 let sequence = TEMP_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1593 let path = std::env::temp_dir().join(format!(
1594 "ic-testkit-{label}-{}-{sequence}",
1595 std::process::id()
1596 ));
1597 if path.exists() {
1598 fs::remove_dir_all(&path).expect("remove stale test directory");
1599 }
1600 fs::create_dir_all(&path).expect("create test directory");
1601 path
1602 }
1603}