1use fs2::FileExt as _;
2use serde_json::Value;
3use std::{
4 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
5 ffi::{OsStr, OsString},
6 fs::{self, File, OpenOptions},
7 io,
8 path::{Path, PathBuf},
9 process::{Command, ExitStatus, Output},
10 time::{Duration, Instant},
11};
12
13use super::{
14 digest::{
15 InputDigest, InputHasher, digest_bytes, digest_labeled_paths, os_bytes, write_atomic,
16 },
17 wasm::wasm_path,
18};
19
20const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
21const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
22const AUTOMATIC_ENVIRONMENT: &[&str] = &[
23 "CARGO_BUILD_RUSTC",
24 "CARGO_ENCODED_RUSTFLAGS",
25 "RUSTC",
26 "RUSTC_WRAPPER",
27 "RUSTC_WORKSPACE_WRAPPER",
28 "RUSTFLAGS",
29 "RUSTUP_TOOLCHAIN",
30];
31
32#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct WasmBuildSpec {
40 workspace_root: PathBuf,
41 target_dir: PathBuf,
42 packages: Vec<String>,
43 profile_target_dir: String,
44 cargo_profile_args: Vec<OsString>,
45 extra_env: BTreeMap<OsString, OsString>,
46 inherited_env: BTreeSet<OsString>,
47 additional_inputs: Vec<PathBuf>,
48 target: String,
49 cargo_program: OsString,
50 rustc_program: OsString,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub enum WasmBuildOutcome {
56 Built(WasmBuildRecord),
58 Reused(WasmBuildRecord),
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct WasmBuildRecord {
65 fingerprint: InputDigest,
66 input_digest: InputDigest,
67 artifacts: Vec<PathBuf>,
68 timings: WasmBuildTimings,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct WasmBuildTimings {
74 lock_wait: Duration,
75 input_resolution: Duration,
76 cargo_build: Option<Duration>,
77 total: Duration,
78}
79
80#[non_exhaustive]
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum WasmBuildPhase {
84 CargoMetadata,
86 CargoIdentity,
88 RustcIdentity,
90 CargoBuild,
92}
93
94#[non_exhaustive]
96#[derive(Debug)]
97pub enum WasmBuildError {
98 InvalidSpec { message: String },
100 Io {
102 operation: &'static str,
103 path: PathBuf,
104 source: io::Error,
105 },
106 CommandSpawn {
108 phase: WasmBuildPhase,
109 program: OsString,
110 source: io::Error,
111 },
112 CommandFailed {
114 phase: WasmBuildPhase,
115 status: ExitStatus,
116 stdout: String,
117 stderr: String,
118 },
119 InvalidMetadata { message: String },
121 MissingArtifacts { paths: Vec<PathBuf> },
123 InputsChangedDuringBuild {
125 before: InputDigest,
126 after: InputDigest,
127 },
128}
129
130impl WasmBuildSpec {
131 #[must_use]
136 pub fn new(
137 workspace_root: &Path,
138 target_dir: &Path,
139 packages: &[&str],
140 profile_target_dir: &str,
141 ) -> Self {
142 Self {
143 workspace_root: workspace_root.to_owned(),
144 target_dir: target_dir.to_owned(),
145 packages: packages
146 .iter()
147 .map(|package| (*package).to_owned())
148 .collect(),
149 profile_target_dir: profile_target_dir.to_owned(),
150 cargo_profile_args: Vec::new(),
151 extra_env: BTreeMap::new(),
152 inherited_env: BTreeSet::new(),
153 additional_inputs: Vec::new(),
154 target: DEFAULT_TARGET.to_owned(),
155 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
156 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
157 }
158 }
159
160 #[must_use]
162 pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
163 self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
164 self
165 }
166
167 #[must_use]
169 pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
170 self.extra_env = environment
171 .iter()
172 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
173 .collect();
174 self
175 }
176
177 #[must_use]
182 pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
183 self.inherited_env.extend(names.iter().map(OsString::from));
184 self
185 }
186
187 #[must_use]
192 pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
193 self.additional_inputs
194 .extend(paths.iter().map(PathBuf::from));
195 self
196 }
197
198 #[must_use]
200 pub fn with_target(mut self, target: &str) -> Self {
201 target.clone_into(&mut self.target);
202 self
203 }
204
205 #[must_use]
207 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
208 self.cargo_program = program.into();
209 self
210 }
211
212 #[must_use]
214 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
215 self.rustc_program = program.into();
216 self
217 }
218
219 #[must_use]
221 pub fn workspace_root(&self) -> &Path {
222 &self.workspace_root
223 }
224
225 #[must_use]
227 pub fn target_dir(&self) -> &Path {
228 &self.target_dir
229 }
230
231 #[must_use]
233 pub fn packages(&self) -> &[String] {
234 &self.packages
235 }
236}
237
238impl WasmBuildOutcome {
239 #[must_use]
241 pub const fn record(&self) -> &WasmBuildRecord {
242 match self {
243 Self::Built(record) | Self::Reused(record) => record,
244 }
245 }
246
247 #[must_use]
249 pub const fn is_reused(&self) -> bool {
250 matches!(self, Self::Reused(_))
251 }
252}
253
254impl WasmBuildRecord {
255 #[must_use]
257 pub const fn fingerprint(&self) -> InputDigest {
258 self.fingerprint
259 }
260
261 #[must_use]
263 pub const fn input_digest(&self) -> InputDigest {
264 self.input_digest
265 }
266
267 #[must_use]
269 pub fn artifacts(&self) -> &[PathBuf] {
270 &self.artifacts
271 }
272
273 #[must_use]
275 pub const fn timings(&self) -> WasmBuildTimings {
276 self.timings
277 }
278}
279
280impl WasmBuildTimings {
281 #[must_use]
283 pub const fn lock_wait(self) -> Duration {
284 self.lock_wait
285 }
286
287 #[must_use]
289 pub const fn input_resolution(self) -> Duration {
290 self.input_resolution
291 }
292
293 #[must_use]
295 pub const fn cargo_build(self) -> Option<Duration> {
296 self.cargo_build
297 }
298
299 #[must_use]
301 pub const fn total(self) -> Duration {
302 self.total
303 }
304}
305
306pub fn build_wasm_canisters_cached(
313 spec: &WasmBuildSpec,
314) -> Result<WasmBuildOutcome, WasmBuildError> {
315 let total_started = Instant::now();
316 validate_spec(spec)?;
317 create_dir_all(&spec.target_dir, "create Cargo target directory")?;
318
319 let lock_path = spec.target_dir.join(".ic-testkit/wasm-build.lock");
320 let lock_file = open_lock_file(&lock_path)?;
321 let lock_started = Instant::now();
322 lock_file
323 .lock_exclusive()
324 .map_err(|source| WasmBuildError::Io {
325 operation: "lock Wasm build cache",
326 path: lock_path,
327 source,
328 })?;
329 let lock_wait = lock_started.elapsed();
330
331 let input_started = Instant::now();
332 let resolved = build_fingerprint(spec)?;
333 let mut input_resolution = input_started.elapsed();
334 let fingerprint = resolved.fingerprint;
335 let artifacts = expected_artifacts(spec, &spec.target_dir);
336
337 if artifact_set_matches(&artifacts, fingerprint) {
338 return Ok(WasmBuildOutcome::Reused(WasmBuildRecord {
339 fingerprint,
340 input_digest: resolved.input_digest,
341 artifacts,
342 timings: WasmBuildTimings {
343 lock_wait,
344 input_resolution,
345 cargo_build: None,
346 total: total_started.elapsed(),
347 },
348 }));
349 }
350
351 let build_target_dir = spec
352 .target_dir
353 .join(".ic-testkit/wasm-targets")
354 .join(fingerprint.to_hex());
355 let cached_artifacts = expected_artifacts(spec, &build_target_dir);
356 if artifact_set_matches(&cached_artifacts, fingerprint) {
357 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
358 return Ok(WasmBuildOutcome::Reused(WasmBuildRecord {
359 fingerprint,
360 input_digest: resolved.input_digest,
361 artifacts,
362 timings: WasmBuildTimings {
363 lock_wait,
364 input_resolution,
365 cargo_build: None,
366 total: total_started.elapsed(),
367 },
368 }));
369 }
370
371 remove_directory_if_present(&build_target_dir)?;
372 create_dir_all(
373 &build_target_dir,
374 "create content-addressed Cargo target directory",
375 )?;
376 let build_started = Instant::now();
377 run_cargo_build(spec, &build_target_dir)?;
378 let cargo_build = build_started.elapsed();
379 let missing = missing_artifacts(&cached_artifacts);
380 if !missing.is_empty() {
381 return Err(WasmBuildError::MissingArtifacts { paths: missing });
382 }
383
384 let verification_started = Instant::now();
385 let verified = build_fingerprint(spec)?;
386 input_resolution += verification_started.elapsed();
387 if fingerprint != verified.fingerprint {
388 return Err(WasmBuildError::InputsChangedDuringBuild {
389 before: fingerprint,
390 after: verified.fingerprint,
391 });
392 }
393
394 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
395 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
396
397 Ok(WasmBuildOutcome::Built(WasmBuildRecord {
398 fingerprint,
399 input_digest: resolved.input_digest,
400 artifacts,
401 timings: WasmBuildTimings {
402 lock_wait,
403 input_resolution,
404 cargo_build: Some(cargo_build),
405 total: total_started.elapsed(),
406 },
407 }))
408}
409
410fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
411 if spec.packages.is_empty() {
412 return Err(WasmBuildError::InvalidSpec {
413 message: "at least one Cargo package is required".to_owned(),
414 });
415 }
416 if spec.profile_target_dir.is_empty() {
417 return Err(WasmBuildError::InvalidSpec {
418 message: "Cargo profile target directory must not be empty".to_owned(),
419 });
420 }
421 if spec.target.is_empty() {
422 return Err(WasmBuildError::InvalidSpec {
423 message: "Cargo compilation target must not be empty".to_owned(),
424 });
425 }
426 Ok(())
427}
428
429struct ResolvedFingerprint {
430 fingerprint: InputDigest,
431 input_digest: InputDigest,
432}
433
434fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedFingerprint, WasmBuildError> {
435 let cargo_identity = command_identity(
436 spec,
437 WasmBuildPhase::CargoIdentity,
438 &spec.cargo_program,
439 &["--version", "--verbose"],
440 )?;
441 let rustc_program = spec
442 .extra_env
443 .get(OsStr::new("RUSTC"))
444 .unwrap_or(&spec.rustc_program);
445 let rustc_identity =
446 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
447 let metadata = cargo_metadata(spec)?;
448 let inputs = resolve_local_inputs(spec, &metadata)?;
449 let exclusions = source_exclusions(spec, &inputs);
450 let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
451 .map_err(|source| WasmBuildError::Io {
452 operation: "hash Wasm build inputs",
453 path: spec.workspace_root.clone(),
454 source,
455 })?;
456
457 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
458 let mut packages = spec.packages.clone();
459 packages.sort();
460 packages.dedup();
461 for package in packages {
462 hasher.field("package", package.as_bytes());
463 }
464 hasher.field("target", spec.target.as_bytes());
465 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
466 for argument in &spec.cargo_profile_args {
467 hasher.field("cargo-argument", &os_bytes(argument));
468 }
469 for (key, value) in effective_environment(spec) {
470 hasher.field("environment-key", &os_bytes(&key));
471 if let Some(value) = value {
472 hasher.field("environment-value", &os_bytes(&value));
473 } else {
474 hasher.field("environment-unset", b"");
475 }
476 }
477 hasher.field("cargo-identity", &cargo_identity);
478 hasher.field("rustc-identity", &rustc_identity);
479 hasher.field("source-input-digest", input_digest.as_bytes());
480 Ok(ResolvedFingerprint {
481 fingerprint: hasher.finish(),
482 input_digest,
483 })
484}
485
486fn command_identity(
487 spec: &WasmBuildSpec,
488 phase: WasmBuildPhase,
489 program: &OsStr,
490 arguments: &[&str],
491) -> Result<Vec<u8>, WasmBuildError> {
492 let mut command = Command::new(program);
493 command.current_dir(&spec.workspace_root).args(arguments);
494 apply_command_environment(&mut command, spec);
495 let output = command
496 .output()
497 .map_err(|source| WasmBuildError::CommandSpawn {
498 phase,
499 program: program.to_owned(),
500 source,
501 })?;
502 ensure_command_success(phase, output).map(|output| {
503 let mut identity = output.stdout;
504 identity.extend_from_slice(&output.stderr);
505 identity
506 })
507}
508
509fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
510 let mut command = Command::new(&spec.cargo_program);
511 command
512 .current_dir(&spec.workspace_root)
513 .args(["metadata", "--format-version", "1"]);
514 for argument in metadata_arguments(&spec.cargo_profile_args) {
515 command.arg(argument);
516 }
517 apply_command_environment(&mut command, spec);
518 let output = command
519 .output()
520 .map_err(|source| WasmBuildError::CommandSpawn {
521 phase: WasmBuildPhase::CargoMetadata,
522 program: spec.cargo_program.clone(),
523 source,
524 })?;
525 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
526 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
527 message: format!("Cargo metadata was not valid JSON: {error}"),
528 })
529}
530
531fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
532 let mut selected = Vec::new();
533 let mut arguments = arguments.iter();
534 while let Some(argument) = arguments.next() {
535 let argument_text = argument.to_string_lossy();
536 match argument_text.as_ref() {
537 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
538 selected.push(argument.clone());
539 }
540 "--features" | "-F" | "--filter-platform" => {
541 selected.push(argument.clone());
542 if let Some(value) = arguments.next() {
543 selected.push(value.clone());
544 }
545 }
546 _ if argument_text.starts_with("--features=")
547 || argument_text.starts_with("--filter-platform=") =>
548 {
549 selected.push(argument.clone());
550 }
551 _ => {}
552 }
553 }
554 selected
555}
556
557#[derive(Clone)]
558struct MetadataPackage {
559 id: String,
560 name: String,
561 version: String,
562 manifest_path: PathBuf,
563 is_local: bool,
564}
565
566fn resolve_local_inputs(
567 spec: &WasmBuildSpec,
568 metadata: &Value,
569) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
570 let packages = metadata_packages(metadata)?;
571 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
572 let dependencies = metadata_dependencies(metadata)?;
573 let mut closure = BTreeSet::new();
574 while let Some(id) = selected_ids.pop_front() {
575 if !closure.insert(id.clone()) {
576 continue;
577 }
578 if let Some(deps) = dependencies.get(&id) {
579 selected_ids.extend(deps.iter().cloned());
580 }
581 }
582
583 let workspace_root = metadata
584 .get("workspace_root")
585 .and_then(Value::as_str)
586 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
587 let mut inputs = workspace_configuration_inputs(&workspace_root);
588 append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
589 append_additional_inputs(&mut inputs, spec, &workspace_root);
590 Ok(inputs)
591}
592
593fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
594 let packages_value = metadata
595 .get("packages")
596 .and_then(Value::as_array)
597 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
598 let mut packages = HashMap::new();
599 for value in packages_value {
600 let package = MetadataPackage {
601 id: required_string(value, "id")?,
602 name: required_string(value, "name")?,
603 version: required_string(value, "version")?,
604 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
605 is_local: value.get("source").is_some_and(Value::is_null),
606 };
607 packages.insert(package.id.clone(), package);
608 }
609 Ok(packages)
610}
611
612fn selected_package_ids(
613 spec: &WasmBuildSpec,
614 metadata: &Value,
615 packages: &HashMap<String, MetadataPackage>,
616) -> Result<VecDeque<String>, WasmBuildError> {
617 let workspace_members = metadata
618 .get("workspace_members")
619 .and_then(Value::as_array)
620 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
621 .iter()
622 .filter_map(Value::as_str)
623 .collect::<HashSet<_>>();
624 let mut selected_ids = VecDeque::new();
625 for requested in &spec.packages {
626 let matches = packages
627 .values()
628 .filter(|package| {
629 package.name == *requested && workspace_members.contains(package.id.as_str())
630 })
631 .map(|package| package.id.clone())
632 .collect::<Vec<_>>();
633 match matches.as_slice() {
634 [id] => selected_ids.push_back(id.clone()),
635 [] => {
636 return Err(WasmBuildError::InvalidSpec {
637 message: format!("Cargo workspace contains no package named `{requested}`"),
638 });
639 }
640 _ => {
641 return Err(WasmBuildError::InvalidSpec {
642 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
643 });
644 }
645 }
646 }
647 Ok(selected_ids)
648}
649
650fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
651 let mut dependencies = HashMap::<String, Vec<String>>::new();
652 let nodes = metadata
653 .pointer("/resolve/nodes")
654 .and_then(Value::as_array)
655 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
656 for node in nodes {
657 let id = required_string(node, "id")?;
658 let deps = node
659 .get("deps")
660 .and_then(Value::as_array)
661 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
662 .iter()
663 .map(|dependency| required_string(dependency, "pkg"))
664 .collect::<Result<Vec<_>, _>>()?;
665 dependencies.insert(id, deps);
666 }
667 Ok(dependencies)
668}
669
670fn workspace_configuration_inputs(workspace_root: &Path) -> Vec<(PathBuf, PathBuf)> {
671 let mut inputs = Vec::new();
672 add_if_present(
673 &mut inputs,
674 "workspace/Cargo.toml",
675 workspace_root.join("Cargo.toml"),
676 );
677 add_if_present(
678 &mut inputs,
679 "workspace/Cargo.lock",
680 workspace_root.join("Cargo.lock"),
681 );
682 add_if_present(
683 &mut inputs,
684 "workspace/.cargo/config.toml",
685 workspace_root.join(".cargo/config.toml"),
686 );
687 add_if_present(
688 &mut inputs,
689 "workspace/.cargo/config",
690 workspace_root.join(".cargo/config"),
691 );
692 add_if_present(
693 &mut inputs,
694 "workspace/rust-toolchain.toml",
695 workspace_root.join("rust-toolchain.toml"),
696 );
697 add_if_present(
698 &mut inputs,
699 "workspace/rust-toolchain",
700 workspace_root.join("rust-toolchain"),
701 );
702 inputs
703}
704
705fn append_package_inputs(
706 inputs: &mut Vec<(PathBuf, PathBuf)>,
707 packages: &HashMap<String, MetadataPackage>,
708 closure: BTreeSet<String>,
709 workspace_root: &Path,
710) -> Result<(), WasmBuildError> {
711 for id in closure {
712 let Some(package) = packages.get(&id) else {
713 return Err(invalid_metadata(&format!(
714 "resolved package `{id}` is missing"
715 )));
716 };
717 if !package.is_local {
718 continue;
719 }
720 let root = package.manifest_path.parent().ok_or_else(|| {
721 invalid_metadata(&format!(
722 "package `{}` manifest has no parent",
723 package.name
724 ))
725 })?;
726 let relative_manifest = package
727 .manifest_path
728 .strip_prefix(workspace_root)
729 .unwrap_or(&package.manifest_path);
730 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
731 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
732 inputs.push((label, root.to_owned()));
733 }
734 Ok(())
735}
736
737fn append_additional_inputs(
738 inputs: &mut Vec<(PathBuf, PathBuf)>,
739 spec: &WasmBuildSpec,
740 workspace_root: &Path,
741) {
742 for additional in &spec.additional_inputs {
743 let path = if additional.is_absolute() {
744 additional.clone()
745 } else {
746 workspace_root.join(additional)
747 };
748 inputs.push((PathBuf::from("additional").join(additional), path));
749 }
750}
751
752fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
753 let mut exclusions = vec![
754 spec.target_dir.clone(),
755 spec.workspace_root.join("target"),
756 spec.workspace_root.join(".git"),
757 ];
758 for (_, path) in inputs {
759 if path.is_dir() {
760 exclusions.push(path.join("target"));
761 exclusions.push(path.join(".git"));
762 }
763 }
764 exclusions
765}
766
767fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
768 let mut names = spec.inherited_env.clone();
769 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
770 let mut environment = names
771 .into_iter()
772 .map(|name| {
773 let value = std::env::var_os(&name);
774 (name, value)
775 })
776 .collect::<BTreeMap<_, _>>();
777 for (key, value) in &spec.extra_env {
778 environment.insert(key.clone(), Some(value.clone()));
779 }
780 environment
781}
782
783fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
784 for (key, value) in &spec.extra_env {
785 command.env(key, value);
786 }
787}
788
789fn run_cargo_build(spec: &WasmBuildSpec, build_target_dir: &Path) -> Result<(), WasmBuildError> {
790 let mut command = Command::new(&spec.cargo_program);
791 command
792 .current_dir(&spec.workspace_root)
793 .env("CARGO_TARGET_DIR", build_target_dir)
794 .args(["build", "--target", &spec.target])
795 .args(&spec.cargo_profile_args);
796 apply_command_environment(&mut command, spec);
797 for package in &spec.packages {
798 command.args(["-p", package]);
799 }
800
801 let output = command
802 .output()
803 .map_err(|source| WasmBuildError::CommandSpawn {
804 phase: WasmBuildPhase::CargoBuild,
805 program: spec.cargo_program.clone(),
806 source,
807 })?;
808 ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ())
809}
810
811fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
812 if output.status.success() {
813 return Ok(output);
814 }
815 Err(WasmBuildError::CommandFailed {
816 phase,
817 status: output.status,
818 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
819 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
820 })
821}
822
823fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
824 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
825 packages.sort_unstable();
826 packages.dedup();
827 packages
828 .into_iter()
829 .map(|package| {
830 if spec.target == DEFAULT_TARGET {
831 wasm_path(target_dir, package, &spec.profile_target_dir)
832 } else {
833 target_dir
834 .join(&spec.target)
835 .join(&spec.profile_target_dir)
836 .join(format!("{package}.wasm"))
837 }
838 })
839 .collect()
840}
841
842fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
843 artifacts.iter().all(|path| {
844 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
845 && cache_stamp_matches(path, fingerprint)
846 })
847}
848
849fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
850 artifacts
851 .iter()
852 .filter(|path| {
853 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
854 })
855 .cloned()
856 .collect()
857}
858
859fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
860 let stamp_path = artifact_stamp_path(artifact);
861 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
862 return false;
863 };
864 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
865}
866
867fn artifact_stamp_path(artifact: &Path) -> PathBuf {
868 let mut name = artifact
869 .file_name()
870 .map_or_else(|| OsString::from("artifact"), OsString::from);
871 name.push(".ic-testkit-build");
872 artifact.with_file_name(name)
873}
874
875fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
876 let artifact_digest = digest_bytes("wasm-artifact-v1", &fs::read(artifact)?);
877 Ok(format!(
878 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
879 ))
880}
881
882fn publish_artifact_stamps(
883 artifacts: &[PathBuf],
884 fingerprint: InputDigest,
885) -> Result<(), WasmBuildError> {
886 for artifact in artifacts {
887 let stamp_path = artifact_stamp_path(artifact);
888 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
889 WasmBuildError::Io {
890 operation: "hash built Wasm artifact",
891 path: artifact.clone(),
892 source,
893 }
894 })?;
895 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
896 operation: "publish Wasm build stamp",
897 path: stamp_path,
898 source,
899 })?;
900 }
901 Ok(())
902}
903
904fn materialize_artifacts(
905 cached_artifacts: &[PathBuf],
906 artifacts: &[PathBuf],
907 fingerprint: InputDigest,
908) -> Result<(), WasmBuildError> {
909 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
910 let contents = fs::read(cached).map_err(|source| WasmBuildError::Io {
911 operation: "read content-addressed Wasm artifact",
912 path: cached.clone(),
913 source,
914 })?;
915 write_atomic(artifact, &contents).map_err(|source| WasmBuildError::Io {
916 operation: "publish Wasm artifact",
917 path: artifact.clone(),
918 source,
919 })?;
920 }
921 publish_artifact_stamps(artifacts, fingerprint)
922}
923
924fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
925 match fs::remove_dir_all(path) {
926 Ok(()) => Ok(()),
927 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
928 Err(source) => Err(WasmBuildError::Io {
929 operation: "remove incomplete content-addressed Cargo target directory",
930 path: path.to_owned(),
931 source,
932 }),
933 }
934}
935
936fn open_lock_file(path: &Path) -> Result<File, WasmBuildError> {
937 if let Some(parent) = path.parent() {
938 create_dir_all(parent, "create Wasm build lock directory")?;
939 }
940 OpenOptions::new()
941 .create(true)
942 .read(true)
943 .write(true)
944 .truncate(false)
945 .open(path)
946 .map_err(|source| WasmBuildError::Io {
947 operation: "open Wasm build lock",
948 path: path.to_owned(),
949 source,
950 })
951}
952
953fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
954 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
955 operation,
956 path: path.to_owned(),
957 source,
958 })
959}
960
961fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
962 if path.exists() {
963 inputs.push((PathBuf::from(label), path));
964 }
965}
966
967fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
968 value
969 .get(field)
970 .and_then(Value::as_str)
971 .map(str::to_owned)
972 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
973}
974
975fn invalid_metadata(message: &str) -> WasmBuildError {
976 WasmBuildError::InvalidMetadata {
977 message: message.to_owned(),
978 }
979}
980
981impl std::fmt::Display for WasmBuildPhase {
982 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983 formatter.write_str(match self {
984 Self::CargoMetadata => "cargo metadata",
985 Self::CargoIdentity => "Cargo identity",
986 Self::RustcIdentity => "Rust compiler identity",
987 Self::CargoBuild => "cargo build",
988 })
989 }
990}
991
992impl std::fmt::Display for WasmBuildError {
993 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
994 match self {
995 Self::InvalidSpec { message } => {
996 write!(formatter, "invalid Wasm build spec: {message}")
997 }
998 Self::Io {
999 operation,
1000 path,
1001 source,
1002 } => write!(
1003 formatter,
1004 "failed to {operation} at {}: {source}",
1005 path.display()
1006 ),
1007 Self::CommandSpawn {
1008 phase,
1009 program,
1010 source,
1011 } => write!(
1012 formatter,
1013 "failed to launch {phase} using `{}`: {source}",
1014 program.to_string_lossy(),
1015 ),
1016 Self::CommandFailed {
1017 phase,
1018 status,
1019 stdout,
1020 stderr,
1021 } => write!(
1022 formatter,
1023 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
1024 ),
1025 Self::InvalidMetadata { message } => {
1026 write!(formatter, "invalid Cargo metadata: {message}")
1027 }
1028 Self::MissingArtifacts { paths } => write!(
1029 formatter,
1030 "cargo build succeeded without producing: {}",
1031 paths
1032 .iter()
1033 .map(|path| path.display().to_string())
1034 .collect::<Vec<_>>()
1035 .join(", "),
1036 ),
1037 Self::InputsChangedDuringBuild { before, after } => write!(
1038 formatter,
1039 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
1040 ),
1041 }
1042 }
1043}
1044
1045impl std::error::Error for WasmBuildError {
1046 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1047 match self {
1048 Self::Io { source, .. } | Self::CommandSpawn { source, .. } => Some(source),
1049 _ => None,
1050 }
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::{WasmBuildError, WasmBuildSpec, metadata_arguments, validate_spec};
1057 use std::{ffi::OsString, path::Path};
1058
1059 #[test]
1060 fn metadata_receives_only_resolution_arguments() {
1061 let arguments = [
1062 OsString::from("--profile"),
1063 OsString::from("fast"),
1064 OsString::from("--locked"),
1065 OsString::from("--features=alpha,beta"),
1066 ];
1067 assert_eq!(
1068 metadata_arguments(&arguments),
1069 [
1070 OsString::from("--locked"),
1071 OsString::from("--features=alpha,beta"),
1072 ]
1073 );
1074 }
1075
1076 #[test]
1077 fn build_spec_requires_at_least_one_package() {
1078 let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
1079 assert!(matches!(
1080 validate_spec(&spec),
1081 Err(WasmBuildError::InvalidSpec { .. })
1082 ));
1083 }
1084}