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