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};
12use toml::Value as TomlValue;
13
14use super::{
15 digest::{
16 InputDigest, InputHasher, digest_bytes, digest_labeled_paths, os_bytes, write_atomic,
17 },
18 wasm::wasm_path,
19};
20
21const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
22const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
23const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
24# This file is a cache directory tag created by ic-testkit.\n\
25# For information about cache directory tags see https://bford.info/cachedir/\n";
26const CACHE_DIRECTORY_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55\n";
27const LAST_USED_FILE: &str = ".ic-testkit-last-used";
28const AUTOMATIC_ENVIRONMENT: &[&str] = &[
29 "CARGO_BUILD_RUSTC",
30 "CARGO_ENCODED_RUSTFLAGS",
31 "RUSTC",
32 "RUSTC_WRAPPER",
33 "RUSTC_WORKSPACE_WRAPPER",
34 "RUSTFLAGS",
35 "RUSTUP_TOOLCHAIN",
36];
37
38#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct WasmBuildSpec {
46 workspace_root: PathBuf,
47 target_dir: PathBuf,
48 packages: Vec<String>,
49 profile_target_dir: String,
50 cargo_profile_args: Vec<OsString>,
51 extra_env: BTreeMap<OsString, OsString>,
52 inherited_env: BTreeSet<OsString>,
53 additional_inputs: Vec<PathBuf>,
54 target: String,
55 cargo_program: OsString,
56 rustc_program: OsString,
57 prune_policy: Option<WasmBuildCachePrunePolicy>,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
62pub enum WasmBuildOutcome {
63 Built(WasmBuildRecord),
65 Reused(WasmBuildRecord),
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct WasmBuildRecord {
72 fingerprint: InputDigest,
73 input_digest: InputDigest,
74 artifacts: Vec<PathBuf>,
75 timings: WasmBuildTimings,
76 maintenance: Option<WasmBuildCacheMaintenance>,
77}
78
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub struct WasmBuildTimings {
82 lock_wait: Duration,
83 input_resolution: WasmInputResolutionTimings,
84 cargo_build: Option<Duration>,
85 cache_maintenance: Option<Duration>,
86 total: Duration,
87}
88
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
91pub struct WasmInputResolutionTimings {
92 tool_identity: Duration,
93 cargo_metadata: Duration,
94 input_discovery: Duration,
95 content_hashing: Duration,
96 total: Duration,
97}
98
99#[non_exhaustive]
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum WasmBuildCacheMaintenance {
103 Pruned(WasmBuildCachePruneReport),
105 PruneFailed {
107 message: String,
109 },
110}
111
112#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
117pub struct WasmBuildCachePrunePolicy {
118 max_age: Option<Duration>,
119 max_size_bytes: Option<u64>,
120}
121
122#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
124pub struct WasmBuildCachePruneReport {
125 entries_scanned: usize,
126 entries_removed: usize,
127 bytes_before: u64,
128 bytes_removed: u64,
129}
130
131#[non_exhaustive]
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum WasmBuildPhase {
135 CargoMetadata,
137 CargoIdentity,
139 RustcIdentity,
141 CargoBuild,
143}
144
145#[non_exhaustive]
147#[derive(Debug)]
148pub enum WasmBuildError {
149 InvalidSpec { message: String },
151 Io {
153 operation: &'static str,
154 path: PathBuf,
155 source: io::Error,
156 },
157 CommandSpawn {
159 phase: WasmBuildPhase,
160 program: OsString,
161 source: io::Error,
162 },
163 CommandFailed {
165 phase: WasmBuildPhase,
166 status: ExitStatus,
167 stdout: String,
168 stderr: String,
169 },
170 InvalidMetadata { message: String },
172 InvalidCargoConfiguration { path: PathBuf, message: String },
174 MissingArtifacts { paths: Vec<PathBuf> },
176 InputsChangedDuringBuild {
178 before: InputDigest,
179 after: InputDigest,
180 },
181 FailedBuildCleanup {
183 build_error: Box<Self>,
184 path: PathBuf,
185 source: io::Error,
186 },
187}
188
189impl WasmBuildSpec {
190 #[must_use]
195 pub fn new(
196 workspace_root: &Path,
197 target_dir: &Path,
198 packages: &[&str],
199 profile_target_dir: &str,
200 ) -> Self {
201 Self {
202 workspace_root: workspace_root.to_owned(),
203 target_dir: target_dir.to_owned(),
204 packages: packages
205 .iter()
206 .map(|package| (*package).to_owned())
207 .collect(),
208 profile_target_dir: profile_target_dir.to_owned(),
209 cargo_profile_args: Vec::new(),
210 extra_env: BTreeMap::new(),
211 inherited_env: BTreeSet::new(),
212 additional_inputs: Vec::new(),
213 target: DEFAULT_TARGET.to_owned(),
214 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
215 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
216 prune_policy: None,
217 }
218 }
219
220 #[must_use]
222 pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
223 self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
224 self
225 }
226
227 #[must_use]
229 pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
230 self.extra_env = environment
231 .iter()
232 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
233 .collect();
234 self
235 }
236
237 #[must_use]
242 pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
243 self.inherited_env.extend(names.iter().map(OsString::from));
244 self
245 }
246
247 #[must_use]
252 pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
253 self.additional_inputs
254 .extend(paths.iter().map(PathBuf::from));
255 self
256 }
257
258 #[must_use]
260 pub fn with_target(mut self, target: &str) -> Self {
261 target.clone_into(&mut self.target);
262 self
263 }
264
265 #[must_use]
267 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
268 self.cargo_program = program.into();
269 self
270 }
271
272 #[must_use]
274 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
275 self.rustc_program = program.into();
276 self
277 }
278
279 #[must_use]
285 pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
286 self.prune_policy = Some(policy);
287 self
288 }
289
290 #[must_use]
292 pub fn workspace_root(&self) -> &Path {
293 &self.workspace_root
294 }
295
296 #[must_use]
298 pub fn target_dir(&self) -> &Path {
299 &self.target_dir
300 }
301
302 #[must_use]
304 pub fn packages(&self) -> &[String] {
305 &self.packages
306 }
307}
308
309impl WasmBuildOutcome {
310 #[must_use]
312 pub const fn record(&self) -> &WasmBuildRecord {
313 match self {
314 Self::Built(record) | Self::Reused(record) => record,
315 }
316 }
317
318 #[must_use]
320 pub const fn is_reused(&self) -> bool {
321 matches!(self, Self::Reused(_))
322 }
323}
324
325impl WasmBuildRecord {
326 #[must_use]
328 pub const fn fingerprint(&self) -> InputDigest {
329 self.fingerprint
330 }
331
332 #[must_use]
334 pub const fn input_digest(&self) -> InputDigest {
335 self.input_digest
336 }
337
338 #[must_use]
340 pub fn artifacts(&self) -> &[PathBuf] {
341 &self.artifacts
342 }
343
344 #[must_use]
346 pub const fn timings(&self) -> WasmBuildTimings {
347 self.timings
348 }
349
350 #[must_use]
352 pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
353 self.maintenance.as_ref()
354 }
355}
356
357impl WasmBuildCacheMaintenance {
358 #[must_use]
360 pub const fn prune_report(&self) -> Option<WasmBuildCachePruneReport> {
361 match self {
362 Self::Pruned(report) => Some(*report),
363 Self::PruneFailed { .. } => None,
364 }
365 }
366
367 #[must_use]
369 pub fn failure_message(&self) -> Option<&str> {
370 match self {
371 Self::Pruned(_) => None,
372 Self::PruneFailed { message } => Some(message),
373 }
374 }
375}
376
377impl WasmBuildTimings {
378 #[must_use]
380 pub const fn lock_wait(self) -> Duration {
381 self.lock_wait
382 }
383
384 #[must_use]
386 pub const fn input_resolution(self) -> Duration {
387 self.input_resolution.total
388 }
389
390 #[must_use]
392 pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
393 self.input_resolution
394 }
395
396 #[must_use]
398 pub const fn cargo_build(self) -> Option<Duration> {
399 self.cargo_build
400 }
401
402 #[must_use]
404 pub const fn cache_maintenance(self) -> Option<Duration> {
405 self.cache_maintenance
406 }
407
408 #[must_use]
410 pub const fn total(self) -> Duration {
411 self.total
412 }
413}
414
415impl WasmInputResolutionTimings {
416 #[must_use]
418 pub const fn tool_identity(self) -> Duration {
419 self.tool_identity
420 }
421
422 #[must_use]
424 pub const fn cargo_metadata(self) -> Duration {
425 self.cargo_metadata
426 }
427
428 #[must_use]
430 pub const fn input_discovery(self) -> Duration {
431 self.input_discovery
432 }
433
434 #[must_use]
436 pub const fn content_hashing(self) -> Duration {
437 self.content_hashing
438 }
439
440 #[must_use]
442 pub const fn total(self) -> Duration {
443 self.total
444 }
445
446 const fn include(&mut self, other: Self) {
447 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
448 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
449 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
450 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
451 self.total = self.total.saturating_add(other.total);
452 }
453}
454
455impl WasmBuildCachePrunePolicy {
456 #[must_use]
458 pub const fn new() -> Self {
459 Self {
460 max_age: None,
461 max_size_bytes: None,
462 }
463 }
464
465 #[must_use]
467 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
468 self.max_age = Some(max_age);
469 self
470 }
471
472 #[must_use]
474 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
475 self.max_size_bytes = Some(bytes);
476 self
477 }
478
479 #[must_use]
481 pub const fn max_age(self) -> Option<Duration> {
482 self.max_age
483 }
484
485 #[must_use]
487 pub const fn max_size_bytes(self) -> Option<u64> {
488 self.max_size_bytes
489 }
490}
491
492impl WasmBuildCachePruneReport {
493 #[must_use]
495 pub const fn entries_scanned(self) -> usize {
496 self.entries_scanned
497 }
498
499 #[must_use]
501 pub const fn entries_removed(self) -> usize {
502 self.entries_removed
503 }
504
505 #[must_use]
507 pub const fn entries_retained(self) -> usize {
508 self.entries_scanned - self.entries_removed
509 }
510
511 #[must_use]
513 pub const fn bytes_before(self) -> u64 {
514 self.bytes_before
515 }
516
517 #[must_use]
519 pub const fn bytes_removed(self) -> u64 {
520 self.bytes_removed
521 }
522
523 #[must_use]
525 pub const fn bytes_retained(self) -> u64 {
526 self.bytes_before - self.bytes_removed
527 }
528}
529
530pub fn build_wasm_canisters_cached(
537 spec: &WasmBuildSpec,
538) -> Result<WasmBuildOutcome, WasmBuildError> {
539 let total_started = Instant::now();
540 validate_spec(spec)?;
541 let (_lock_file, lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
542 ensure_cache_directory_tag(&spec.target_dir)?;
543
544 let resolved = build_fingerprint(spec)?;
545 let mut input_resolution = resolved.timings;
546 let fingerprint = resolved.fingerprint;
547 let artifacts = expected_artifacts(spec, &spec.target_dir);
548 let build_target_dir = spec
549 .target_dir
550 .join(".ic-testkit/wasm-targets")
551 .join(fingerprint.to_hex());
552
553 if artifact_set_matches(&artifacts, fingerprint) {
554 record_cache_entry_use_if_present(&build_target_dir)?;
555 return Ok(WasmBuildOutcome::Reused(complete_build_record(
556 spec,
557 BuildRecordInput {
558 fingerprint,
559 input_digest: resolved.input_digest,
560 artifacts,
561 lock_wait,
562 input_resolution,
563 cargo_build: None,
564 active_entry: &build_target_dir,
565 },
566 total_started,
567 )));
568 }
569
570 let cached_artifacts = expected_artifacts(spec, &build_target_dir);
571 if artifact_set_matches(&cached_artifacts, fingerprint) {
572 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
573 record_cache_entry_use(&build_target_dir)?;
574 return Ok(WasmBuildOutcome::Reused(complete_build_record(
575 spec,
576 BuildRecordInput {
577 fingerprint,
578 input_digest: resolved.input_digest,
579 artifacts,
580 lock_wait,
581 input_resolution,
582 cargo_build: None,
583 active_entry: &build_target_dir,
584 },
585 total_started,
586 )));
587 }
588
589 remove_directory_if_present(&build_target_dir)?;
590 create_dir_all(
591 &build_target_dir,
592 "create content-addressed Cargo target directory",
593 )?;
594 let incomplete_directory = IncompleteBuildDirectory::new(build_target_dir.clone());
595 let build_result = (|| {
596 let build_started = Instant::now();
597 run_cargo_build(spec, &build_target_dir)?;
598 let cargo_build = build_started.elapsed();
599 let missing = missing_artifacts(&cached_artifacts);
600 if !missing.is_empty() {
601 return Err(WasmBuildError::MissingArtifacts { paths: missing });
602 }
603
604 let verified = build_fingerprint(spec)?;
605 input_resolution.include(verified.timings);
606 if fingerprint != verified.fingerprint {
607 return Err(WasmBuildError::InputsChangedDuringBuild {
608 before: fingerprint,
609 after: verified.fingerprint,
610 });
611 }
612
613 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
614 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
615 record_cache_entry_use(&build_target_dir)?;
616
617 Ok(WasmBuildOutcome::Built(complete_build_record(
618 spec,
619 BuildRecordInput {
620 fingerprint,
621 input_digest: resolved.input_digest,
622 artifacts,
623 lock_wait,
624 input_resolution,
625 cargo_build: Some(cargo_build),
626 active_entry: &build_target_dir,
627 },
628 total_started,
629 )))
630 })();
631 finish_fingerprint_build(build_result, incomplete_directory)
632}
633
634pub fn prune_wasm_build_cache(
642 target_dir: &Path,
643 policy: WasmBuildCachePrunePolicy,
644) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
645 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
646 ensure_cache_directory_tag(target_dir)?;
647
648 prune_wasm_build_cache_locked(target_dir, policy, None)
649}
650
651struct BuildRecordInput<'a> {
652 fingerprint: InputDigest,
653 input_digest: InputDigest,
654 artifacts: Vec<PathBuf>,
655 lock_wait: Duration,
656 input_resolution: WasmInputResolutionTimings,
657 cargo_build: Option<Duration>,
658 active_entry: &'a Path,
659}
660
661fn complete_build_record(
662 spec: &WasmBuildSpec,
663 input: BuildRecordInput<'_>,
664 total_started: Instant,
665) -> WasmBuildRecord {
666 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
667 let started = Instant::now();
668 let result =
669 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry));
670 let elapsed = started.elapsed();
671 let maintenance = match result {
672 Ok(report) => WasmBuildCacheMaintenance::Pruned(report),
673 Err(error) => WasmBuildCacheMaintenance::PruneFailed {
674 message: error.to_string(),
675 },
676 };
677 (Some(maintenance), Some(elapsed))
678 });
679 WasmBuildRecord {
680 fingerprint: input.fingerprint,
681 input_digest: input.input_digest,
682 artifacts: input.artifacts,
683 timings: WasmBuildTimings {
684 lock_wait: input.lock_wait,
685 input_resolution: input.input_resolution,
686 cargo_build: input.cargo_build,
687 cache_maintenance,
688 total: total_started.elapsed(),
689 },
690 maintenance,
691 }
692}
693
694fn prune_wasm_build_cache_locked(
695 target_dir: &Path,
696 policy: WasmBuildCachePrunePolicy,
697 protected_entry: Option<&Path>,
698) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
699 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
700 let mut entries = cache_entries(&cache_root)?;
701 let bytes_before = entries
702 .iter()
703 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
704 let entries_scanned = entries.len();
705 let now = SystemTime::now();
706 let mut report = WasmBuildCachePruneReport {
707 entries_scanned,
708 entries_removed: 0,
709 bytes_before,
710 bytes_removed: 0,
711 };
712
713 if let Some(max_age) = policy.max_age {
714 for entry in &mut entries {
715 let age = now.duration_since(entry.last_used).unwrap_or_default();
716 if protected_entry != Some(entry.path.as_path()) && age > max_age {
717 remove_cache_entry(entry, &mut report)?;
718 }
719 }
720 }
721
722 if let Some(max_size_bytes) = policy.max_size_bytes {
723 entries.sort_by(|left, right| {
724 left.last_used
725 .cmp(&right.last_used)
726 .then_with(|| left.path.cmp(&right.path))
727 });
728 for entry in &mut entries {
729 if report.bytes_retained() <= max_size_bytes {
730 break;
731 }
732 if protected_entry == Some(entry.path.as_path()) {
733 continue;
734 }
735 remove_cache_entry(entry, &mut report)?;
736 }
737 }
738
739 Ok(report)
740}
741
742struct CacheEntry {
743 path: PathBuf,
744 bytes: u64,
745 last_used: SystemTime,
746 removed: bool,
747}
748
749struct IncompleteBuildDirectory {
750 path: PathBuf,
751 armed: bool,
752}
753
754impl IncompleteBuildDirectory {
755 const fn new(path: PathBuf) -> Self {
756 Self { path, armed: true }
757 }
758
759 fn preserve(mut self) {
760 self.armed = false;
761 }
762
763 fn cleanup(mut self) -> io::Result<()> {
764 let result = remove_dir_all_if_present(&self.path);
765 if result.is_ok() {
766 self.armed = false;
767 }
768 result
769 }
770}
771
772impl Drop for IncompleteBuildDirectory {
773 fn drop(&mut self) {
774 if self.armed {
775 let _ = remove_dir_all_if_present(&self.path);
776 }
777 }
778}
779
780fn finish_fingerprint_build(
781 result: Result<WasmBuildOutcome, WasmBuildError>,
782 incomplete_directory: IncompleteBuildDirectory,
783) -> Result<WasmBuildOutcome, WasmBuildError> {
784 match result {
785 Ok(outcome) => {
786 incomplete_directory.preserve();
787 Ok(outcome)
788 }
789 Err(build_error) => {
790 let path = incomplete_directory.path.clone();
791 match incomplete_directory.cleanup() {
792 Ok(()) => Err(build_error),
793 Err(source) => Err(WasmBuildError::FailedBuildCleanup {
794 build_error: Box::new(build_error),
795 path,
796 source,
797 }),
798 }
799 }
800 }
801}
802
803fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
804 create_dir_all(target_dir, "create Cargo target directory")?;
805 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
806 let lock_file = open_lock_file(&lock_path)?;
807 let lock_started = Instant::now();
808 lock_file
809 .lock_exclusive()
810 .map_err(|source| WasmBuildError::Io {
811 operation: "lock Wasm build cache",
812 path: lock_path,
813 source,
814 })?;
815 Ok((lock_file, lock_started.elapsed()))
816}
817
818fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
819 let path = target_dir.join("CACHEDIR.TAG");
820 if fs::read_to_string(&path)
821 .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
822 {
823 return Ok(());
824 }
825 write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| WasmBuildError::Io {
826 operation: "write Cargo cache directory tag",
827 path,
828 source,
829 })
830}
831
832fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
833 if path.is_dir() {
834 record_cache_entry_use(path)?;
835 }
836 Ok(())
837}
838
839fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
840 write_last_used(path, SystemTime::now())
841}
842
843fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), WasmBuildError> {
844 let elapsed = last_used
845 .duration_since(UNIX_EPOCH)
846 .map_err(|source| WasmBuildError::Io {
847 operation: "record Wasm build cache use time",
848 path: path.join(LAST_USED_FILE),
849 source: io::Error::new(io::ErrorKind::InvalidInput, source),
850 })?;
851 let timestamp = elapsed.as_nanos().to_string();
852 let marker = path.join(LAST_USED_FILE);
853 write_atomic(&marker, timestamp.as_bytes()).map_err(|source| WasmBuildError::Io {
854 operation: "record Wasm build cache use time",
855 path: marker,
856 source,
857 })
858}
859
860fn cache_entries(cache_root: &Path) -> Result<Vec<CacheEntry>, WasmBuildError> {
861 let read_dir = match fs::read_dir(cache_root) {
862 Ok(read_dir) => read_dir,
863 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
864 Err(source) => {
865 return Err(WasmBuildError::Io {
866 operation: "read Wasm build cache directory",
867 path: cache_root.to_owned(),
868 source,
869 });
870 }
871 };
872 let mut entries = Vec::new();
873 for directory_entry in read_dir {
874 let directory_entry = directory_entry.map_err(|source| WasmBuildError::Io {
875 operation: "read Wasm build cache entry",
876 path: cache_root.to_owned(),
877 source,
878 })?;
879 let path = directory_entry.path();
880 let file_type = directory_entry
881 .file_type()
882 .map_err(|source| WasmBuildError::Io {
883 operation: "inspect Wasm build cache entry",
884 path: path.clone(),
885 source,
886 })?;
887 if !file_type.is_dir() || !is_fingerprint_directory(&path) {
888 continue;
889 }
890 let bytes = directory_logical_size(&path).map_err(|source| WasmBuildError::Io {
891 operation: "measure Wasm build cache entry",
892 path: path.clone(),
893 source,
894 })?;
895 let last_used = cache_entry_last_used(&path).map_err(|source| WasmBuildError::Io {
896 operation: "read Wasm build cache use time",
897 path: path.clone(),
898 source,
899 })?;
900 entries.push(CacheEntry {
901 path,
902 bytes,
903 last_used,
904 removed: false,
905 });
906 }
907 Ok(entries)
908}
909
910fn is_fingerprint_directory(path: &Path) -> bool {
911 path.file_name().is_some_and(|name| {
912 let bytes = name.as_encoded_bytes();
913 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
914 })
915}
916
917fn directory_logical_size(path: &Path) -> io::Result<u64> {
918 let mut total = 0_u64;
919 let mut pending = vec![path.to_owned()];
920 while let Some(current) = pending.pop() {
921 let metadata = fs::symlink_metadata(¤t)?;
922 if metadata.is_dir() {
923 for entry in fs::read_dir(¤t)? {
924 pending.push(entry?.path());
925 }
926 } else {
927 total = total.saturating_add(metadata.len());
928 }
929 }
930 Ok(total)
931}
932
933fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
934 let marker = path.join(LAST_USED_FILE);
935 if let Ok(contents) = fs::read_to_string(&marker)
936 && let Ok(nanoseconds) = contents.parse::<u128>()
937 {
938 let seconds = nanoseconds / 1_000_000_000;
939 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
940 if let Ok(seconds) = u64::try_from(seconds)
941 && let Some(timestamp) = UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
942 {
943 return Ok(timestamp);
944 }
945 }
946 fs::metadata(path)?.modified()
947}
948
949fn remove_cache_entry(
950 entry: &mut CacheEntry,
951 report: &mut WasmBuildCachePruneReport,
952) -> Result<(), WasmBuildError> {
953 if entry.removed {
954 return Ok(());
955 }
956 remove_dir_all_if_present(&entry.path).map_err(|source| WasmBuildError::Io {
957 operation: "prune Wasm build cache entry",
958 path: entry.path.clone(),
959 source,
960 })?;
961 entry.removed = true;
962 report.entries_removed += 1;
963 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
964 Ok(())
965}
966
967fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
968 if spec.packages.is_empty() {
969 return Err(WasmBuildError::InvalidSpec {
970 message: "at least one Cargo package is required".to_owned(),
971 });
972 }
973 if spec.profile_target_dir.is_empty() {
974 return Err(WasmBuildError::InvalidSpec {
975 message: "Cargo profile target directory must not be empty".to_owned(),
976 });
977 }
978 if spec.target.is_empty() {
979 return Err(WasmBuildError::InvalidSpec {
980 message: "Cargo compilation target must not be empty".to_owned(),
981 });
982 }
983 Ok(())
984}
985
986struct ResolvedFingerprint {
987 fingerprint: InputDigest,
988 input_digest: InputDigest,
989 timings: WasmInputResolutionTimings,
990}
991
992fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedFingerprint, WasmBuildError> {
993 let total_started = Instant::now();
994 let tool_started = Instant::now();
995 let cargo_identity = command_identity(
996 spec,
997 WasmBuildPhase::CargoIdentity,
998 &spec.cargo_program,
999 &["--version", "--verbose"],
1000 )?;
1001 let rustc_program = spec
1002 .extra_env
1003 .get(OsStr::new("RUSTC"))
1004 .unwrap_or(&spec.rustc_program);
1005 let rustc_identity =
1006 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
1007 let tool_identity = tool_started.elapsed();
1008
1009 let metadata_started = Instant::now();
1010 let metadata = cargo_metadata(spec)?;
1011 let cargo_metadata = metadata_started.elapsed();
1012
1013 let discovery_started = Instant::now();
1014 let inputs = resolve_local_inputs(spec, &metadata)?;
1015 let exclusions = source_exclusions(spec, &inputs);
1016 let input_discovery = discovery_started.elapsed();
1017
1018 let hashing_started = Instant::now();
1019 let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
1020 .map_err(|source| WasmBuildError::Io {
1021 operation: "hash Wasm build inputs",
1022 path: spec.workspace_root.clone(),
1023 source,
1024 })?;
1025 let content_hashing = hashing_started.elapsed();
1026
1027 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
1028 let mut packages = spec.packages.clone();
1029 packages.sort();
1030 packages.dedup();
1031 for package in packages {
1032 hasher.field("package", package.as_bytes());
1033 }
1034 hasher.field("target", spec.target.as_bytes());
1035 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
1036 for argument in &spec.cargo_profile_args {
1037 hasher.field("cargo-argument", &os_bytes(argument));
1038 }
1039 for (key, value) in effective_environment(spec) {
1040 hasher.field("environment-key", &os_bytes(&key));
1041 if let Some(value) = value {
1042 hasher.field("environment-value", &os_bytes(&value));
1043 } else {
1044 hasher.field("environment-unset", b"");
1045 }
1046 }
1047 hasher.field("cargo-identity", &cargo_identity);
1048 hasher.field("rustc-identity", &rustc_identity);
1049 hasher.field("source-input-digest", input_digest.as_bytes());
1050 Ok(ResolvedFingerprint {
1051 fingerprint: hasher.finish(),
1052 input_digest,
1053 timings: WasmInputResolutionTimings {
1054 tool_identity,
1055 cargo_metadata,
1056 input_discovery,
1057 content_hashing,
1058 total: total_started.elapsed(),
1059 },
1060 })
1061}
1062
1063fn command_identity(
1064 spec: &WasmBuildSpec,
1065 phase: WasmBuildPhase,
1066 program: &OsStr,
1067 arguments: &[&str],
1068) -> Result<Vec<u8>, WasmBuildError> {
1069 let mut command = Command::new(program);
1070 command.current_dir(&spec.workspace_root).args(arguments);
1071 apply_command_environment(&mut command, spec);
1072 let output = command
1073 .output()
1074 .map_err(|source| WasmBuildError::CommandSpawn {
1075 phase,
1076 program: program.to_owned(),
1077 source,
1078 })?;
1079 ensure_command_success(phase, output).map(|output| {
1080 let mut identity = output.stdout;
1081 identity.extend_from_slice(&output.stderr);
1082 identity
1083 })
1084}
1085
1086fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
1087 let mut command = Command::new(&spec.cargo_program);
1088 command
1089 .current_dir(&spec.workspace_root)
1090 .args(["metadata", "--format-version", "1"]);
1091 for argument in metadata_arguments(&spec.cargo_profile_args) {
1092 command.arg(argument);
1093 }
1094 apply_command_environment(&mut command, spec);
1095 let output = command
1096 .output()
1097 .map_err(|source| WasmBuildError::CommandSpawn {
1098 phase: WasmBuildPhase::CargoMetadata,
1099 program: spec.cargo_program.clone(),
1100 source,
1101 })?;
1102 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
1103 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
1104 message: format!("Cargo metadata was not valid JSON: {error}"),
1105 })
1106}
1107
1108fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
1109 let mut selected = Vec::new();
1110 let mut arguments = arguments.iter();
1111 while let Some(argument) = arguments.next() {
1112 let argument_text = argument.to_string_lossy();
1113 match argument_text.as_ref() {
1114 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
1115 selected.push(argument.clone());
1116 }
1117 "--features" | "-F" | "--filter-platform" => {
1118 selected.push(argument.clone());
1119 if let Some(value) = arguments.next() {
1120 selected.push(value.clone());
1121 }
1122 }
1123 _ if argument_text.starts_with("--features=")
1124 || argument_text.starts_with("--filter-platform=") =>
1125 {
1126 selected.push(argument.clone());
1127 }
1128 _ => {}
1129 }
1130 }
1131 selected
1132}
1133
1134#[derive(Clone)]
1135struct MetadataPackage {
1136 id: String,
1137 name: String,
1138 version: String,
1139 manifest_path: PathBuf,
1140 is_local: bool,
1141}
1142
1143fn resolve_local_inputs(
1144 spec: &WasmBuildSpec,
1145 metadata: &Value,
1146) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1147 let packages = metadata_packages(metadata)?;
1148 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
1149 let dependencies = metadata_dependencies(metadata)?;
1150 let mut closure = BTreeSet::new();
1151 while let Some(id) = selected_ids.pop_front() {
1152 if !closure.insert(id.clone()) {
1153 continue;
1154 }
1155 if let Some(deps) = dependencies.get(&id) {
1156 selected_ids.extend(deps.iter().cloned());
1157 }
1158 }
1159
1160 let workspace_root = metadata
1161 .get("workspace_root")
1162 .and_then(Value::as_str)
1163 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
1164 let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
1165 append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
1166 append_additional_inputs(&mut inputs, spec, &workspace_root);
1167 Ok(inputs)
1168}
1169
1170fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
1171 let packages_value = metadata
1172 .get("packages")
1173 .and_then(Value::as_array)
1174 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
1175 let mut packages = HashMap::new();
1176 for value in packages_value {
1177 let package = MetadataPackage {
1178 id: required_string(value, "id")?,
1179 name: required_string(value, "name")?,
1180 version: required_string(value, "version")?,
1181 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
1182 is_local: value.get("source").is_some_and(Value::is_null),
1183 };
1184 packages.insert(package.id.clone(), package);
1185 }
1186 Ok(packages)
1187}
1188
1189fn selected_package_ids(
1190 spec: &WasmBuildSpec,
1191 metadata: &Value,
1192 packages: &HashMap<String, MetadataPackage>,
1193) -> Result<VecDeque<String>, WasmBuildError> {
1194 let workspace_members = metadata
1195 .get("workspace_members")
1196 .and_then(Value::as_array)
1197 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
1198 .iter()
1199 .filter_map(Value::as_str)
1200 .collect::<HashSet<_>>();
1201 let mut selected_ids = VecDeque::new();
1202 for requested in &spec.packages {
1203 let matches = packages
1204 .values()
1205 .filter(|package| {
1206 package.name == *requested && workspace_members.contains(package.id.as_str())
1207 })
1208 .map(|package| package.id.clone())
1209 .collect::<Vec<_>>();
1210 match matches.as_slice() {
1211 [id] => selected_ids.push_back(id.clone()),
1212 [] => {
1213 return Err(WasmBuildError::InvalidSpec {
1214 message: format!("Cargo workspace contains no package named `{requested}`"),
1215 });
1216 }
1217 _ => {
1218 return Err(WasmBuildError::InvalidSpec {
1219 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
1220 });
1221 }
1222 }
1223 }
1224 Ok(selected_ids)
1225}
1226
1227fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
1228 let mut dependencies = HashMap::<String, Vec<String>>::new();
1229 let nodes = metadata
1230 .pointer("/resolve/nodes")
1231 .and_then(Value::as_array)
1232 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
1233 for node in nodes {
1234 let id = required_string(node, "id")?;
1235 let deps = node
1236 .get("deps")
1237 .and_then(Value::as_array)
1238 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
1239 .iter()
1240 .map(|dependency| required_string(dependency, "pkg"))
1241 .collect::<Result<Vec<_>, _>>()?;
1242 dependencies.insert(id, deps);
1243 }
1244 Ok(dependencies)
1245}
1246
1247fn workspace_configuration_inputs(
1248 spec: &WasmBuildSpec,
1249 workspace_root: &Path,
1250) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1251 let mut inputs = Vec::new();
1252 add_if_present(
1253 &mut inputs,
1254 "workspace/Cargo.toml",
1255 workspace_root.join("Cargo.toml"),
1256 );
1257 add_if_present(
1258 &mut inputs,
1259 "workspace/Cargo.lock",
1260 workspace_root.join("Cargo.lock"),
1261 );
1262 add_if_present(
1263 &mut inputs,
1264 "workspace/rust-toolchain.toml",
1265 workspace_root.join("rust-toolchain.toml"),
1266 );
1267 add_if_present(
1268 &mut inputs,
1269 "workspace/rust-toolchain",
1270 workspace_root.join("rust-toolchain"),
1271 );
1272 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
1273 Ok(inputs)
1274}
1275
1276fn append_cargo_configuration_inputs(
1277 inputs: &mut Vec<(PathBuf, PathBuf)>,
1278 spec: &WasmBuildSpec,
1279 workspace_root: &Path,
1280) -> Result<(), WasmBuildError> {
1281 let invocation_root =
1282 spec.workspace_root
1283 .canonicalize()
1284 .map_err(|source| WasmBuildError::Io {
1285 operation: "resolve Cargo invocation directory",
1286 path: spec.workspace_root.clone(),
1287 source,
1288 })?;
1289 let canonical_workspace =
1290 workspace_root
1291 .canonicalize()
1292 .map_err(|source| WasmBuildError::Io {
1293 operation: "resolve Cargo workspace directory",
1294 path: workspace_root.to_owned(),
1295 source,
1296 })?;
1297
1298 let mut roots = invocation_root
1299 .ancestors()
1300 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
1301 .collect::<Vec<_>>();
1302 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
1303 && let Some(config) = effective_cargo_config(&cargo_home)
1304 {
1305 roots.push(config);
1306 }
1307
1308 let mut visited = BTreeSet::new();
1309 for config in roots {
1310 append_cargo_configuration_tree(
1311 inputs,
1312 &config,
1313 &canonical_workspace,
1314 &mut visited,
1315 false,
1316 )?;
1317 }
1318 Ok(())
1319}
1320
1321fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
1322 let extensionless = directory.join("config");
1323 if extensionless.exists() {
1324 return Some(extensionless);
1325 }
1326 let toml = directory.join("config.toml");
1327 toml.exists().then_some(toml)
1328}
1329
1330fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
1331 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
1332 let cargo_home = PathBuf::from(cargo_home);
1333 return Some(if cargo_home.is_absolute() {
1334 cargo_home
1335 } else {
1336 invocation_root.join(cargo_home)
1337 });
1338 }
1339
1340 default_home_directory(spec).map(|home| {
1341 let home = if home.is_absolute() {
1342 home
1343 } else {
1344 invocation_root.join(home)
1345 };
1346 home.join(".cargo")
1347 })
1348}
1349
1350#[cfg(windows)]
1351fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
1352 command_environment_value(spec, "USERPROFILE")
1353 .or_else(|| command_environment_value(spec, "HOME"))
1354 .map(PathBuf::from)
1355}
1356
1357#[cfg(not(windows))]
1358fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
1359 command_environment_value(spec, "HOME").map(PathBuf::from)
1360}
1361
1362fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
1363 spec.extra_env
1364 .get(OsStr::new(name))
1365 .cloned()
1366 .or_else(|| std::env::var_os(name))
1367}
1368
1369fn append_cargo_configuration_tree(
1370 inputs: &mut Vec<(PathBuf, PathBuf)>,
1371 config: &Path,
1372 workspace_root: &Path,
1373 visited: &mut BTreeSet<PathBuf>,
1374 optional: bool,
1375) -> Result<(), WasmBuildError> {
1376 let canonical = match config.canonicalize() {
1377 Ok(canonical) => canonical,
1378 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
1379 Err(source) => {
1380 return Err(WasmBuildError::Io {
1381 operation: "resolve Cargo configuration",
1382 path: config.to_owned(),
1383 source,
1384 });
1385 }
1386 };
1387 if !visited.insert(canonical.clone()) {
1388 return Ok(());
1389 }
1390
1391 let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
1392 operation: "read Cargo configuration",
1393 path: canonical.clone(),
1394 source,
1395 })?;
1396 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
1397 WasmBuildError::InvalidCargoConfiguration {
1398 path: canonical.clone(),
1399 message: error.to_string(),
1400 }
1401 })?;
1402 inputs.push((
1403 cargo_configuration_label(&canonical, workspace_root),
1404 canonical.clone(),
1405 ));
1406
1407 let Some(include) = configuration.get("include") else {
1408 return Ok(());
1409 };
1410 let parent = canonical
1411 .parent()
1412 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
1413 path: canonical.clone(),
1414 message: "configuration path has no parent directory".to_owned(),
1415 })?;
1416 for (included, optional) in cargo_configuration_includes(include, &canonical)? {
1417 let included = if included.is_absolute() {
1418 included
1419 } else {
1420 parent.join(included)
1421 };
1422 append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
1423 }
1424 Ok(())
1425}
1426
1427fn cargo_configuration_includes(
1428 include: &TomlValue,
1429 config: &Path,
1430) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
1431 let values = match include {
1432 TomlValue::Array(values) => values.as_slice(),
1433 value => std::slice::from_ref(value),
1434 };
1435 values
1436 .iter()
1437 .map(|value| match value {
1438 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
1439 TomlValue::Table(table) => {
1440 let path = table
1441 .get("path")
1442 .and_then(TomlValue::as_str)
1443 .ok_or_else(|| {
1444 invalid_cargo_configuration(
1445 config,
1446 "Cargo configuration include table requires a string `path`",
1447 )
1448 })?;
1449 let optional = table
1450 .get("optional")
1451 .map(|value| {
1452 value.as_bool().ok_or_else(|| {
1453 invalid_cargo_configuration(
1454 config,
1455 "Cargo configuration include `optional` must be a boolean",
1456 )
1457 })
1458 })
1459 .transpose()?
1460 .unwrap_or(false);
1461 Ok((PathBuf::from(path), optional))
1462 }
1463 _ => Err(invalid_cargo_configuration(
1464 config,
1465 "Cargo configuration `include` must contain paths or include tables",
1466 )),
1467 })
1468 .collect()
1469}
1470
1471fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
1472 if let Ok(relative) = config.strip_prefix(workspace_root) {
1473 return PathBuf::from("cargo-config/workspace").join(relative);
1474 }
1475 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
1476 PathBuf::from("cargo-config/external").join(location.to_hex())
1477}
1478
1479fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
1480 WasmBuildError::InvalidCargoConfiguration {
1481 path: path.to_owned(),
1482 message: message.to_owned(),
1483 }
1484}
1485
1486fn append_package_inputs(
1487 inputs: &mut Vec<(PathBuf, PathBuf)>,
1488 packages: &HashMap<String, MetadataPackage>,
1489 closure: BTreeSet<String>,
1490 workspace_root: &Path,
1491) -> Result<(), WasmBuildError> {
1492 for id in closure {
1493 let Some(package) = packages.get(&id) else {
1494 return Err(invalid_metadata(&format!(
1495 "resolved package `{id}` is missing"
1496 )));
1497 };
1498 if !package.is_local {
1499 continue;
1500 }
1501 let root = package.manifest_path.parent().ok_or_else(|| {
1502 invalid_metadata(&format!(
1503 "package `{}` manifest has no parent",
1504 package.name
1505 ))
1506 })?;
1507 let relative_manifest = package
1508 .manifest_path
1509 .strip_prefix(workspace_root)
1510 .unwrap_or(&package.manifest_path);
1511 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
1512 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
1513 inputs.push((label, root.to_owned()));
1514 }
1515 Ok(())
1516}
1517
1518fn append_additional_inputs(
1519 inputs: &mut Vec<(PathBuf, PathBuf)>,
1520 spec: &WasmBuildSpec,
1521 workspace_root: &Path,
1522) {
1523 for additional in &spec.additional_inputs {
1524 let path = if additional.is_absolute() {
1525 additional.clone()
1526 } else {
1527 workspace_root.join(additional)
1528 };
1529 inputs.push((PathBuf::from("additional").join(additional), path));
1530 }
1531}
1532
1533fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
1534 let mut exclusions = vec![
1535 spec.target_dir.clone(),
1536 spec.workspace_root.join("target"),
1537 spec.workspace_root.join(".git"),
1538 ];
1539 for (_, path) in inputs {
1540 if path.is_dir() {
1541 exclusions.push(path.join("target"));
1542 exclusions.push(path.join(".git"));
1543 }
1544 }
1545 exclusions
1546}
1547
1548fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
1549 let mut names = spec.inherited_env.clone();
1550 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
1551 let mut environment = names
1552 .into_iter()
1553 .map(|name| {
1554 let value = std::env::var_os(&name);
1555 (name, value)
1556 })
1557 .collect::<BTreeMap<_, _>>();
1558 for (key, value) in &spec.extra_env {
1559 environment.insert(key.clone(), Some(value.clone()));
1560 }
1561 environment
1562}
1563
1564fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
1565 for (key, value) in &spec.extra_env {
1566 command.env(key, value);
1567 }
1568}
1569
1570fn run_cargo_build(spec: &WasmBuildSpec, build_target_dir: &Path) -> Result<(), WasmBuildError> {
1571 let mut command = Command::new(&spec.cargo_program);
1572 command
1573 .current_dir(&spec.workspace_root)
1574 .env("CARGO_TARGET_DIR", build_target_dir)
1575 .args(["build", "--target", &spec.target])
1576 .args(&spec.cargo_profile_args);
1577 apply_command_environment(&mut command, spec);
1578 for package in &spec.packages {
1579 command.args(["-p", package]);
1580 }
1581
1582 let output = command
1583 .output()
1584 .map_err(|source| WasmBuildError::CommandSpawn {
1585 phase: WasmBuildPhase::CargoBuild,
1586 program: spec.cargo_program.clone(),
1587 source,
1588 })?;
1589 ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ())
1590}
1591
1592fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
1593 if output.status.success() {
1594 return Ok(output);
1595 }
1596 Err(WasmBuildError::CommandFailed {
1597 phase,
1598 status: output.status,
1599 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1600 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1601 })
1602}
1603
1604fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
1605 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
1606 packages.sort_unstable();
1607 packages.dedup();
1608 packages
1609 .into_iter()
1610 .map(|package| {
1611 if spec.target == DEFAULT_TARGET {
1612 wasm_path(target_dir, package, &spec.profile_target_dir)
1613 } else {
1614 target_dir
1615 .join(&spec.target)
1616 .join(&spec.profile_target_dir)
1617 .join(format!("{package}.wasm"))
1618 }
1619 })
1620 .collect()
1621}
1622
1623fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
1624 artifacts.iter().all(|path| {
1625 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
1626 && cache_stamp_matches(path, fingerprint)
1627 })
1628}
1629
1630fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
1631 artifacts
1632 .iter()
1633 .filter(|path| {
1634 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
1635 })
1636 .cloned()
1637 .collect()
1638}
1639
1640fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
1641 let stamp_path = artifact_stamp_path(artifact);
1642 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
1643 return false;
1644 };
1645 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
1646}
1647
1648fn artifact_stamp_path(artifact: &Path) -> PathBuf {
1649 let mut name = artifact
1650 .file_name()
1651 .map_or_else(|| OsString::from("artifact"), OsString::from);
1652 name.push(".ic-testkit-build");
1653 artifact.with_file_name(name)
1654}
1655
1656fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
1657 let artifact_digest = digest_bytes("wasm-artifact-v1", &fs::read(artifact)?);
1658 Ok(format!(
1659 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
1660 ))
1661}
1662
1663fn publish_artifact_stamps(
1664 artifacts: &[PathBuf],
1665 fingerprint: InputDigest,
1666) -> Result<(), WasmBuildError> {
1667 for artifact in artifacts {
1668 let stamp_path = artifact_stamp_path(artifact);
1669 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
1670 WasmBuildError::Io {
1671 operation: "hash built Wasm artifact",
1672 path: artifact.clone(),
1673 source,
1674 }
1675 })?;
1676 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
1677 operation: "publish Wasm build stamp",
1678 path: stamp_path,
1679 source,
1680 })?;
1681 }
1682 Ok(())
1683}
1684
1685fn materialize_artifacts(
1686 cached_artifacts: &[PathBuf],
1687 artifacts: &[PathBuf],
1688 fingerprint: InputDigest,
1689) -> Result<(), WasmBuildError> {
1690 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
1691 let contents = fs::read(cached).map_err(|source| WasmBuildError::Io {
1692 operation: "read content-addressed Wasm artifact",
1693 path: cached.clone(),
1694 source,
1695 })?;
1696 write_atomic(artifact, &contents).map_err(|source| WasmBuildError::Io {
1697 operation: "publish Wasm artifact",
1698 path: artifact.clone(),
1699 source,
1700 })?;
1701 }
1702 publish_artifact_stamps(artifacts, fingerprint)
1703}
1704
1705fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
1706 remove_dir_all_if_present(path).map_err(|source| WasmBuildError::Io {
1707 operation: "remove incomplete content-addressed Cargo target directory",
1708 path: path.to_owned(),
1709 source,
1710 })
1711}
1712
1713fn remove_dir_all_if_present(path: &Path) -> io::Result<()> {
1714 match fs::remove_dir_all(path) {
1715 Ok(()) => Ok(()),
1716 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1717 Err(error) => Err(error),
1718 }
1719}
1720
1721fn open_lock_file(path: &Path) -> Result<File, WasmBuildError> {
1722 if let Some(parent) = path.parent() {
1723 create_dir_all(parent, "create Wasm build lock directory")?;
1724 }
1725 OpenOptions::new()
1726 .create(true)
1727 .read(true)
1728 .write(true)
1729 .truncate(false)
1730 .open(path)
1731 .map_err(|source| WasmBuildError::Io {
1732 operation: "open Wasm build lock",
1733 path: path.to_owned(),
1734 source,
1735 })
1736}
1737
1738fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
1739 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
1740 operation,
1741 path: path.to_owned(),
1742 source,
1743 })
1744}
1745
1746fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
1747 if path.exists() {
1748 inputs.push((PathBuf::from(label), path));
1749 }
1750}
1751
1752fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
1753 value
1754 .get(field)
1755 .and_then(Value::as_str)
1756 .map(str::to_owned)
1757 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
1758}
1759
1760fn invalid_metadata(message: &str) -> WasmBuildError {
1761 WasmBuildError::InvalidMetadata {
1762 message: message.to_owned(),
1763 }
1764}
1765
1766impl std::fmt::Display for WasmBuildPhase {
1767 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1768 formatter.write_str(match self {
1769 Self::CargoMetadata => "cargo metadata",
1770 Self::CargoIdentity => "Cargo identity",
1771 Self::RustcIdentity => "Rust compiler identity",
1772 Self::CargoBuild => "cargo build",
1773 })
1774 }
1775}
1776
1777impl std::fmt::Display for WasmBuildError {
1778 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779 match self {
1780 Self::InvalidSpec { message } => {
1781 write!(formatter, "invalid Wasm build spec: {message}")
1782 }
1783 Self::Io {
1784 operation,
1785 path,
1786 source,
1787 } => write!(
1788 formatter,
1789 "failed to {operation} at {}: {source}",
1790 path.display()
1791 ),
1792 Self::CommandSpawn {
1793 phase,
1794 program,
1795 source,
1796 } => write!(
1797 formatter,
1798 "failed to launch {phase} using `{}`: {source}",
1799 program.to_string_lossy(),
1800 ),
1801 Self::CommandFailed {
1802 phase,
1803 status,
1804 stdout,
1805 stderr,
1806 } => write!(
1807 formatter,
1808 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
1809 ),
1810 Self::InvalidMetadata { message } => {
1811 write!(formatter, "invalid Cargo metadata: {message}")
1812 }
1813 Self::InvalidCargoConfiguration { path, message } => write!(
1814 formatter,
1815 "invalid Cargo configuration at {}: {message}",
1816 path.display(),
1817 ),
1818 Self::MissingArtifacts { paths } => write!(
1819 formatter,
1820 "cargo build succeeded without producing: {}",
1821 paths
1822 .iter()
1823 .map(|path| path.display().to_string())
1824 .collect::<Vec<_>>()
1825 .join(", "),
1826 ),
1827 Self::InputsChangedDuringBuild { before, after } => write!(
1828 formatter,
1829 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
1830 ),
1831 Self::FailedBuildCleanup {
1832 build_error,
1833 path,
1834 source,
1835 } => write!(
1836 formatter,
1837 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
1838 path.display(),
1839 ),
1840 }
1841 }
1842}
1843
1844impl std::error::Error for WasmBuildError {
1845 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1846 match self {
1847 Self::Io { source, .. }
1848 | Self::CommandSpawn { source, .. }
1849 | Self::FailedBuildCleanup { source, .. } => Some(source),
1850 _ => None,
1851 }
1852 }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857 use super::{
1858 CACHE_DIRECTORY_TAG_SIGNATURE, IncompleteBuildDirectory, WasmBuildCachePrunePolicy,
1859 WasmBuildError, WasmBuildOutcome, WasmBuildSpec, append_cargo_configuration_inputs,
1860 directory_logical_size, ensure_cache_directory_tag, finish_fingerprint_build,
1861 metadata_arguments, prune_wasm_build_cache, prune_wasm_build_cache_locked, validate_spec,
1862 write_last_used,
1863 };
1864 use std::{
1865 collections::BTreeSet,
1866 ffi::OsString,
1867 fs,
1868 path::{Path, PathBuf},
1869 sync::atomic::{AtomicU64, Ordering},
1870 time::{Duration, SystemTime, UNIX_EPOCH},
1871 };
1872
1873 static TEMP_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1874
1875 #[test]
1876 fn metadata_receives_only_resolution_arguments() {
1877 let arguments = [
1878 OsString::from("--profile"),
1879 OsString::from("fast"),
1880 OsString::from("--locked"),
1881 OsString::from("--features=alpha,beta"),
1882 ];
1883 assert_eq!(
1884 metadata_arguments(&arguments),
1885 [
1886 OsString::from("--locked"),
1887 OsString::from("--features=alpha,beta"),
1888 ]
1889 );
1890 }
1891
1892 #[test]
1893 fn build_spec_requires_at_least_one_package() {
1894 let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
1895 assert!(matches!(
1896 validate_spec(&spec),
1897 Err(WasmBuildError::InvalidSpec { .. })
1898 ));
1899 }
1900
1901 #[test]
1902 fn cache_directory_tag_is_created_at_target_root() {
1903 let target_dir = unique_temp_directory("cache-directory-tag");
1904 fs::write(target_dir.join("CACHEDIR.TAG"), "not a cache tag")
1905 .expect("write invalid cache tag");
1906
1907 ensure_cache_directory_tag(&target_dir).expect("write valid cache tag");
1908
1909 let contents =
1910 fs::read_to_string(target_dir.join("CACHEDIR.TAG")).expect("read cache directory tag");
1911 assert!(contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE));
1912 fs::remove_dir_all(target_dir).expect("remove tag test directory");
1913 }
1914
1915 #[test]
1916 fn failed_build_removes_its_incomplete_fingerprint_directory() {
1917 let target_dir = unique_temp_directory("failed-build-cleanup");
1918 let fingerprint_dir = target_dir.join("a".repeat(64));
1919 fs::create_dir_all(&fingerprint_dir).expect("create incomplete target directory");
1920 fs::write(fingerprint_dir.join("partial-output"), b"partial")
1921 .expect("write incomplete output");
1922 let failure: Result<WasmBuildOutcome, WasmBuildError> = Err(WasmBuildError::InvalidSpec {
1923 message: "synthetic build failure".to_owned(),
1924 });
1925
1926 let result = finish_fingerprint_build(
1927 failure,
1928 IncompleteBuildDirectory::new(fingerprint_dir.clone()),
1929 );
1930
1931 assert!(matches!(result, Err(WasmBuildError::InvalidSpec { .. })));
1932 assert!(!fingerprint_dir.exists());
1933 fs::remove_dir_all(target_dir).expect("remove cleanup test directory");
1934 }
1935
1936 #[test]
1937 fn age_pruning_removes_only_stale_fingerprint_directories() {
1938 let target_dir = unique_temp_directory("age-pruning");
1939 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1940 let old = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1941 let current = create_cache_entry(&cache_root, 'b', 10, SystemTime::now());
1942 let unrelated = cache_root.join("not-a-fingerprint");
1943 fs::create_dir_all(&unrelated).expect("create unrelated directory");
1944
1945 let report = prune_wasm_build_cache(
1946 &target_dir,
1947 WasmBuildCachePrunePolicy::new().with_max_age(Duration::from_secs(60)),
1948 )
1949 .expect("prune old cache entry");
1950
1951 assert_eq!(report.entries_scanned(), 2);
1952 assert_eq!(report.entries_removed(), 1);
1953 assert_eq!(report.entries_retained(), 1);
1954 assert!(!old.exists());
1955 assert!(current.exists());
1956 assert!(unrelated.exists());
1957 assert!(target_dir.join("CACHEDIR.TAG").is_file());
1958 fs::remove_dir_all(target_dir).expect("remove age-pruning test directory");
1959 }
1960
1961 #[test]
1962 fn size_pruning_removes_least_recently_used_entries_first() {
1963 let target_dir = unique_temp_directory("size-pruning");
1964 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1965 let oldest = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1966 let middle = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
1967 let newest = create_cache_entry(&cache_root, 'c', 10, UNIX_EPOCH + Duration::from_secs(3));
1968 let newest_bytes = directory_logical_size(&newest).expect("measure newest entry");
1969
1970 let report = prune_wasm_build_cache(
1971 &target_dir,
1972 WasmBuildCachePrunePolicy::new().with_max_size_bytes(newest_bytes),
1973 )
1974 .expect("prune cache to size");
1975
1976 assert_eq!(report.entries_scanned(), 3);
1977 assert_eq!(report.entries_removed(), 2);
1978 assert_eq!(report.entries_retained(), 1);
1979 assert!(report.bytes_retained() <= newest_bytes);
1980 assert!(!oldest.exists());
1981 assert!(!middle.exists());
1982 assert!(newest.exists());
1983 fs::remove_dir_all(target_dir).expect("remove size-pruning test directory");
1984 }
1985
1986 #[test]
1987 fn in_build_pruning_protects_the_active_fingerprint() {
1988 let target_dir = unique_temp_directory("protected-pruning");
1989 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1990 let stale = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1991 let active = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
1992
1993 let report = prune_wasm_build_cache_locked(
1994 &target_dir,
1995 WasmBuildCachePrunePolicy::new()
1996 .with_max_age(Duration::ZERO)
1997 .with_max_size_bytes(0),
1998 Some(&active),
1999 )
2000 .expect("prune while protecting active cache entry");
2001
2002 assert_eq!(report.entries_scanned(), 2);
2003 assert_eq!(report.entries_removed(), 1);
2004 assert!(!stale.exists());
2005 assert!(active.exists());
2006 assert!(report.bytes_retained() > 0);
2007 fs::remove_dir_all(target_dir).expect("remove protected-pruning test directory");
2008 }
2009
2010 #[test]
2011 fn cargo_configuration_discovery_matches_cargo_search_and_include_rules() {
2012 let root = unique_temp_directory("cargo-configuration-discovery");
2013 let workspace = root.join("workspace");
2014 let workspace_cargo = workspace.join(".cargo");
2015 let ancestor_cargo = root.join(".cargo");
2016 let cargo_home = root.join("cargo-home");
2017 fs::create_dir_all(&workspace_cargo).expect("create workspace Cargo directory");
2018 fs::create_dir_all(&ancestor_cargo).expect("create ancestor Cargo directory");
2019 fs::create_dir_all(&cargo_home).expect("create Cargo home");
2020
2021 fs::write(
2022 workspace_cargo.join("config"),
2023 "include = [\"included.toml\", { path = \"missing.toml\", optional = true }]\n",
2024 )
2025 .expect("write effective workspace Cargo config");
2026 fs::write(
2027 workspace_cargo.join("config.toml"),
2028 "[build]\ntarget-dir = \"ignored-by-cargo\"\n",
2029 )
2030 .expect("write shadowed workspace Cargo config");
2031 fs::write(
2032 workspace_cargo.join("included.toml"),
2033 "include = \"nested.toml\"\n",
2034 )
2035 .expect("write included Cargo config");
2036 fs::write(
2037 workspace_cargo.join("nested.toml"),
2038 "[build]\nincremental = false\n",
2039 )
2040 .expect("write nested Cargo config");
2041 fs::write(
2042 ancestor_cargo.join("config.toml"),
2043 "[net]\noffline = true\n",
2044 )
2045 .expect("write ancestor Cargo config");
2046 fs::write(cargo_home.join("config"), "[term]\nquiet = true\n")
2047 .expect("write Cargo-home config");
2048
2049 let cargo_home_text = cargo_home.to_str().expect("temporary path is UTF-8");
2050 let spec = WasmBuildSpec::new(&workspace, &root.join("target"), &["fixture"], "debug")
2051 .with_extra_env(&[("CARGO_HOME", cargo_home_text)]);
2052 let mut inputs = Vec::new();
2053 append_cargo_configuration_inputs(&mut inputs, &spec, &workspace)
2054 .expect("discover effective Cargo configuration");
2055 let paths = inputs
2056 .into_iter()
2057 .map(|(_, path)| path)
2058 .collect::<BTreeSet<_>>();
2059
2060 assert!(paths.contains(&workspace_cargo.join("config").canonicalize().unwrap()));
2061 assert!(
2062 paths.contains(
2063 &workspace_cargo
2064 .join("included.toml")
2065 .canonicalize()
2066 .unwrap()
2067 )
2068 );
2069 assert!(paths.contains(&workspace_cargo.join("nested.toml").canonicalize().unwrap()));
2070 assert!(paths.contains(&ancestor_cargo.join("config.toml").canonicalize().unwrap()));
2071 assert!(paths.contains(&cargo_home.join("config").canonicalize().unwrap()));
2072 assert!(!paths.contains(&workspace_cargo.join("config.toml").canonicalize().unwrap()));
2073 assert_eq!(paths.len(), 5);
2074 fs::remove_dir_all(root).expect("remove Cargo-configuration test directory");
2075 }
2076
2077 #[test]
2078 fn required_cargo_configuration_include_is_an_exact_input() {
2079 let root = unique_temp_directory("required-cargo-configuration-include");
2080 let workspace = root.join("workspace");
2081 let cargo_dir = workspace.join(".cargo");
2082 fs::create_dir_all(&cargo_dir).expect("create workspace Cargo directory");
2083 fs::write(
2084 cargo_dir.join("config.toml"),
2085 "include = \"missing.toml\"\n",
2086 )
2087 .expect("write Cargo config");
2088 let isolated_home = root.join("isolated-cargo-home");
2089 let isolated_home_text = isolated_home.to_str().expect("temporary path is UTF-8");
2090 let spec = WasmBuildSpec::new(&workspace, &root.join("target"), &["fixture"], "debug")
2091 .with_extra_env(&[("CARGO_HOME", isolated_home_text)]);
2092
2093 let error = append_cargo_configuration_inputs(&mut Vec::new(), &spec, &workspace)
2094 .expect_err("required missing include must fail input discovery");
2095
2096 assert!(matches!(error, WasmBuildError::Io { .. }));
2097 fs::remove_dir_all(root).expect("remove required-include test directory");
2098 }
2099
2100 fn create_cache_entry(
2101 cache_root: &Path,
2102 fingerprint_digit: char,
2103 payload_bytes: usize,
2104 last_used: SystemTime,
2105 ) -> PathBuf {
2106 let path = cache_root.join(fingerprint_digit.to_string().repeat(64));
2107 fs::create_dir_all(&path).expect("create cache entry");
2108 fs::write(path.join("payload"), vec![0; payload_bytes]).expect("write cache payload");
2109 write_last_used(&path, last_used).expect("write cache use time");
2110 path
2111 }
2112
2113 fn unique_temp_directory(label: &str) -> PathBuf {
2114 let sequence = TEMP_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2115 let path = std::env::temp_dir().join(format!(
2116 "ic-testkit-{label}-{}-{sequence}",
2117 std::process::id()
2118 ));
2119 if path.exists() {
2120 fs::remove_dir_all(&path).expect("remove stale test directory");
2121 }
2122 fs::create_dir_all(&path).expect("create test directory");
2123 path
2124 }
2125}