Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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/// Complete caller-owned description of one cacheable Cargo Wasm build.
40///
41/// The package dependency closure, workspace manifest, lockfile, Cargo
42/// configuration, Rust toolchain files, target, profile arguments, explicit
43/// child environment, selected inherited environment, and additional watched
44/// inputs all contribute to the build fingerprint.
45#[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/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub enum WasmBuildOutcome {
64    /// Cargo ran and a new successful stamp was published.
65    Built(WasmBuildRecord),
66    /// Existing artifacts and their content-addressed stamp matched exactly.
67    Reused(WasmBuildRecord),
68}
69
70/// Details shared by built and reused Wasm outcomes.
71#[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/// Timings for cache coordination, input resolution, and Cargo execution.
81#[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/// Detailed timings for exact Wasm build-input resolution.
91#[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
100/// Wasm-cache compatibility name for generic artifact-cache retention limits.
101pub type WasmBuildCachePrunePolicy = ArtifactCachePrunePolicy;
102
103/// Wasm-cache compatibility name for a generic artifact-cache pruning report.
104pub type WasmBuildCachePruneReport = ArtifactCachePruneReport;
105
106/// Wasm-cache compatibility name for generic nonfatal cache maintenance.
107pub type WasmBuildCacheMaintenance = ArtifactCacheMaintenance;
108
109/// External phase associated with a cacheable Wasm build failure.
110#[non_exhaustive]
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub enum WasmBuildPhase {
113    /// Resolving Cargo's package graph.
114    CargoMetadata,
115    /// Reading the Cargo executable identity.
116    CargoIdentity,
117    /// Reading the Rust compiler identity.
118    RustcIdentity,
119    /// Compiling the selected Wasm packages.
120    CargoBuild,
121}
122
123/// Structured failure from a cacheable Wasm build.
124#[non_exhaustive]
125#[derive(Debug)]
126pub enum WasmBuildError {
127    /// The caller supplied an incomplete or inconsistent specification.
128    InvalidSpec { message: String },
129    /// A filesystem operation failed.
130    Io {
131        operation: &'static str,
132        path: PathBuf,
133        source: io::Error,
134    },
135    /// An external command could not be launched.
136    CommandSpawn {
137        phase: WasmBuildPhase,
138        program: OsString,
139        source: io::Error,
140    },
141    /// An external command completed unsuccessfully.
142    CommandFailed {
143        phase: WasmBuildPhase,
144        status: ExitStatus,
145        stdout: String,
146        stderr: String,
147    },
148    /// Cargo metadata did not contain the expected package graph.
149    InvalidMetadata { message: String },
150    /// A discovered Cargo configuration could not be interpreted exactly.
151    InvalidCargoConfiguration { path: PathBuf, message: String },
152    /// Cargo succeeded without producing every declared Wasm artifact.
153    MissingArtifacts { paths: Vec<PathBuf> },
154    /// Declared inputs changed while Cargo was building.
155    InputsChangedDuringBuild {
156        before: InputDigest,
157        after: InputDigest,
158    },
159    /// A build failed and its incomplete fingerprint directory could not be removed.
160    FailedBuildCleanup {
161        build_error: Box<Self>,
162        path: PathBuf,
163        source: io::Error,
164    },
165}
166
167impl WasmBuildSpec {
168    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
169    ///
170    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
171    /// `release`, or the name supplied to `--profile`.
172    #[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    /// Set Cargo profile and feature arguments used for both the build and fingerprint.
199    #[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    /// Set deterministic child-process environment overrides.
206    #[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    /// Add ambient environment variables whose current values affect the build.
216    ///
217    /// Common Rust and Cargo toolchain variables are included automatically.
218    /// Callers must declare application-specific variables read by build scripts.
219    #[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    /// Add files or directories not discoverable through Cargo's local dependency graph.
226    ///
227    /// Relative paths are resolved from the workspace root. Use this for build
228    /// script configuration, generated schemas, or other externally read inputs.
229    #[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    /// Override the Cargo compilation target.
237    #[must_use]
238    pub fn with_target(mut self, target: &str) -> Self {
239        target.clone_into(&mut self.target);
240        self
241    }
242
243    /// Override the Cargo executable used by metadata, identity, and build commands.
244    #[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    /// Override the Rust compiler executable used to fingerprint the toolchain.
251    #[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    /// Apply cache retention under the build operation's existing process lock.
258    ///
259    /// Maintenance is best-effort: its structured result is attached to the
260    /// successful build record and cannot turn ready artifacts into a build
261    /// failure. The active fingerprint is protected from this pruning pass.
262    #[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    /// Workspace containing the selected Cargo packages.
269    #[must_use]
270    pub fn workspace_root(&self) -> &Path {
271        &self.workspace_root
272    }
273
274    /// Cargo target directory containing artifacts, lock, and stamps.
275    #[must_use]
276    pub fn target_dir(&self) -> &Path {
277        &self.target_dir
278    }
279
280    /// Selected Cargo package names.
281    #[must_use]
282    pub fn packages(&self) -> &[String] {
283        &self.packages
284    }
285}
286
287impl WasmBuildOutcome {
288    /// Read the common build record.
289    #[must_use]
290    pub const fn record(&self) -> &WasmBuildRecord {
291        match self {
292            Self::Built(record) | Self::Reused(record) => record,
293        }
294    }
295
296    /// Report whether exact matching artifacts were reused.
297    #[must_use]
298    pub const fn is_reused(&self) -> bool {
299        matches!(self, Self::Reused(_))
300    }
301}
302
303impl WasmBuildRecord {
304    /// Exact build fingerprint used by the atomic cache stamp.
305    #[must_use]
306    pub const fn fingerprint(&self) -> InputDigest {
307        self.fingerprint
308    }
309
310    /// Exact digest of package sources, lockfile, and configuration inputs.
311    #[must_use]
312    pub const fn input_digest(&self) -> InputDigest {
313        self.input_digest
314    }
315
316    /// Expected Wasm artifacts produced or reused by the build.
317    #[must_use]
318    pub fn artifacts(&self) -> &[PathBuf] {
319        &self.artifacts
320    }
321
322    /// Phase timings captured by the cacheable build operation.
323    #[must_use]
324    pub const fn timings(&self) -> WasmBuildTimings {
325        self.timings
326    }
327
328    /// Cache maintenance attempted under the build lock, when configured.
329    #[must_use]
330    pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
331        self.maintenance.as_ref()
332    }
333}
334
335impl WasmBuildTimings {
336    /// Time spent waiting for the output-directory process lock.
337    #[must_use]
338    pub const fn lock_wait(self) -> Duration {
339        self.lock_wait
340    }
341
342    /// Time spent resolving toolchain identity, Cargo metadata, and exact inputs.
343    #[must_use]
344    pub const fn input_resolution(self) -> Duration {
345        self.input_resolution.total
346    }
347
348    /// Detailed tool, metadata, discovery, and hashing timings.
349    #[must_use]
350    pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
351        self.input_resolution
352    }
353
354    /// Time spent in `cargo build`, or `None` for a cache hit.
355    #[must_use]
356    pub const fn cargo_build(self) -> Option<Duration> {
357        self.cargo_build
358    }
359
360    /// Time spent on configured best-effort cache maintenance.
361    #[must_use]
362    pub const fn cache_maintenance(self) -> Option<Duration> {
363        self.cache_maintenance
364    }
365
366    /// Total operation duration, including lock coordination.
367    #[must_use]
368    pub const fn total(self) -> Duration {
369        self.total
370    }
371}
372
373impl WasmInputResolutionTimings {
374    /// Time spent reading Cargo and rustc identities.
375    #[must_use]
376    pub const fn tool_identity(self) -> Duration {
377        self.tool_identity
378    }
379
380    /// Time spent running and decoding `cargo metadata`.
381    #[must_use]
382    pub const fn cargo_metadata(self) -> Duration {
383        self.cargo_metadata
384    }
385
386    /// Time spent resolving packages, configuration, and watched paths.
387    #[must_use]
388    pub const fn input_discovery(self) -> Duration {
389        self.input_discovery
390    }
391
392    /// Time spent reading and hashing exact input contents.
393    #[must_use]
394    pub const fn content_hashing(self) -> Duration {
395        self.content_hashing
396    }
397
398    /// Complete input-resolution duration.
399    #[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
413/// Build or reuse one exact set of Cargo Wasm artifacts.
414///
415/// The operation takes an exclusive process lock scoped to `target_dir`, then
416/// fingerprints all declared inputs. A cache hit requires both a matching
417/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
418/// builds never publish a successful stamp.
419pub 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
517/// Prune fingerprint-specific Cargo target directories under `target_dir`.
518///
519/// Pruning uses the same exclusive process lock as builds. Entries older than
520/// the configured age are removed first, then least-recently-used entries are
521/// removed until the configured logical byte limit is met. Only direct child
522/// directories with SHA-256 fingerprint names are eligible; caller-facing
523/// artifacts and unrelated target contents are never removed.
524pub 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}