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