Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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/// Complete caller-owned description of one cacheable Cargo Wasm build.
33///
34/// The package dependency closure, workspace manifest, lockfile, Cargo
35/// configuration, Rust toolchain files, target, profile arguments, explicit
36/// child environment, selected inherited environment, and additional watched
37/// inputs all contribute to the build fingerprint.
38#[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/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub enum WasmBuildOutcome {
56    /// Cargo ran and a new successful stamp was published.
57    Built(WasmBuildRecord),
58    /// Existing artifacts and their content-addressed stamp matched exactly.
59    Reused(WasmBuildRecord),
60}
61
62/// Details shared by built and reused Wasm outcomes.
63#[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/// Timings for cache coordination, input resolution, and Cargo execution.
72#[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/// External phase associated with a cacheable Wasm build failure.
81#[non_exhaustive]
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum WasmBuildPhase {
84    /// Resolving Cargo's package graph.
85    CargoMetadata,
86    /// Reading the Cargo executable identity.
87    CargoIdentity,
88    /// Reading the Rust compiler identity.
89    RustcIdentity,
90    /// Compiling the selected Wasm packages.
91    CargoBuild,
92}
93
94/// Structured failure from a cacheable Wasm build.
95#[non_exhaustive]
96#[derive(Debug)]
97pub enum WasmBuildError {
98    /// The caller supplied an incomplete or inconsistent specification.
99    InvalidSpec { message: String },
100    /// A filesystem operation failed.
101    Io {
102        operation: &'static str,
103        path: PathBuf,
104        source: io::Error,
105    },
106    /// An external command could not be launched.
107    CommandSpawn {
108        phase: WasmBuildPhase,
109        program: OsString,
110        source: io::Error,
111    },
112    /// An external command completed unsuccessfully.
113    CommandFailed {
114        phase: WasmBuildPhase,
115        status: ExitStatus,
116        stdout: String,
117        stderr: String,
118    },
119    /// Cargo metadata did not contain the expected package graph.
120    InvalidMetadata { message: String },
121    /// Cargo succeeded without producing every declared Wasm artifact.
122    MissingArtifacts { paths: Vec<PathBuf> },
123    /// Declared inputs changed while Cargo was building.
124    InputsChangedDuringBuild {
125        before: InputDigest,
126        after: InputDigest,
127    },
128}
129
130impl WasmBuildSpec {
131    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
132    ///
133    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
134    /// `release`, or the name supplied to `--profile`.
135    #[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    /// Set Cargo profile and feature arguments used for both the build and fingerprint.
161    #[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    /// Set deterministic child-process environment overrides.
168    #[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    /// Add ambient environment variables whose current values affect the build.
178    ///
179    /// Common Rust and Cargo toolchain variables are included automatically.
180    /// Callers must declare application-specific variables read by build scripts.
181    #[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    /// Add files or directories not discoverable through Cargo's local dependency graph.
188    ///
189    /// Relative paths are resolved from the workspace root. Use this for build
190    /// script configuration, generated schemas, or other externally read inputs.
191    #[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    /// Override the Cargo compilation target.
199    #[must_use]
200    pub fn with_target(mut self, target: &str) -> Self {
201        target.clone_into(&mut self.target);
202        self
203    }
204
205    /// Override the Cargo executable used by metadata, identity, and build commands.
206    #[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    /// Override the Rust compiler executable used to fingerprint the toolchain.
213    #[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    /// Workspace containing the selected Cargo packages.
220    #[must_use]
221    pub fn workspace_root(&self) -> &Path {
222        &self.workspace_root
223    }
224
225    /// Cargo target directory containing artifacts, lock, and stamps.
226    #[must_use]
227    pub fn target_dir(&self) -> &Path {
228        &self.target_dir
229    }
230
231    /// Selected Cargo package names.
232    #[must_use]
233    pub fn packages(&self) -> &[String] {
234        &self.packages
235    }
236}
237
238impl WasmBuildOutcome {
239    /// Read the common build record.
240    #[must_use]
241    pub const fn record(&self) -> &WasmBuildRecord {
242        match self {
243            Self::Built(record) | Self::Reused(record) => record,
244        }
245    }
246
247    /// Report whether exact matching artifacts were reused.
248    #[must_use]
249    pub const fn is_reused(&self) -> bool {
250        matches!(self, Self::Reused(_))
251    }
252}
253
254impl WasmBuildRecord {
255    /// Exact build fingerprint used by the atomic cache stamp.
256    #[must_use]
257    pub const fn fingerprint(&self) -> InputDigest {
258        self.fingerprint
259    }
260
261    /// Exact digest of package sources, lockfile, and configuration inputs.
262    #[must_use]
263    pub const fn input_digest(&self) -> InputDigest {
264        self.input_digest
265    }
266
267    /// Expected Wasm artifacts produced or reused by the build.
268    #[must_use]
269    pub fn artifacts(&self) -> &[PathBuf] {
270        &self.artifacts
271    }
272
273    /// Phase timings captured by the cacheable build operation.
274    #[must_use]
275    pub const fn timings(&self) -> WasmBuildTimings {
276        self.timings
277    }
278}
279
280impl WasmBuildTimings {
281    /// Time spent waiting for the output-directory process lock.
282    #[must_use]
283    pub const fn lock_wait(self) -> Duration {
284        self.lock_wait
285    }
286
287    /// Time spent resolving toolchain identity, Cargo metadata, and exact inputs.
288    #[must_use]
289    pub const fn input_resolution(self) -> Duration {
290        self.input_resolution
291    }
292
293    /// Time spent in `cargo build`, or `None` for a cache hit.
294    #[must_use]
295    pub const fn cargo_build(self) -> Option<Duration> {
296        self.cargo_build
297    }
298
299    /// Total operation duration, including lock coordination.
300    #[must_use]
301    pub const fn total(self) -> Duration {
302        self.total
303    }
304}
305
306/// Build or reuse one exact set of Cargo Wasm artifacts.
307///
308/// The operation takes an exclusive process lock scoped to `target_dir`, then
309/// fingerprints all declared inputs. A cache hit requires both a matching
310/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
311/// builds never publish a successful stamp.
312pub 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}