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