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