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