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::{Child, Command, ExitStatus, Output, Stdio},
9    sync::mpsc::{self, RecvTimeoutError},
10    thread,
11    time::{Duration, Instant, SystemTime},
12};
13use toml::Value as TomlValue;
14
15use super::{
16    cache_fs::{
17        ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
18        cache_entry_last_used, cache_maintenance_due, directory_logical_size,
19        ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
20        perform_scheduled_cache_maintenance, prune_direct_child_directories,
21        record_cache_entry_use as record_entry_use, record_cache_maintenance,
22        remove_path_if_present,
23    },
24    digest::{
25        InputDigest, InputHasher, copy_file_atomic, digest_bytes, digest_file,
26        digest_labeled_paths, os_bytes, write_atomic,
27    },
28    wasm::wasm_path,
29};
30
31const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
32const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
33const AUTOMATIC_ENVIRONMENT: &[&str] = &[
34    "CARGO_BUILD_RUSTC",
35    "CARGO_ENCODED_RUSTFLAGS",
36    "RUSTC",
37    "RUSTC_WRAPPER",
38    "RUSTC_WORKSPACE_WRAPPER",
39    "RUSTFLAGS",
40    "RUSTUP_TOOLCHAIN",
41];
42
43/// Complete caller-owned description of one cacheable Cargo Wasm build.
44///
45/// The package dependency closure, workspace manifest, lockfile, Cargo
46/// configuration, Rust toolchain files, target, profile arguments, explicit
47/// child environment, selected inherited environment, and additional watched
48/// inputs all contribute to the build fingerprint.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct WasmBuildSpec {
51    workspace_root: PathBuf,
52    target_dir: PathBuf,
53    packages: Vec<String>,
54    profile_target_dir: String,
55    cargo_profile_args: Vec<OsString>,
56    extra_env: BTreeMap<OsString, OsString>,
57    inherited_env: BTreeSet<OsString>,
58    additional_inputs: Vec<PathBuf>,
59    target: String,
60    cargo_program: OsString,
61    rustc_program: OsString,
62    cache_mode: WasmBuildCacheMode,
63    prune_policy: Option<WasmBuildCachePrunePolicy>,
64    prune_interval: Option<Duration>,
65    shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69struct SharedIncrementalTargetMaintenanceConfig {
70    policy: SharedIncrementalTargetPrunePolicy,
71    minimum_interval: Duration,
72}
73
74/// Cargo-target ownership mode for one exact cached Wasm build.
75#[non_exhaustive]
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub enum WasmBuildCacheMode {
78    /// Build each exact fingerprint in its own content-addressed Cargo target.
79    Isolated,
80    /// Build misses in caller-owned shared Cargo incremental state, then cache final Wasm files.
81    SharedIncremental {
82        /// Mutable Cargo target directory shared across source fingerprints.
83        target_dir: PathBuf,
84    },
85}
86
87/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub enum WasmBuildOutcome {
90    /// Cargo ran and a new successful stamp was published.
91    Built(WasmBuildRecord),
92    /// Existing artifacts and their content-addressed stamp matched exactly.
93    Reused(WasmBuildRecord),
94}
95
96/// Details shared by built and reused Wasm outcomes.
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct WasmBuildRecord {
99    fingerprint: InputDigest,
100    input_digest: InputDigest,
101    artifacts: Vec<PathBuf>,
102    timings: WasmBuildTimings,
103    maintenance: Option<WasmBuildCacheMaintenance>,
104    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
105}
106
107/// Timings for cache coordination, input resolution, and Cargo execution.
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub struct WasmBuildTimings {
110    lock_wait: Duration,
111    shared_incremental_lock_wait: Option<Duration>,
112    input_resolution: WasmInputResolutionTimings,
113    cargo_build: Option<Duration>,
114    cache_maintenance: Option<Duration>,
115    total: Duration,
116}
117
118/// Detailed timings for exact Wasm build-input resolution.
119#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
120pub struct WasmInputResolutionTimings {
121    tool_identity: Duration,
122    cargo_metadata: Duration,
123    input_discovery: Duration,
124    content_hashing: Duration,
125    total: Duration,
126}
127
128/// One exact local Cargo source or configuration input under a stable logical label.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct CargoBuildInput {
131    label: PathBuf,
132    path: PathBuf,
133}
134
135/// Resolved exact inputs and identity for one [`WasmBuildSpec`].
136///
137/// The snapshot can be resolved again after an external operation to detect
138/// source, configuration, toolchain, argument, or environment changes.
139#[derive(Clone, Debug, Eq, PartialEq)]
140pub struct ResolvedCargoBuildInputs {
141    fingerprint: InputDigest,
142    input_digest: InputDigest,
143    inputs: Vec<CargoBuildInput>,
144    exclusions: Vec<PathBuf>,
145    timings: WasmInputResolutionTimings,
146}
147
148/// Lock-coordinated disk-usage observation for a caller-owned shared Cargo target.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct SharedIncrementalTargetInspection {
151    target_dir: PathBuf,
152    logical_size_bytes: u64,
153    last_used: SystemTime,
154    lock_wait: Duration,
155}
156
157/// Whole-target retention limits for caller-owned shared Cargo state.
158///
159/// Unlike immutable fingerprint entries, a shared Cargo target has no safe
160/// per-entry LRU boundary. When either configured limit is exceeded,
161/// maintenance clears every other target child while preserving
162/// `ic-testkit`'s coordination metadata and the target root. Callers must not
163/// colocate unrelated data that needs to survive a clear.
164#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
165pub struct SharedIncrementalTargetPrunePolicy {
166    max_age: Option<Duration>,
167    max_size_bytes: Option<u64>,
168}
169
170/// Result of explicit shared Cargo target maintenance.
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct SharedIncrementalTargetMaintenance {
173    target_dir: PathBuf,
174    logical_size_bytes_before: u64,
175    logical_size_bytes_after: u64,
176    last_used_before: SystemTime,
177    cleared: bool,
178    lock_wait: Duration,
179    maintenance: Duration,
180}
181
182/// Result of interval-limited shared Cargo target maintenance.
183#[non_exhaustive]
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub enum SharedIncrementalTargetMaintenanceOutcome {
186    /// The configured shared target does not exist, so nothing was created or inspected.
187    Missing {
188        /// Configured target path. A missing path cannot necessarily be canonicalized.
189        target_dir: PathBuf,
190    },
191    /// A successful matching maintenance pass is still inside the requested interval.
192    Skipped {
193        /// Canonical shared Cargo target directory.
194        target_dir: PathBuf,
195        /// Time spent waiting for another process using the shared target.
196        lock_wait: Duration,
197        /// Time spent checking the small cross-process schedule marker.
198        schedule_check: Duration,
199    },
200    /// Retention was evaluated under the shared-target lock.
201    Performed {
202        /// Completed retention report.
203        maintenance: SharedIncrementalTargetMaintenance,
204        /// Time spent checking the small cross-process schedule marker.
205        schedule_check: Duration,
206    },
207}
208
209/// Observation settings for one cacheable Wasm build.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub struct WasmBuildProgressConfig {
212    heartbeat_interval: Option<Duration>,
213    emit_cargo_output: bool,
214}
215
216/// Raw child-process stream attached to a Cargo progress event.
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218pub enum WasmBuildOutputStream {
219    /// Cargo standard output.
220    Stdout,
221    /// Cargo standard error.
222    Stderr,
223}
224
225/// Final cache state reported by a successful observed build.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub enum WasmBuildProgressOutcome {
228    /// Cargo ran and exact artifacts were published.
229    Built,
230    /// Exact artifacts were reused without Cargo.
231    Reused,
232}
233
234/// Structured progress emitted by an observed cacheable Wasm build.
235#[non_exhaustive]
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub enum WasmBuildProgressEvent {
238    /// One build/cache acquisition started.
239    Started,
240    /// One exact Cargo input-resolution pass completed.
241    InputsResolved {
242        /// Complete exact build fingerprint.
243        fingerprint: InputDigest,
244        /// Source/configuration-only digest.
245        input_digest: InputDigest,
246        /// Time spent on this resolution pass.
247        elapsed: Duration,
248    },
249    /// No reusable exact entry existed for this fingerprint.
250    CacheMiss {
251        /// Missing exact fingerprint.
252        fingerprint: InputDigest,
253    },
254    /// Exact artifacts were found and materialized when necessary.
255    CacheHit {
256        /// Reused exact fingerprint.
257        fingerprint: InputDigest,
258    },
259    /// The build is about to wait for a caller-owned shared Cargo target.
260    SharedTargetLockStarted {
261        /// Shared target selected by the build specification.
262        target_dir: PathBuf,
263    },
264    /// Exclusive shared-target ownership was acquired.
265    SharedTargetLockAcquired {
266        /// Canonical shared target directory.
267        target_dir: PathBuf,
268        /// Time spent waiting for another process.
269        wait: Duration,
270    },
271    /// Scheduled shared-target retention is about to be evaluated under lock.
272    SharedTargetMaintenanceStarted {
273        /// Canonical shared target selected by the build specification.
274        target_dir: PathBuf,
275    },
276    /// Scheduled shared-target retention completed or was skipped.
277    SharedTargetMaintenanceFinished {
278        /// Structured retention result attached to the successful acquisition.
279        outcome: SharedIncrementalTargetMaintenanceOutcome,
280    },
281    /// Cargo compilation started.
282    CargoStarted {
283        /// Cargo target receiving compilation state.
284        target_dir: PathBuf,
285    },
286    /// One raw Cargo output chunk was read without lossy UTF-8 conversion.
287    CargoOutput {
288        /// Child-process stream that produced the bytes.
289        stream: WasmBuildOutputStream,
290        /// Raw output bytes in per-stream read order.
291        bytes: Vec<u8>,
292    },
293    /// Cargo remained active without another emitted output chunk.
294    CargoHeartbeat {
295        /// Time elapsed since Cargo started.
296        elapsed: Duration,
297    },
298    /// Cargo exited and all captured output was drained.
299    CargoFinished {
300        /// Whether Cargo reported success.
301        success: bool,
302        /// Portable exit code when the platform exposes one.
303        code: Option<i32>,
304        /// Complete Cargo execution duration.
305        elapsed: Duration,
306    },
307    /// The complete cacheable build operation succeeded.
308    Finished {
309        /// Whether Cargo ran or an exact entry was reused.
310        outcome: WasmBuildProgressOutcome,
311        /// Exact fingerprint selected by the operation.
312        fingerprint: InputDigest,
313        /// Total operation duration.
314        elapsed: Duration,
315    },
316}
317
318impl Default for WasmBuildProgressConfig {
319    fn default() -> Self {
320        Self {
321            heartbeat_interval: Some(Duration::from_secs(10)),
322            emit_cargo_output: true,
323        }
324    }
325}
326
327impl WasmBuildProgressConfig {
328    /// Observe Cargo output and emit a heartbeat at least every ten quiet seconds.
329    #[must_use]
330    pub fn new() -> Self {
331        Self::default()
332    }
333
334    /// Select the maximum quiet interval between Cargo heartbeat events.
335    ///
336    /// A zero interval is rejected before any build work begins.
337    #[must_use]
338    pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
339        self.heartbeat_interval = Some(interval);
340        self
341    }
342
343    /// Disable time-based heartbeats while retaining phase and output events.
344    #[must_use]
345    pub const fn without_heartbeats(mut self) -> Self {
346        self.heartbeat_interval = None;
347        self
348    }
349
350    /// Select whether raw Cargo stdout/stderr chunks are forwarded.
351    ///
352    /// Output is always captured for structured build failures.
353    #[must_use]
354    pub const fn with_cargo_output(mut self, emit: bool) -> Self {
355        self.emit_cargo_output = emit;
356        self
357    }
358
359    /// Configured heartbeat interval, or `None` when disabled.
360    #[must_use]
361    pub const fn heartbeat_interval(self) -> Option<Duration> {
362        self.heartbeat_interval
363    }
364
365    /// Whether raw Cargo output chunks are emitted to the observer.
366    #[must_use]
367    pub const fn emits_cargo_output(self) -> bool {
368        self.emit_cargo_output
369    }
370}
371
372struct ProgressReporter<'a> {
373    config: WasmBuildProgressConfig,
374    observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
375}
376
377impl ProgressReporter<'_> {
378    fn silent() -> Self {
379        Self {
380            config: WasmBuildProgressConfig {
381                heartbeat_interval: None,
382                emit_cargo_output: false,
383            },
384            observer: None,
385        }
386    }
387
388    fn emit(&mut self, event: WasmBuildProgressEvent) {
389        if let Some(observer) = &mut self.observer {
390            observer(event);
391        }
392    }
393
394    const fn is_observed(&self) -> bool {
395        self.observer.is_some()
396    }
397}
398
399/// Wasm-cache compatibility name for generic artifact-cache retention limits.
400pub type WasmBuildCachePrunePolicy = ArtifactCachePrunePolicy;
401
402/// Wasm-cache compatibility name for a generic artifact-cache pruning report.
403pub type WasmBuildCachePruneReport = ArtifactCachePruneReport;
404
405/// Wasm-cache compatibility name for generic nonfatal cache maintenance.
406pub type WasmBuildCacheMaintenance = ArtifactCacheMaintenance;
407
408/// External phase associated with a cacheable Wasm build failure.
409#[non_exhaustive]
410#[derive(Clone, Copy, Debug, Eq, PartialEq)]
411pub enum WasmBuildPhase {
412    /// Resolving Cargo's package graph.
413    CargoMetadata,
414    /// Reading the Cargo executable identity.
415    CargoIdentity,
416    /// Reading the Rust compiler identity.
417    RustcIdentity,
418    /// Compiling the selected Wasm packages.
419    CargoBuild,
420}
421
422/// Structured failure from a cacheable Wasm build.
423#[non_exhaustive]
424#[derive(Debug)]
425pub enum WasmBuildError {
426    /// The caller supplied an incomplete or inconsistent specification.
427    InvalidSpec { message: String },
428    /// A filesystem operation failed.
429    Io {
430        operation: &'static str,
431        path: PathBuf,
432        source: io::Error,
433    },
434    /// An external command could not be launched.
435    CommandSpawn {
436        phase: WasmBuildPhase,
437        program: OsString,
438        source: io::Error,
439    },
440    /// An external command completed unsuccessfully.
441    CommandFailed {
442        phase: WasmBuildPhase,
443        status: ExitStatus,
444        stdout: String,
445        stderr: String,
446    },
447    /// Cargo metadata did not contain the expected package graph.
448    InvalidMetadata { message: String },
449    /// A discovered Cargo configuration could not be interpreted exactly.
450    InvalidCargoConfiguration { path: PathBuf, message: String },
451    /// Cargo succeeded without producing every declared Wasm artifact.
452    MissingArtifacts { paths: Vec<PathBuf> },
453    /// Declared inputs changed while Cargo was building.
454    InputsChangedDuringBuild {
455        before: InputDigest,
456        after: InputDigest,
457    },
458    /// A build failed and its incomplete fingerprint directory could not be removed.
459    FailedBuildCleanup {
460        build_error: Box<Self>,
461        path: PathBuf,
462        source: io::Error,
463    },
464}
465
466impl WasmBuildSpec {
467    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
468    ///
469    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
470    /// `release`, or the name supplied to `--profile`.
471    #[must_use]
472    pub fn new(
473        workspace_root: &Path,
474        target_dir: &Path,
475        packages: &[&str],
476        profile_target_dir: &str,
477    ) -> Self {
478        Self {
479            workspace_root: workspace_root.to_owned(),
480            target_dir: target_dir.to_owned(),
481            packages: packages
482                .iter()
483                .map(|package| (*package).to_owned())
484                .collect(),
485            profile_target_dir: profile_target_dir.to_owned(),
486            cargo_profile_args: Vec::new(),
487            extra_env: BTreeMap::new(),
488            inherited_env: BTreeSet::new(),
489            additional_inputs: Vec::new(),
490            target: DEFAULT_TARGET.to_owned(),
491            cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
492            rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
493            cache_mode: WasmBuildCacheMode::Isolated,
494            prune_policy: None,
495            prune_interval: None,
496            shared_incremental_maintenance_config: None,
497        }
498    }
499
500    /// Set Cargo profile and feature arguments used for both the build and fingerprint.
501    #[must_use]
502    pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
503        self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
504        self
505    }
506
507    /// Set OS-native Cargo profile and feature arguments used for the build and fingerprint.
508    #[must_use]
509    pub fn with_cargo_profile_args_os<I, S>(mut self, arguments: I) -> Self
510    where
511        I: IntoIterator<Item = S>,
512        S: Into<OsString>,
513    {
514        self.cargo_profile_args = arguments.into_iter().map(Into::into).collect();
515        self
516    }
517
518    /// Set deterministic child-process environment overrides.
519    #[must_use]
520    pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
521        self.extra_env = environment
522            .iter()
523            .map(|(key, value)| (OsString::from(key), OsString::from(value)))
524            .collect();
525        self
526    }
527
528    /// Set OS-native deterministic child-process environment overrides.
529    #[must_use]
530    pub fn with_extra_env_os<I, K, V>(mut self, environment: I) -> Self
531    where
532        I: IntoIterator<Item = (K, V)>,
533        K: Into<OsString>,
534        V: Into<OsString>,
535    {
536        self.extra_env = environment
537            .into_iter()
538            .map(|(key, value)| (key.into(), value.into()))
539            .collect();
540        self
541    }
542
543    /// Add ambient environment variables whose current values affect the build.
544    ///
545    /// Common Rust and Cargo toolchain variables are included automatically.
546    /// Callers must declare application-specific variables read by build scripts.
547    #[must_use]
548    pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
549        self.inherited_env.extend(names.iter().map(OsString::from));
550        self
551    }
552
553    /// Add OS-native ambient environment names whose current values affect the build.
554    #[must_use]
555    pub fn with_inherited_env_os<I, S>(mut self, names: I) -> Self
556    where
557        I: IntoIterator<Item = S>,
558        S: Into<OsString>,
559    {
560        self.inherited_env.extend(names.into_iter().map(Into::into));
561        self
562    }
563
564    /// Add files or directories not discoverable through Cargo's local dependency graph.
565    ///
566    /// Relative paths are resolved from the workspace root. Use this for build
567    /// script configuration, generated schemas, or other externally read inputs.
568    #[must_use]
569    pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
570        self.additional_inputs
571            .extend(paths.iter().map(PathBuf::from));
572        self
573    }
574
575    /// Add path-native files or directories outside Cargo's local dependency graph.
576    #[must_use]
577    pub fn with_additional_input_paths<I, P>(mut self, paths: I) -> Self
578    where
579        I: IntoIterator<Item = P>,
580        P: Into<PathBuf>,
581    {
582        self.additional_inputs
583            .extend(paths.into_iter().map(Into::into));
584        self
585    }
586
587    /// Override the Cargo compilation target.
588    #[must_use]
589    pub fn with_target(mut self, target: &str) -> Self {
590        target.clone_into(&mut self.target);
591        self
592    }
593
594    /// Override the Cargo executable used by metadata, identity, and build commands.
595    #[must_use]
596    pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
597        self.cargo_program = program.into();
598        self
599    }
600
601    /// Override the Rust compiler executable used to fingerprint the toolchain.
602    #[must_use]
603    pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
604        self.rustc_program = program.into();
605        self
606    }
607
608    /// Build cache misses in one caller-owned shared Cargo incremental target.
609    ///
610    /// Exact final Wasm artifacts still live in the content-addressed cache.
611    /// The shared target is coordinated across processes but is never pruned
612    /// or removed by `ic-testkit` after a failed build.
613    #[must_use]
614    pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
615        self.cache_mode = WasmBuildCacheMode::SharedIncremental {
616            target_dir: target_dir.into(),
617        };
618        self
619    }
620
621    /// Schedule caller-owned shared-target retention as part of acquisition.
622    ///
623    /// This option requires [`Self::with_shared_incremental_target`]. Every
624    /// acquisition coordinates through that target, including an exact hit,
625    /// so a missing target can be created and receive its first schedule
626    /// marker immediately. Matching recent passes only check the marker; due
627    /// passes reuse the acquisition's exact Cargo input resolution before
628    /// evaluating retention. The structured result is attached to the build
629    /// record and emitted through observed progress. Maintenance failures fail
630    /// the acquisition and do not record a successful schedule marker.
631    #[must_use]
632    pub const fn with_shared_incremental_target_maintenance_at_most_every(
633        mut self,
634        policy: SharedIncrementalTargetPrunePolicy,
635        minimum_interval: Duration,
636    ) -> Self {
637        self.shared_incremental_maintenance_config =
638            Some(SharedIncrementalTargetMaintenanceConfig {
639                policy,
640                minimum_interval,
641            });
642        self
643    }
644
645    /// Apply cache retention under the build operation's existing process lock.
646    ///
647    /// Maintenance is best-effort: its structured result is attached to the
648    /// successful build record and cannot turn ready artifacts into a build
649    /// failure. The active fingerprint is protected from this pruning pass.
650    #[must_use]
651    pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
652        self.prune_policy = Some(policy);
653        self.prune_interval = None;
654        self
655    }
656
657    /// Apply exact-entry retention at most once per `minimum_interval`.
658    ///
659    /// The active fingerprint remains protected. A zero interval is equivalent
660    /// to [`Self::with_prune_policy`]. The interval covers attempted
661    /// maintenance, including a nonfatal failed attempt. This schedule never
662    /// owns or scans a caller-owned shared incremental Cargo target.
663    #[must_use]
664    pub const fn with_prune_policy_at_most_every(
665        mut self,
666        policy: WasmBuildCachePrunePolicy,
667        minimum_interval: Duration,
668    ) -> Self {
669        self.prune_policy = Some(policy);
670        self.prune_interval = Some(minimum_interval);
671        self
672    }
673
674    /// Workspace containing the selected Cargo packages.
675    #[must_use]
676    pub fn workspace_root(&self) -> &Path {
677        &self.workspace_root
678    }
679
680    /// Cargo target directory containing artifacts, lock, and stamps.
681    #[must_use]
682    pub fn target_dir(&self) -> &Path {
683        &self.target_dir
684    }
685
686    /// Selected Cargo package names.
687    #[must_use]
688    pub fn packages(&self) -> &[String] {
689        &self.packages
690    }
691
692    /// Cargo-target ownership mode used for cache misses.
693    #[must_use]
694    pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
695        &self.cache_mode
696    }
697}
698
699impl WasmBuildOutcome {
700    /// Read the common build record.
701    #[must_use]
702    pub const fn record(&self) -> &WasmBuildRecord {
703        match self {
704            Self::Built(record) | Self::Reused(record) => record,
705        }
706    }
707
708    /// Report whether exact matching artifacts were reused.
709    #[must_use]
710    pub const fn is_reused(&self) -> bool {
711        matches!(self, Self::Reused(_))
712    }
713}
714
715impl WasmBuildRecord {
716    /// Exact build fingerprint used by the atomic cache stamp.
717    #[must_use]
718    pub const fn fingerprint(&self) -> InputDigest {
719        self.fingerprint
720    }
721
722    /// Exact digest of package sources, lockfile, and configuration inputs.
723    #[must_use]
724    pub const fn input_digest(&self) -> InputDigest {
725        self.input_digest
726    }
727
728    /// Expected Wasm artifacts produced or reused by the build.
729    #[must_use]
730    pub fn artifacts(&self) -> &[PathBuf] {
731        &self.artifacts
732    }
733
734    /// Phase timings captured by the cacheable build operation.
735    #[must_use]
736    pub const fn timings(&self) -> WasmBuildTimings {
737        self.timings
738    }
739
740    /// Cache maintenance attempted under the build lock, when configured.
741    #[must_use]
742    pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
743        self.maintenance.as_ref()
744    }
745
746    /// Scheduled caller-owned shared-target maintenance, when configured.
747    #[must_use]
748    pub const fn shared_incremental_maintenance(
749        &self,
750    ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
751        self.shared_incremental_maintenance.as_ref()
752    }
753}
754
755impl WasmBuildTimings {
756    /// Time spent waiting for the output-directory process lock.
757    #[must_use]
758    pub const fn lock_wait(self) -> Duration {
759        self.lock_wait
760    }
761
762    /// Time spent waiting for a shared incremental-target lock, when configured.
763    #[must_use]
764    pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
765        self.shared_incremental_lock_wait
766    }
767
768    /// Time spent resolving toolchain identity, Cargo metadata, and exact inputs.
769    #[must_use]
770    pub const fn input_resolution(self) -> Duration {
771        self.input_resolution.total
772    }
773
774    /// Detailed tool, metadata, discovery, and hashing timings.
775    #[must_use]
776    pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
777        self.input_resolution
778    }
779
780    /// Time spent in `cargo build`, or `None` for a cache hit.
781    #[must_use]
782    pub const fn cargo_build(self) -> Option<Duration> {
783        self.cargo_build
784    }
785
786    /// Time spent on configured best-effort cache maintenance.
787    #[must_use]
788    pub const fn cache_maintenance(self) -> Option<Duration> {
789        self.cache_maintenance
790    }
791
792    /// Total operation duration, including lock coordination.
793    #[must_use]
794    pub const fn total(self) -> Duration {
795        self.total
796    }
797}
798
799impl WasmInputResolutionTimings {
800    /// Time spent reading Cargo and rustc identities.
801    #[must_use]
802    pub const fn tool_identity(self) -> Duration {
803        self.tool_identity
804    }
805
806    /// Time spent running and decoding `cargo metadata`.
807    #[must_use]
808    pub const fn cargo_metadata(self) -> Duration {
809        self.cargo_metadata
810    }
811
812    /// Time spent resolving packages, configuration, and watched paths.
813    #[must_use]
814    pub const fn input_discovery(self) -> Duration {
815        self.input_discovery
816    }
817
818    /// Time spent reading and hashing exact input contents.
819    #[must_use]
820    pub const fn content_hashing(self) -> Duration {
821        self.content_hashing
822    }
823
824    /// Complete input-resolution duration.
825    #[must_use]
826    pub const fn total(self) -> Duration {
827        self.total
828    }
829
830    const fn include(&mut self, other: Self) {
831        self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
832        self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
833        self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
834        self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
835        self.total = self.total.saturating_add(other.total);
836    }
837}
838
839impl CargoBuildInput {
840    /// Stable checkout-independent label used while hashing this input.
841    #[must_use]
842    pub fn label(&self) -> &Path {
843        &self.label
844    }
845
846    /// Resolved file or directory read by the Cargo build.
847    #[must_use]
848    pub fn path(&self) -> &Path {
849        &self.path
850    }
851}
852
853impl ResolvedCargoBuildInputs {
854    /// Exact build fingerprint including Cargo inputs, tools, arguments, and environment.
855    #[must_use]
856    pub const fn fingerprint(&self) -> InputDigest {
857        self.fingerprint
858    }
859
860    /// Exact digest of local source and configuration contents.
861    #[must_use]
862    pub const fn input_digest(&self) -> InputDigest {
863        self.input_digest
864    }
865
866    /// Stable logical labels and resolved local input paths.
867    #[must_use]
868    pub fn inputs(&self) -> &[CargoBuildInput] {
869        &self.inputs
870    }
871
872    /// Generated-state roots excluded while recursively hashing local inputs.
873    ///
874    /// These exclusions are derived by `ic-testkit`; callers cannot add
875    /// arbitrary exclusions through this snapshot.
876    #[must_use]
877    pub fn exclusions(&self) -> &[PathBuf] {
878        &self.exclusions
879    }
880
881    /// Timings for tool identity, metadata, discovery, and content hashing.
882    #[must_use]
883    pub const fn timings(&self) -> WasmInputResolutionTimings {
884        self.timings
885    }
886
887    /// Resolve `spec` again and report whether its exact identity is unchanged.
888    pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
889        resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
890    }
891
892    /// Rehash the already discovered Cargo source/configuration set.
893    ///
894    /// This is cheaper than rerunning Cargo metadata and is intended for
895    /// before/after guards around external artifact transformations. Resolve a
896    /// new snapshot to observe tool, argument, environment, or dependency-graph
897    /// identity changes between separate acquisitions.
898    pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
899        self.current_input_digest()
900            .map(|current| current == self.input_digest)
901    }
902
903    pub(super) fn current_input_digest(&self) -> Result<InputDigest, WasmBuildError> {
904        let inputs = self
905            .inputs
906            .iter()
907            .map(|input| (input.label.clone(), input.path.clone()))
908            .collect::<Vec<_>>();
909        digest_labeled_paths("wasm-source-inputs-v1", &inputs, &self.exclusions).map_err(|source| {
910            WasmBuildError::Io {
911                operation: "rehash resolved Cargo build inputs",
912                path: self
913                    .inputs
914                    .first()
915                    .map_or_else(PathBuf::new, |input| input.path.clone()),
916                source,
917            }
918        })
919    }
920}
921
922impl SharedIncrementalTargetInspection {
923    /// Canonical shared Cargo target directory that was inspected.
924    #[must_use]
925    pub fn target_dir(&self) -> &Path {
926        &self.target_dir
927    }
928
929    /// Logical bytes currently occupied by the complete shared target.
930    #[must_use]
931    pub const fn logical_size_bytes(&self) -> u64 {
932        self.logical_size_bytes
933    }
934
935    /// Most recent build use recorded by `ic-testkit`, or the directory mtime for older targets.
936    #[must_use]
937    pub const fn last_used(&self) -> SystemTime {
938        self.last_used
939    }
940
941    /// Time spent waiting for another process using the shared target.
942    #[must_use]
943    pub const fn lock_wait(&self) -> Duration {
944        self.lock_wait
945    }
946}
947
948impl SharedIncrementalTargetPrunePolicy {
949    /// Create an explicit policy without a clearing threshold.
950    #[must_use]
951    pub const fn new() -> Self {
952        Self {
953            max_age: None,
954            max_size_bytes: None,
955        }
956    }
957
958    /// Clear shared Cargo state when its recorded use is older than `max_age`.
959    #[must_use]
960    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
961        self.max_age = Some(max_age);
962        self
963    }
964
965    /// Clear shared Cargo state when its logical size exceeds `bytes`.
966    #[must_use]
967    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
968        self.max_size_bytes = Some(bytes);
969        self
970    }
971
972    /// Configured maximum time since recorded build use.
973    #[must_use]
974    pub const fn max_age(self) -> Option<Duration> {
975        self.max_age
976    }
977
978    /// Configured maximum logical target size.
979    #[must_use]
980    pub const fn max_size_bytes(self) -> Option<u64> {
981        self.max_size_bytes
982    }
983
984    fn maintenance_identity(self) -> String {
985        format!(
986            "age={:?};size={:?}",
987            self.max_age.map(|duration| duration.as_nanos()),
988            self.max_size_bytes
989        )
990    }
991}
992
993impl SharedIncrementalTargetMaintenance {
994    /// Canonical shared Cargo target directory maintained under lock.
995    #[must_use]
996    pub fn target_dir(&self) -> &Path {
997        &self.target_dir
998    }
999
1000    /// Logical bytes observed before applying the policy.
1001    #[must_use]
1002    pub const fn logical_size_bytes_before(&self) -> u64 {
1003        self.logical_size_bytes_before
1004    }
1005
1006    /// Logical bytes retained after applying the policy.
1007    #[must_use]
1008    pub const fn logical_size_bytes_after(&self) -> u64 {
1009        self.logical_size_bytes_after
1010    }
1011
1012    /// Most recent build use observed before applying the policy.
1013    #[must_use]
1014    pub const fn last_used_before(&self) -> SystemTime {
1015        self.last_used_before
1016    }
1017
1018    /// Whether a configured limit caused the mutable target contents to be cleared.
1019    #[must_use]
1020    pub const fn was_cleared(&self) -> bool {
1021        self.cleared
1022    }
1023
1024    /// Time spent waiting for another process using the shared target.
1025    #[must_use]
1026    pub const fn lock_wait(&self) -> Duration {
1027        self.lock_wait
1028    }
1029
1030    /// Time spent measuring and, when required, clearing the target.
1031    #[must_use]
1032    pub const fn maintenance(&self) -> Duration {
1033        self.maintenance
1034    }
1035}
1036
1037impl std::fmt::Display for SharedIncrementalTargetMaintenance {
1038    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1039        write!(
1040            formatter,
1041            "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
1042            self.target_dir.display(),
1043            if self.cleared { "cleared" } else { "retained" },
1044            self.logical_size_bytes_before,
1045            self.logical_size_bytes_after,
1046            self.lock_wait,
1047            self.maintenance,
1048        )
1049    }
1050}
1051
1052impl SharedIncrementalTargetMaintenanceOutcome {
1053    /// Configured or canonical target associated with this result.
1054    #[must_use]
1055    pub fn target_dir(&self) -> &Path {
1056        match self {
1057            Self::Missing { target_dir } | Self::Skipped { target_dir, .. } => target_dir,
1058            Self::Performed { maintenance, .. } => maintenance.target_dir(),
1059        }
1060    }
1061
1062    /// Completed maintenance report, when retention was evaluated.
1063    #[must_use]
1064    pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
1065        match self {
1066            Self::Performed { maintenance, .. } => Some(maintenance),
1067            Self::Missing { .. } | Self::Skipped { .. } => None,
1068        }
1069    }
1070
1071    /// Whether retention was evaluated during this call.
1072    #[must_use]
1073    pub const fn was_performed(&self) -> bool {
1074        matches!(self, Self::Performed { .. })
1075    }
1076
1077    /// Time spent waiting for another process, when the target existed.
1078    #[must_use]
1079    pub const fn lock_wait(&self) -> Option<Duration> {
1080        match self {
1081            Self::Missing { .. } => None,
1082            Self::Skipped { lock_wait, .. } => Some(*lock_wait),
1083            Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
1084        }
1085    }
1086
1087    /// Time spent checking the schedule marker, when the target existed.
1088    #[must_use]
1089    pub const fn schedule_check(&self) -> Option<Duration> {
1090        match self {
1091            Self::Missing { .. } => None,
1092            Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
1093                Some(*schedule_check)
1094            }
1095        }
1096    }
1097}
1098
1099impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
1100    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1101        match self {
1102            Self::Missing { target_dir } => {
1103                write!(formatter, "target={} action=missing", target_dir.display())
1104            }
1105            Self::Skipped {
1106                target_dir,
1107                lock_wait,
1108                schedule_check,
1109            } => write!(
1110                formatter,
1111                "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
1112                target_dir.display(),
1113            ),
1114            Self::Performed {
1115                maintenance,
1116                schedule_check,
1117            } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
1118        }
1119    }
1120}
1121
1122impl std::fmt::Display for WasmBuildTimings {
1123    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1124        write!(
1125            formatter,
1126            "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
1127            self.total,
1128            self.lock_wait,
1129            self.shared_incremental_lock_wait,
1130            self.input_resolution.total,
1131            self.cargo_build,
1132            self.cache_maintenance,
1133        )
1134    }
1135}
1136
1137impl std::fmt::Display for WasmBuildOutcome {
1138    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1139        let state = if self.is_reused() { "reused" } else { "built" };
1140        write!(
1141            formatter,
1142            "{state} fingerprint={} artifacts={} {}",
1143            self.record().fingerprint,
1144            self.record().artifacts.len(),
1145            self.record().timings,
1146        )?;
1147        if let Some(maintenance) = self.record().shared_incremental_maintenance() {
1148            write!(formatter, " shared_maintenance=({maintenance})")?;
1149        }
1150        Ok(())
1151    }
1152}
1153
1154/// Resolve the exact Cargo source, configuration, toolchain, argument, and environment identity.
1155///
1156/// This performs the same resolution used before and after cached Wasm builds
1157/// without running `cargo build`.
1158pub fn resolve_cargo_build_inputs(
1159    spec: &WasmBuildSpec,
1160) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1161    validate_spec(spec)?;
1162    build_fingerprint(spec)
1163}
1164
1165/// Inspect one configured shared Cargo target under its build coordination lock.
1166///
1167/// Returns `None` without creating anything when the caller-owned target does
1168/// not exist. This operation never removes Cargo state.
1169pub fn inspect_shared_incremental_target(
1170    spec: &WasmBuildSpec,
1171) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
1172    if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
1173        return Ok(None);
1174    }
1175
1176    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1177    let logical_size_bytes =
1178        directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1179            operation: "measure shared incremental Cargo target",
1180            path: canonical.clone(),
1181            source,
1182        })?;
1183    let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
1184        operation: "read shared incremental Cargo target use time",
1185        path: canonical.clone(),
1186        source,
1187    })?;
1188    Ok(Some(SharedIncrementalTargetInspection {
1189        target_dir: canonical,
1190        logical_size_bytes,
1191        last_used,
1192        lock_wait,
1193    }))
1194}
1195
1196/// Apply explicit whole-target retention to caller-owned shared Cargo state.
1197///
1198/// Returns `None` without creating anything when the target does not exist.
1199/// Policy evaluation and any clearing occur under the same cross-process lock
1200/// used by shared-incremental builds. The target root, `CACHEDIR.TAG`, and
1201/// `.ic-testkit` lock metadata are preserved, so another process cannot enter
1202/// through a replacement lock while maintenance is active.
1203/// Every other target child is removed when a limit is exceeded; unrelated
1204/// data that must survive must not be colocated there. Exact Cargo input
1205/// resolution first rejects targets overlapping source or configuration.
1206///
1207/// This function is never called automatically by exact Wasm acquisitions.
1208/// Consumers retain ownership of when mutable incremental state may be lost.
1209pub fn maintain_shared_incremental_target(
1210    spec: &WasmBuildSpec,
1211    policy: SharedIncrementalTargetPrunePolicy,
1212) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
1213    if !shared_incremental_target_exists(
1214        spec,
1215        "inspect shared incremental Cargo target before maintenance",
1216    )? {
1217        return Ok(None);
1218    }
1219
1220    // Reuse the exact build resolver so destructive maintenance cannot act on
1221    // a target that overlaps Cargo sources, configuration, or additional
1222    // inputs. The target itself is excluded as generated state during hashing.
1223    let _ = resolve_cargo_build_inputs(spec)?;
1224    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1225    maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
1226}
1227
1228/// Apply whole-target retention at most once per interval across processes.
1229///
1230/// The schedule marker is checked under the same lock used by shared Cargo
1231/// builds. A matching successful pass inside `minimum_interval` returns
1232/// [`SharedIncrementalTargetMaintenanceOutcome::Skipped`] without resolving
1233/// Cargo inputs or traversing the target. Missing targets are not created.
1234/// Changing the policy makes maintenance immediately due, and a zero interval
1235/// always evaluates retention.
1236///
1237/// Due maintenance performs exact Cargo input resolution before inspecting or
1238/// clearing the target. Failures are returned and are not recorded as a
1239/// successful pass, so an unsafe configuration cannot be hidden by the
1240/// schedule.
1241pub fn maintain_shared_incremental_target_at_most_every(
1242    spec: &WasmBuildSpec,
1243    policy: SharedIncrementalTargetPrunePolicy,
1244    minimum_interval: Duration,
1245) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1246    let target_dir =
1247        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1248            message: "shared incremental target is not configured".to_owned(),
1249        })?;
1250    if !shared_incremental_target_exists(
1251        spec,
1252        "inspect shared incremental Cargo target before scheduled maintenance",
1253    )? {
1254        return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
1255    }
1256
1257    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1258    let schedule = schedule_shared_incremental_target_maintenance(
1259        &canonical,
1260        policy,
1261        minimum_interval,
1262        lock_wait,
1263    )?;
1264    let schedule = match schedule {
1265        SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
1266        SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
1267    };
1268
1269    // Keep the schedule decision and maintenance in one critical section so
1270    // concurrent test binaries cannot all perform the same expensive scan.
1271    let _ = resolve_cargo_build_inputs(spec)?;
1272    perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
1273}
1274
1275enum SharedIncrementalTargetMaintenanceSchedule {
1276    Skipped(SharedIncrementalTargetMaintenanceOutcome),
1277    Due(DueSharedIncrementalTargetMaintenance),
1278}
1279
1280struct DueSharedIncrementalTargetMaintenance {
1281    schedule_root: PathBuf,
1282    maintenance_identity: String,
1283    schedule_check: Duration,
1284}
1285
1286fn schedule_shared_incremental_target_maintenance(
1287    canonical: &Path,
1288    policy: SharedIncrementalTargetPrunePolicy,
1289    minimum_interval: Duration,
1290    lock_wait: Duration,
1291) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
1292    let schedule_root = canonical.join(".ic-testkit");
1293    let maintenance_identity = policy.maintenance_identity();
1294    let schedule_started = Instant::now();
1295    let due = cache_maintenance_due(
1296        &schedule_root,
1297        Some(minimum_interval),
1298        &maintenance_identity,
1299    )
1300    .map_err(wasm_cache_fs_error)?;
1301    let schedule_check = schedule_started.elapsed();
1302    if !due {
1303        return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
1304            SharedIncrementalTargetMaintenanceOutcome::Skipped {
1305                target_dir: canonical.to_owned(),
1306                lock_wait,
1307                schedule_check,
1308            },
1309        ));
1310    }
1311    Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
1312        DueSharedIncrementalTargetMaintenance {
1313            schedule_root,
1314            maintenance_identity,
1315            schedule_check,
1316        },
1317    ))
1318}
1319
1320fn perform_due_shared_incremental_target_maintenance(
1321    canonical: &Path,
1322    policy: SharedIncrementalTargetPrunePolicy,
1323    lock_wait: Duration,
1324    due: DueSharedIncrementalTargetMaintenance,
1325) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1326    let DueSharedIncrementalTargetMaintenance {
1327        schedule_root,
1328        maintenance_identity,
1329        schedule_check,
1330    } = due;
1331    let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
1332    record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
1333    Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
1334        maintenance,
1335        schedule_check,
1336    })
1337}
1338
1339fn maintain_shared_incremental_target_locked(
1340    canonical: &Path,
1341    policy: SharedIncrementalTargetPrunePolicy,
1342    lock_wait: Duration,
1343) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
1344    let started = Instant::now();
1345    let logical_size_bytes_before =
1346        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1347            operation: "measure shared incremental Cargo target before maintenance",
1348            path: canonical.to_owned(),
1349            source,
1350        })?;
1351    let last_used_before =
1352        cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
1353            operation: "read shared incremental Cargo target use time before maintenance",
1354            path: canonical.to_owned(),
1355            source,
1356        })?;
1357    let expired = policy.max_age.is_some_and(|max_age| {
1358        SystemTime::now()
1359            .duration_since(last_used_before)
1360            .is_ok_and(|age| age > max_age)
1361    });
1362    let oversized = policy
1363        .max_size_bytes
1364        .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
1365    let cleared = expired || oversized;
1366    if cleared {
1367        clear_shared_incremental_target_contents(canonical)?;
1368        record_cache_entry_use(canonical)?;
1369    }
1370    let logical_size_bytes_after = if cleared {
1371        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1372            operation: "measure shared incremental Cargo target after maintenance",
1373            path: canonical.to_owned(),
1374            source,
1375        })?
1376    } else {
1377        logical_size_bytes_before
1378    };
1379    Ok(SharedIncrementalTargetMaintenance {
1380        target_dir: canonical.to_owned(),
1381        logical_size_bytes_before,
1382        logical_size_bytes_after,
1383        last_used_before,
1384        cleared,
1385        lock_wait,
1386        maintenance: started.elapsed(),
1387    })
1388}
1389
1390fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
1391    let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
1392        operation: "read shared incremental Cargo target for maintenance",
1393        path: target_dir.to_owned(),
1394        source,
1395    })?;
1396    for entry in entries {
1397        let path = entry
1398            .map_err(|source| WasmBuildError::Io {
1399                operation: "read shared incremental Cargo target entry for maintenance",
1400                path: target_dir.to_owned(),
1401                source,
1402            })?
1403            .path();
1404        let preserved = path
1405            .file_name()
1406            .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
1407        if !preserved {
1408            remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
1409                operation: "clear shared incremental Cargo target entry",
1410                path,
1411                source,
1412            })?;
1413        }
1414    }
1415    Ok(())
1416}
1417
1418/// Build or reuse one exact set of Cargo Wasm artifacts.
1419///
1420/// The operation takes an exclusive process lock scoped to `target_dir`, then
1421/// fingerprints all declared inputs. A cache hit requires both a matching
1422/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
1423/// builds never publish a successful stamp.
1424pub fn build_wasm_canisters_cached(
1425    spec: &WasmBuildSpec,
1426) -> Result<WasmBuildOutcome, WasmBuildError> {
1427    build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent())
1428}
1429
1430/// Build or reuse one exact Wasm set while streaming structured progress.
1431///
1432/// Cargo output remains captured for [`WasmBuildError::CommandFailed`] and is
1433/// additionally forwarded as raw chunks when enabled. Quiet Cargo processes
1434/// emit periodic heartbeats, so a legitimate cold build need not appear
1435/// stalled. Observer panics propagate after terminating the child process and
1436/// preserving normal incomplete-entry cleanup.
1437pub fn build_wasm_canisters_cached_with_progress<F>(
1438    spec: &WasmBuildSpec,
1439    config: WasmBuildProgressConfig,
1440    mut observer: F,
1441) -> Result<WasmBuildOutcome, WasmBuildError>
1442where
1443    F: FnMut(WasmBuildProgressEvent),
1444{
1445    if config.heartbeat_interval == Some(Duration::ZERO) {
1446        return Err(WasmBuildError::InvalidSpec {
1447            message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
1448        });
1449    }
1450    build_wasm_canisters_cached_internal(
1451        spec,
1452        &mut ProgressReporter {
1453            config,
1454            observer: Some(&mut observer),
1455        },
1456    )
1457}
1458
1459fn build_wasm_canisters_cached_internal(
1460    spec: &WasmBuildSpec,
1461    progress: &mut ProgressReporter<'_>,
1462) -> Result<WasmBuildOutcome, WasmBuildError> {
1463    let total_started = Instant::now();
1464    validate_spec(spec)?;
1465    progress.emit(WasmBuildProgressEvent::Started);
1466    if spec.shared_incremental_maintenance_config.is_some() {
1467        let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
1468            spec,
1469            total_started,
1470            progress,
1471        )?;
1472        emit_finished_progress(&outcome, progress);
1473        return Ok(outcome);
1474    }
1475    let (cache_lock, first_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1476    ensure_cache_directory_tag(&spec.target_dir)?;
1477
1478    let resolved = resolve_inputs_with_progress(spec, progress)?;
1479    if let Some(outcome) = try_reuse_wasm_artifacts(
1480        spec,
1481        &resolved,
1482        first_lock_wait,
1483        &SharedIncrementalAcquisitionContext::default(),
1484        total_started,
1485    )? {
1486        emit_finished_progress(&outcome, progress);
1487        return Ok(outcome);
1488    }
1489    progress.emit(WasmBuildProgressEvent::CacheMiss {
1490        fingerprint: resolved.fingerprint,
1491    });
1492
1493    let outcome = match &spec.cache_mode {
1494        WasmBuildCacheMode::Isolated => {
1495            let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
1496            build_wasm_cache_miss(
1497                spec,
1498                resolved,
1499                first_lock_wait,
1500                SharedIncrementalAcquisitionContext::default(),
1501                cache_entry,
1502                total_started,
1503                progress,
1504            )
1505        }
1506        WasmBuildCacheMode::SharedIncremental { .. } => {
1507            drop(cache_lock);
1508            let configured_target = shared_incremental_target(spec)
1509                .expect("shared cache mode must resolve a shared Cargo target");
1510            progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1511                target_dir: configured_target,
1512            });
1513            let (shared_lock, shared_lock_wait, shared_target) =
1514                lock_shared_incremental_target(spec)?;
1515            progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
1516                target_dir: shared_target.clone(),
1517                wait: shared_lock_wait,
1518            });
1519            let (_cache_lock, second_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1520            ensure_cache_directory_tag(&spec.target_dir)?;
1521
1522            let mut current = resolve_inputs_with_progress(spec, progress)?;
1523            current.timings.include(resolved.timings);
1524            let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
1525            let shared_incremental = SharedIncrementalAcquisitionContext {
1526                lock_wait: Some(shared_lock_wait),
1527                maintenance: None,
1528            };
1529            if let Some(outcome) = try_reuse_wasm_artifacts(
1530                spec,
1531                &current,
1532                lock_wait,
1533                &shared_incremental,
1534                total_started,
1535            )? {
1536                emit_finished_progress(&outcome, progress);
1537                return Ok(outcome);
1538            }
1539
1540            let outcome = build_wasm_cache_miss(
1541                spec,
1542                current,
1543                lock_wait,
1544                shared_incremental,
1545                shared_target,
1546                total_started,
1547                progress,
1548            );
1549            drop(shared_lock);
1550            outcome
1551        }
1552    }?;
1553    emit_finished_progress(&outcome, progress);
1554    Ok(outcome)
1555}
1556
1557fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
1558    spec: &WasmBuildSpec,
1559    total_started: Instant,
1560    progress: &mut ProgressReporter<'_>,
1561) -> Result<WasmBuildOutcome, WasmBuildError> {
1562    let configured_target = shared_incremental_target(spec)
1563        .expect("validated scheduled maintenance must have a shared Cargo target");
1564    progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1565        target_dir: configured_target,
1566    });
1567    let (_shared_lock, shared_lock_wait, shared_target) = lock_shared_incremental_target(spec)?;
1568    progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
1569        target_dir: shared_target.clone(),
1570        wait: shared_lock_wait,
1571    });
1572    let (_cache_lock, lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1573    ensure_cache_directory_tag(&spec.target_dir)?;
1574
1575    // Resolution under both locks proves the target boundary once for the
1576    // scheduled retention pass and the following exact-cache acquisition.
1577    let resolved = resolve_inputs_with_progress(spec, progress)?;
1578    let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
1579        spec,
1580        &shared_target,
1581        shared_lock_wait,
1582        progress,
1583    )?;
1584    let shared_incremental = SharedIncrementalAcquisitionContext {
1585        lock_wait: Some(shared_lock_wait),
1586        maintenance: Some(shared_maintenance),
1587    };
1588    if let Some(outcome) = try_reuse_wasm_artifacts(
1589        spec,
1590        &resolved,
1591        lock_wait,
1592        &shared_incremental,
1593        total_started,
1594    )? {
1595        return Ok(outcome);
1596    }
1597    progress.emit(WasmBuildProgressEvent::CacheMiss {
1598        fingerprint: resolved.fingerprint,
1599    });
1600    build_wasm_cache_miss(
1601        spec,
1602        resolved,
1603        lock_wait,
1604        shared_incremental,
1605        shared_target,
1606        total_started,
1607        progress,
1608    )
1609}
1610
1611fn perform_configured_shared_incremental_target_maintenance(
1612    spec: &WasmBuildSpec,
1613    shared_target: &Path,
1614    lock_wait: Duration,
1615    progress: &mut ProgressReporter<'_>,
1616) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1617    let config = spec
1618        .shared_incremental_maintenance_config
1619        .expect("configured shared-target maintenance must have settings");
1620    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
1621        target_dir: shared_target.to_owned(),
1622    });
1623    let schedule = schedule_shared_incremental_target_maintenance(
1624        shared_target,
1625        config.policy,
1626        config.minimum_interval,
1627        lock_wait,
1628    )?;
1629    let outcome = match schedule {
1630        SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => outcome,
1631        SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
1632            perform_due_shared_incremental_target_maintenance(
1633                shared_target,
1634                config.policy,
1635                lock_wait,
1636                due,
1637            )?
1638        }
1639    };
1640    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
1641        outcome: outcome.clone(),
1642    });
1643    Ok(outcome)
1644}
1645
1646fn resolve_inputs_with_progress(
1647    spec: &WasmBuildSpec,
1648    progress: &mut ProgressReporter<'_>,
1649) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1650    let resolved = build_fingerprint(spec)?;
1651    progress.emit(WasmBuildProgressEvent::InputsResolved {
1652        fingerprint: resolved.fingerprint,
1653        input_digest: resolved.input_digest,
1654        elapsed: resolved.timings.total,
1655    });
1656    Ok(resolved)
1657}
1658
1659fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
1660    let state = if outcome.is_reused() {
1661        progress.emit(WasmBuildProgressEvent::CacheHit {
1662            fingerprint: outcome.record().fingerprint,
1663        });
1664        WasmBuildProgressOutcome::Reused
1665    } else {
1666        WasmBuildProgressOutcome::Built
1667    };
1668    progress.emit(WasmBuildProgressEvent::Finished {
1669        outcome: state,
1670        fingerprint: outcome.record().fingerprint,
1671        elapsed: outcome.record().timings.total,
1672    });
1673}
1674
1675#[derive(Clone, Debug, Default)]
1676struct SharedIncrementalAcquisitionContext {
1677    lock_wait: Option<Duration>,
1678    maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
1679}
1680
1681fn try_reuse_wasm_artifacts(
1682    spec: &WasmBuildSpec,
1683    resolved: &ResolvedCargoBuildInputs,
1684    lock_wait: Duration,
1685    shared_incremental: &SharedIncrementalAcquisitionContext,
1686    total_started: Instant,
1687) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
1688    let fingerprint = resolved.fingerprint;
1689    let artifacts = expected_artifacts(spec, &spec.target_dir);
1690    let cache_entry = cache_entry_directory(spec, fingerprint);
1691    if artifact_set_matches(&artifacts, fingerprint) {
1692        record_cache_entry_use_if_present(&cache_entry)?;
1693        return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1694            spec,
1695            BuildRecordInput {
1696                fingerprint,
1697                input_digest: resolved.input_digest,
1698                artifacts,
1699                lock_wait,
1700                shared_incremental: shared_incremental.clone(),
1701                input_resolution: resolved.timings,
1702                cargo_build: None,
1703                active_entry: &cache_entry,
1704            },
1705            total_started,
1706        ))));
1707    }
1708
1709    let cached_artifacts = expected_artifacts(spec, &cache_entry);
1710    if !artifact_set_matches(&cached_artifacts, fingerprint) {
1711        return Ok(None);
1712    }
1713    materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1714    record_cache_entry_use(&cache_entry)?;
1715    Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1716        spec,
1717        BuildRecordInput {
1718            fingerprint,
1719            input_digest: resolved.input_digest,
1720            artifacts,
1721            lock_wait,
1722            shared_incremental: shared_incremental.clone(),
1723            input_resolution: resolved.timings,
1724            cargo_build: None,
1725            active_entry: &cache_entry,
1726        },
1727        total_started,
1728    ))))
1729}
1730
1731fn build_wasm_cache_miss(
1732    spec: &WasmBuildSpec,
1733    resolved: ResolvedCargoBuildInputs,
1734    lock_wait: Duration,
1735    shared_incremental: SharedIncrementalAcquisitionContext,
1736    cargo_target_dir: PathBuf,
1737    total_started: Instant,
1738    progress: &mut ProgressReporter<'_>,
1739) -> Result<WasmBuildOutcome, WasmBuildError> {
1740    let fingerprint = resolved.fingerprint;
1741    let mut input_resolution = resolved.timings;
1742    let artifacts = expected_artifacts(spec, &spec.target_dir);
1743    let cache_entry = cache_entry_directory(spec, fingerprint);
1744    remove_directory_if_present(&cache_entry)?;
1745    create_dir_all(
1746        &cache_entry,
1747        "create content-addressed Cargo target directory",
1748    )?;
1749    let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
1750    let build_result = (|| {
1751        if matches!(
1752            spec.cache_mode,
1753            WasmBuildCacheMode::SharedIncremental { .. }
1754        ) {
1755            record_cache_entry_use(&cargo_target_dir)?;
1756        }
1757        let build_started = Instant::now();
1758        run_cargo_build(spec, &cargo_target_dir, progress)?;
1759        let cargo_build = build_started.elapsed();
1760        let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
1761        let missing = missing_artifacts(&built_artifacts);
1762        if !missing.is_empty() {
1763            return Err(WasmBuildError::MissingArtifacts { paths: missing });
1764        }
1765
1766        let verified = resolve_inputs_with_progress(spec, progress)?;
1767        input_resolution.include(verified.timings);
1768        if fingerprint != verified.fingerprint {
1769            return Err(WasmBuildError::InputsChangedDuringBuild {
1770                before: fingerprint,
1771                after: verified.fingerprint,
1772            });
1773        }
1774
1775        let cached_artifacts = expected_artifacts(spec, &cache_entry);
1776        if cargo_target_dir != cache_entry {
1777            copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
1778        }
1779        publish_artifact_stamps(&cached_artifacts, fingerprint)?;
1780        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1781        record_cache_entry_use(&cache_entry)?;
1782
1783        Ok(WasmBuildOutcome::Built(complete_build_record(
1784            spec,
1785            BuildRecordInput {
1786                fingerprint,
1787                input_digest: resolved.input_digest,
1788                artifacts,
1789                lock_wait,
1790                shared_incremental,
1791                input_resolution,
1792                cargo_build: Some(cargo_build),
1793                active_entry: &cache_entry,
1794            },
1795            total_started,
1796        )))
1797    })();
1798    finish_fingerprint_build(build_result, incomplete_directory)
1799}
1800
1801/// Prune fingerprint-specific Cargo target directories under `target_dir`.
1802///
1803/// Pruning uses the same exclusive process lock as builds. Entries older than
1804/// the configured age are removed first, then least-recently-used entries are
1805/// removed until the configured logical byte limit is met. Only direct child
1806/// directories with SHA-256 fingerprint names are eligible; caller-facing
1807/// artifacts and unrelated target contents are never removed.
1808pub fn prune_wasm_build_cache(
1809    target_dir: &Path,
1810    policy: WasmBuildCachePrunePolicy,
1811) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1812    let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
1813    ensure_cache_directory_tag(target_dir)?;
1814
1815    prune_wasm_build_cache_locked(target_dir, policy, None)
1816}
1817
1818struct BuildRecordInput<'a> {
1819    fingerprint: InputDigest,
1820    input_digest: InputDigest,
1821    artifacts: Vec<PathBuf>,
1822    lock_wait: Duration,
1823    shared_incremental: SharedIncrementalAcquisitionContext,
1824    input_resolution: WasmInputResolutionTimings,
1825    cargo_build: Option<Duration>,
1826    active_entry: &'a Path,
1827}
1828
1829fn complete_build_record(
1830    spec: &WasmBuildSpec,
1831    input: BuildRecordInput<'_>,
1832    total_started: Instant,
1833) -> WasmBuildRecord {
1834    let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
1835        let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
1836        let identity = policy.maintenance_identity();
1837        perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
1838            prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
1839                .map_err(|error| error.to_string())
1840        })
1841    });
1842    WasmBuildRecord {
1843        fingerprint: input.fingerprint,
1844        input_digest: input.input_digest,
1845        artifacts: input.artifacts,
1846        timings: WasmBuildTimings {
1847            lock_wait: input.lock_wait,
1848            shared_incremental_lock_wait: input.shared_incremental.lock_wait,
1849            input_resolution: input.input_resolution,
1850            cargo_build: input.cargo_build,
1851            cache_maintenance,
1852            total: total_started.elapsed(),
1853        },
1854        maintenance,
1855        shared_incremental_maintenance: input.shared_incremental.maintenance,
1856    }
1857}
1858
1859fn prune_wasm_build_cache_locked(
1860    target_dir: &Path,
1861    policy: WasmBuildCachePrunePolicy,
1862    protected_entry: Option<&Path>,
1863) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1864    let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1865    prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
1866        .map_err(wasm_cache_fs_error)
1867}
1868
1869struct IncompleteBuildDirectory {
1870    path: PathBuf,
1871    armed: bool,
1872}
1873
1874impl IncompleteBuildDirectory {
1875    const fn new(path: PathBuf) -> Self {
1876        Self { path, armed: true }
1877    }
1878
1879    fn preserve(mut self) {
1880        self.armed = false;
1881    }
1882
1883    fn cleanup(mut self) -> io::Result<()> {
1884        let result = remove_path_if_present(&self.path);
1885        if result.is_ok() {
1886            self.armed = false;
1887        }
1888        result
1889    }
1890}
1891
1892impl Drop for IncompleteBuildDirectory {
1893    fn drop(&mut self) {
1894        if self.armed {
1895            let _ = remove_path_if_present(&self.path);
1896        }
1897    }
1898}
1899
1900fn finish_fingerprint_build(
1901    result: Result<WasmBuildOutcome, WasmBuildError>,
1902    incomplete_directory: IncompleteBuildDirectory,
1903) -> Result<WasmBuildOutcome, WasmBuildError> {
1904    match result {
1905        Ok(outcome) => {
1906            incomplete_directory.preserve();
1907            Ok(outcome)
1908        }
1909        Err(build_error) => {
1910            let path = incomplete_directory.path.clone();
1911            match incomplete_directory.cleanup() {
1912                Ok(()) => Err(build_error),
1913                Err(source) => Err(WasmBuildError::FailedBuildCleanup {
1914                    build_error: Box::new(build_error),
1915                    path,
1916                    source,
1917                }),
1918            }
1919        }
1920    }
1921}
1922
1923fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
1924    create_dir_all(target_dir, "create Cargo target directory")?;
1925    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
1926    lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
1927}
1928
1929fn lock_shared_incremental_target(
1930    spec: &WasmBuildSpec,
1931) -> Result<(File, Duration, PathBuf), WasmBuildError> {
1932    let target_dir =
1933        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1934            message: "shared incremental target is not configured".to_owned(),
1935        })?;
1936    create_dir_all(
1937        &target_dir,
1938        "create shared incremental Cargo target directory",
1939    )?;
1940    ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
1941    let canonical = target_dir
1942        .canonicalize()
1943        .map_err(|source| WasmBuildError::Io {
1944            operation: "resolve shared incremental Cargo target directory",
1945            path: target_dir.clone(),
1946            source,
1947        })?;
1948    let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
1949    let (lock, wait) = lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?;
1950    Ok((lock, wait, canonical))
1951}
1952
1953fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
1954    ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
1955}
1956
1957fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
1958    if path.is_dir() {
1959        record_cache_entry_use(path)?;
1960    }
1961    Ok(())
1962}
1963
1964fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
1965    record_entry_use(path).map_err(wasm_cache_fs_error)
1966}
1967
1968fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
1969    WasmBuildError::Io {
1970        operation: error.operation,
1971        path: error.path,
1972        source: error.source,
1973    }
1974}
1975
1976fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
1977    if spec.packages.is_empty() {
1978        return Err(WasmBuildError::InvalidSpec {
1979            message: "at least one Cargo package is required".to_owned(),
1980        });
1981    }
1982    if spec.profile_target_dir.is_empty() {
1983        return Err(WasmBuildError::InvalidSpec {
1984            message: "Cargo profile target directory must not be empty".to_owned(),
1985        });
1986    }
1987    if spec.target.is_empty() {
1988        return Err(WasmBuildError::InvalidSpec {
1989            message: "Cargo compilation target must not be empty".to_owned(),
1990        });
1991    }
1992    if matches!(
1993        &spec.cache_mode,
1994        WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
1995    ) {
1996        return Err(WasmBuildError::InvalidSpec {
1997            message: "shared incremental Cargo target directory must not be empty".to_owned(),
1998        });
1999    }
2000    if spec.shared_incremental_maintenance_config.is_some()
2001        && !matches!(
2002            spec.cache_mode,
2003            WasmBuildCacheMode::SharedIncremental { .. }
2004        )
2005    {
2006        return Err(WasmBuildError::InvalidSpec {
2007            message:
2008                "scheduled shared-target maintenance requires a shared incremental Cargo target"
2009                    .to_owned(),
2010        });
2011    }
2012    Ok(())
2013}
2014
2015fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2016    let total_started = Instant::now();
2017    let tool_started = Instant::now();
2018    let cargo_identity = command_identity(
2019        spec,
2020        WasmBuildPhase::CargoIdentity,
2021        &spec.cargo_program,
2022        &["--version", "--verbose"],
2023    )?;
2024    let rustc_program = spec
2025        .extra_env
2026        .get(OsStr::new("RUSTC"))
2027        .unwrap_or(&spec.rustc_program);
2028    let rustc_identity =
2029        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
2030    let tool_identity = tool_started.elapsed();
2031
2032    let metadata_started = Instant::now();
2033    let metadata = cargo_metadata(spec)?;
2034    let cargo_metadata = metadata_started.elapsed();
2035
2036    let discovery_started = Instant::now();
2037    let inputs = resolve_local_inputs(spec, &metadata)?;
2038    validate_shared_incremental_target_boundary(spec, &inputs)?;
2039    let exclusions = source_exclusions(spec, &inputs);
2040    let input_discovery = discovery_started.elapsed();
2041
2042    let hashing_started = Instant::now();
2043    let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
2044        .map_err(|source| WasmBuildError::Io {
2045            operation: "hash Wasm build inputs",
2046            path: spec.workspace_root.clone(),
2047            source,
2048        })?;
2049    let content_hashing = hashing_started.elapsed();
2050
2051    let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
2052    let mut packages = spec.packages.clone();
2053    packages.sort();
2054    packages.dedup();
2055    for package in packages {
2056        hasher.field("package", package.as_bytes());
2057    }
2058    hasher.field("target", spec.target.as_bytes());
2059    hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
2060    for argument in &spec.cargo_profile_args {
2061        hasher.field("cargo-argument", &os_bytes(argument));
2062    }
2063    for (key, value) in effective_environment(spec) {
2064        hasher.field("environment-key", &os_bytes(&key));
2065        if let Some(value) = value {
2066            hasher.field("environment-value", &os_bytes(&value));
2067        } else {
2068            hasher.field("environment-unset", b"");
2069        }
2070    }
2071    hasher.field("cargo-identity", &cargo_identity);
2072    hasher.field("rustc-identity", &rustc_identity);
2073    hasher.field("source-input-digest", input_digest.as_bytes());
2074    Ok(ResolvedCargoBuildInputs {
2075        fingerprint: hasher.finish(),
2076        input_digest,
2077        inputs: inputs
2078            .into_iter()
2079            .map(|(label, path)| CargoBuildInput { label, path })
2080            .collect(),
2081        exclusions,
2082        timings: WasmInputResolutionTimings {
2083            tool_identity,
2084            cargo_metadata,
2085            input_discovery,
2086            content_hashing,
2087            total: total_started.elapsed(),
2088        },
2089    })
2090}
2091
2092fn command_identity(
2093    spec: &WasmBuildSpec,
2094    phase: WasmBuildPhase,
2095    program: &OsStr,
2096    arguments: &[&str],
2097) -> Result<Vec<u8>, WasmBuildError> {
2098    let mut command = Command::new(program);
2099    command.current_dir(&spec.workspace_root).args(arguments);
2100    apply_command_environment(&mut command, spec);
2101    let output = command
2102        .output()
2103        .map_err(|source| WasmBuildError::CommandSpawn {
2104            phase,
2105            program: program.to_owned(),
2106            source,
2107        })?;
2108    ensure_command_success(phase, output).map(|output| {
2109        let mut identity = output.stdout;
2110        identity.extend_from_slice(&output.stderr);
2111        identity
2112    })
2113}
2114
2115fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
2116    let mut command = Command::new(&spec.cargo_program);
2117    command
2118        .current_dir(&spec.workspace_root)
2119        .args(["metadata", "--format-version", "1"]);
2120    for argument in metadata_arguments(&spec.cargo_profile_args) {
2121        command.arg(argument);
2122    }
2123    apply_command_environment(&mut command, spec);
2124    let output = command
2125        .output()
2126        .map_err(|source| WasmBuildError::CommandSpawn {
2127            phase: WasmBuildPhase::CargoMetadata,
2128            program: spec.cargo_program.clone(),
2129            source,
2130        })?;
2131    let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
2132    serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
2133        message: format!("Cargo metadata was not valid JSON: {error}"),
2134    })
2135}
2136
2137fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
2138    let mut selected = Vec::new();
2139    let mut arguments = arguments.iter();
2140    while let Some(argument) = arguments.next() {
2141        let argument_text = argument.to_string_lossy();
2142        match argument_text.as_ref() {
2143            "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
2144                selected.push(argument.clone());
2145            }
2146            "--features" | "-F" | "--filter-platform" => {
2147                selected.push(argument.clone());
2148                if let Some(value) = arguments.next() {
2149                    selected.push(value.clone());
2150                }
2151            }
2152            _ if argument_text.starts_with("--features=")
2153                || argument_text.starts_with("--filter-platform=") =>
2154            {
2155                selected.push(argument.clone());
2156            }
2157            _ => {}
2158        }
2159    }
2160    selected
2161}
2162
2163#[derive(Clone)]
2164struct MetadataPackage {
2165    id: String,
2166    name: String,
2167    version: String,
2168    manifest_path: PathBuf,
2169    is_local: bool,
2170}
2171
2172fn resolve_local_inputs(
2173    spec: &WasmBuildSpec,
2174    metadata: &Value,
2175) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
2176    let packages = metadata_packages(metadata)?;
2177    let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
2178    let dependencies = metadata_dependencies(metadata)?;
2179    let mut closure = BTreeSet::new();
2180    while let Some(id) = selected_ids.pop_front() {
2181        if !closure.insert(id.clone()) {
2182            continue;
2183        }
2184        if let Some(deps) = dependencies.get(&id) {
2185            selected_ids.extend(deps.iter().cloned());
2186        }
2187    }
2188
2189    let workspace_root = metadata
2190        .get("workspace_root")
2191        .and_then(Value::as_str)
2192        .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
2193    let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
2194    append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
2195    append_additional_inputs(&mut inputs, spec, &workspace_root);
2196    Ok(inputs)
2197}
2198
2199fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
2200    let packages_value = metadata
2201        .get("packages")
2202        .and_then(Value::as_array)
2203        .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
2204    let mut packages = HashMap::new();
2205    for value in packages_value {
2206        let package = MetadataPackage {
2207            id: required_string(value, "id")?,
2208            name: required_string(value, "name")?,
2209            version: required_string(value, "version")?,
2210            manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
2211            is_local: value.get("source").is_some_and(Value::is_null),
2212        };
2213        packages.insert(package.id.clone(), package);
2214    }
2215    Ok(packages)
2216}
2217
2218fn selected_package_ids(
2219    spec: &WasmBuildSpec,
2220    metadata: &Value,
2221    packages: &HashMap<String, MetadataPackage>,
2222) -> Result<VecDeque<String>, WasmBuildError> {
2223    let workspace_members = metadata
2224        .get("workspace_members")
2225        .and_then(Value::as_array)
2226        .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
2227        .iter()
2228        .filter_map(Value::as_str)
2229        .collect::<HashSet<_>>();
2230    let mut selected_ids = VecDeque::new();
2231    for requested in &spec.packages {
2232        let matches = packages
2233            .values()
2234            .filter(|package| {
2235                package.name == *requested && workspace_members.contains(package.id.as_str())
2236            })
2237            .map(|package| package.id.clone())
2238            .collect::<Vec<_>>();
2239        match matches.as_slice() {
2240            [id] => selected_ids.push_back(id.clone()),
2241            [] => {
2242                return Err(WasmBuildError::InvalidSpec {
2243                    message: format!("Cargo workspace contains no package named `{requested}`"),
2244                });
2245            }
2246            _ => {
2247                return Err(WasmBuildError::InvalidSpec {
2248                    message: format!("Cargo workspace package name `{requested}` is ambiguous"),
2249                });
2250            }
2251        }
2252    }
2253    Ok(selected_ids)
2254}
2255
2256fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
2257    let mut dependencies = HashMap::<String, Vec<String>>::new();
2258    let nodes = metadata
2259        .pointer("/resolve/nodes")
2260        .and_then(Value::as_array)
2261        .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
2262    for node in nodes {
2263        let id = required_string(node, "id")?;
2264        let deps = node
2265            .get("deps")
2266            .and_then(Value::as_array)
2267            .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
2268            .iter()
2269            .map(|dependency| required_string(dependency, "pkg"))
2270            .collect::<Result<Vec<_>, _>>()?;
2271        dependencies.insert(id, deps);
2272    }
2273    Ok(dependencies)
2274}
2275
2276fn workspace_configuration_inputs(
2277    spec: &WasmBuildSpec,
2278    workspace_root: &Path,
2279) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
2280    let mut inputs = Vec::new();
2281    add_if_present(
2282        &mut inputs,
2283        "workspace/Cargo.toml",
2284        workspace_root.join("Cargo.toml"),
2285    );
2286    add_if_present(
2287        &mut inputs,
2288        "workspace/Cargo.lock",
2289        workspace_root.join("Cargo.lock"),
2290    );
2291    add_if_present(
2292        &mut inputs,
2293        "workspace/rust-toolchain.toml",
2294        workspace_root.join("rust-toolchain.toml"),
2295    );
2296    add_if_present(
2297        &mut inputs,
2298        "workspace/rust-toolchain",
2299        workspace_root.join("rust-toolchain"),
2300    );
2301    append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
2302    Ok(inputs)
2303}
2304
2305fn append_cargo_configuration_inputs(
2306    inputs: &mut Vec<(PathBuf, PathBuf)>,
2307    spec: &WasmBuildSpec,
2308    workspace_root: &Path,
2309) -> Result<(), WasmBuildError> {
2310    let invocation_root =
2311        spec.workspace_root
2312            .canonicalize()
2313            .map_err(|source| WasmBuildError::Io {
2314                operation: "resolve Cargo invocation directory",
2315                path: spec.workspace_root.clone(),
2316                source,
2317            })?;
2318    let canonical_workspace =
2319        workspace_root
2320            .canonicalize()
2321            .map_err(|source| WasmBuildError::Io {
2322                operation: "resolve Cargo workspace directory",
2323                path: workspace_root.to_owned(),
2324                source,
2325            })?;
2326
2327    let mut roots = invocation_root
2328        .ancestors()
2329        .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
2330        .collect::<Vec<_>>();
2331    if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
2332        && let Some(config) = effective_cargo_config(&cargo_home)
2333    {
2334        roots.push(config);
2335    }
2336
2337    let mut visited = BTreeSet::new();
2338    for config in roots {
2339        append_cargo_configuration_tree(
2340            inputs,
2341            &config,
2342            &canonical_workspace,
2343            &mut visited,
2344            false,
2345        )?;
2346    }
2347    Ok(())
2348}
2349
2350fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
2351    let extensionless = directory.join("config");
2352    if extensionless.exists() {
2353        return Some(extensionless);
2354    }
2355    let toml = directory.join("config.toml");
2356    toml.exists().then_some(toml)
2357}
2358
2359fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
2360    if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
2361        let cargo_home = PathBuf::from(cargo_home);
2362        return Some(if cargo_home.is_absolute() {
2363            cargo_home
2364        } else {
2365            invocation_root.join(cargo_home)
2366        });
2367    }
2368
2369    default_home_directory(spec).map(|home| {
2370        let home = if home.is_absolute() {
2371            home
2372        } else {
2373            invocation_root.join(home)
2374        };
2375        home.join(".cargo")
2376    })
2377}
2378
2379#[cfg(windows)]
2380fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2381    command_environment_value(spec, "USERPROFILE")
2382        .or_else(|| command_environment_value(spec, "HOME"))
2383        .map(PathBuf::from)
2384}
2385
2386#[cfg(not(windows))]
2387fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2388    command_environment_value(spec, "HOME").map(PathBuf::from)
2389}
2390
2391fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
2392    spec.extra_env
2393        .get(OsStr::new(name))
2394        .cloned()
2395        .or_else(|| std::env::var_os(name))
2396}
2397
2398fn append_cargo_configuration_tree(
2399    inputs: &mut Vec<(PathBuf, PathBuf)>,
2400    config: &Path,
2401    workspace_root: &Path,
2402    visited: &mut BTreeSet<PathBuf>,
2403    optional: bool,
2404) -> Result<(), WasmBuildError> {
2405    let canonical = match config.canonicalize() {
2406        Ok(canonical) => canonical,
2407        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
2408        Err(source) => {
2409            return Err(WasmBuildError::Io {
2410                operation: "resolve Cargo configuration",
2411                path: config.to_owned(),
2412                source,
2413            });
2414        }
2415    };
2416    if !visited.insert(canonical.clone()) {
2417        return Ok(());
2418    }
2419
2420    let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
2421        operation: "read Cargo configuration",
2422        path: canonical.clone(),
2423        source,
2424    })?;
2425    let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
2426        WasmBuildError::InvalidCargoConfiguration {
2427            path: canonical.clone(),
2428            message: error.to_string(),
2429        }
2430    })?;
2431    inputs.push((
2432        cargo_configuration_label(&canonical, workspace_root),
2433        canonical.clone(),
2434    ));
2435
2436    let Some(include) = configuration.get("include") else {
2437        return Ok(());
2438    };
2439    let parent = canonical
2440        .parent()
2441        .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
2442            path: canonical.clone(),
2443            message: "configuration path has no parent directory".to_owned(),
2444        })?;
2445    for (included, optional) in cargo_configuration_includes(include, &canonical)? {
2446        let included = if included.is_absolute() {
2447            included
2448        } else {
2449            parent.join(included)
2450        };
2451        append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
2452    }
2453    Ok(())
2454}
2455
2456fn cargo_configuration_includes(
2457    include: &TomlValue,
2458    config: &Path,
2459) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
2460    let values = match include {
2461        TomlValue::Array(values) => values.as_slice(),
2462        value => std::slice::from_ref(value),
2463    };
2464    values
2465        .iter()
2466        .map(|value| match value {
2467            TomlValue::String(path) => Ok((PathBuf::from(path), false)),
2468            TomlValue::Table(table) => {
2469                let path = table
2470                    .get("path")
2471                    .and_then(TomlValue::as_str)
2472                    .ok_or_else(|| {
2473                        invalid_cargo_configuration(
2474                            config,
2475                            "Cargo configuration include table requires a string `path`",
2476                        )
2477                    })?;
2478                let optional = table
2479                    .get("optional")
2480                    .map(|value| {
2481                        value.as_bool().ok_or_else(|| {
2482                            invalid_cargo_configuration(
2483                                config,
2484                                "Cargo configuration include `optional` must be a boolean",
2485                            )
2486                        })
2487                    })
2488                    .transpose()?
2489                    .unwrap_or(false);
2490                Ok((PathBuf::from(path), optional))
2491            }
2492            _ => Err(invalid_cargo_configuration(
2493                config,
2494                "Cargo configuration `include` must contain paths or include tables",
2495            )),
2496        })
2497        .collect()
2498}
2499
2500fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
2501    if let Ok(relative) = config.strip_prefix(workspace_root) {
2502        return PathBuf::from("cargo-config/workspace").join(relative);
2503    }
2504    let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
2505    PathBuf::from("cargo-config/external").join(location.to_hex())
2506}
2507
2508fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
2509    WasmBuildError::InvalidCargoConfiguration {
2510        path: path.to_owned(),
2511        message: message.to_owned(),
2512    }
2513}
2514
2515fn append_package_inputs(
2516    inputs: &mut Vec<(PathBuf, PathBuf)>,
2517    packages: &HashMap<String, MetadataPackage>,
2518    closure: BTreeSet<String>,
2519    workspace_root: &Path,
2520) -> Result<(), WasmBuildError> {
2521    for id in closure {
2522        let Some(package) = packages.get(&id) else {
2523            return Err(invalid_metadata(&format!(
2524                "resolved package `{id}` is missing"
2525            )));
2526        };
2527        if !package.is_local {
2528            continue;
2529        }
2530        let root = package.manifest_path.parent().ok_or_else(|| {
2531            invalid_metadata(&format!(
2532                "package `{}` manifest has no parent",
2533                package.name
2534            ))
2535        })?;
2536        let relative_manifest = package
2537            .manifest_path
2538            .strip_prefix(workspace_root)
2539            .unwrap_or(&package.manifest_path);
2540        let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
2541            .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
2542        inputs.push((label, root.to_owned()));
2543    }
2544    Ok(())
2545}
2546
2547fn append_additional_inputs(
2548    inputs: &mut Vec<(PathBuf, PathBuf)>,
2549    spec: &WasmBuildSpec,
2550    workspace_root: &Path,
2551) {
2552    for additional in &spec.additional_inputs {
2553        let path = if additional.is_absolute() {
2554            additional.clone()
2555        } else {
2556            workspace_root.join(additional)
2557        };
2558        inputs.push((PathBuf::from("additional").join(additional), path));
2559    }
2560}
2561
2562fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
2563    let mut exclusions = vec![
2564        spec.target_dir.clone(),
2565        spec.workspace_root.join("target"),
2566        spec.workspace_root.join(".git"),
2567    ];
2568    if let Some(shared_target) = shared_incremental_target(spec) {
2569        exclusions.push(shared_target);
2570    }
2571    for (_, path) in inputs {
2572        if path.is_dir() {
2573            exclusions.push(path.join("target"));
2574            exclusions.push(path.join(".git"));
2575        }
2576    }
2577    exclusions
2578}
2579
2580fn validate_shared_incremental_target_boundary(
2581    spec: &WasmBuildSpec,
2582    inputs: &[(PathBuf, PathBuf)],
2583) -> Result<(), WasmBuildError> {
2584    let Some(shared_target) = shared_incremental_target(spec) else {
2585        return Ok(());
2586    };
2587    let shared_target =
2588        canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
2589            operation: "resolve shared incremental Cargo target boundary",
2590            path: shared_target.clone(),
2591            source,
2592        })?;
2593    let resolved_inputs = inputs
2594        .iter()
2595        .map(|(_, input)| {
2596            let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
2597                operation: "resolve Cargo input boundary",
2598                path: input.clone(),
2599                source,
2600            })?;
2601            let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
2602                operation: "inspect Cargo input boundary",
2603                path: canonical.clone(),
2604                source,
2605            })?;
2606            Ok((canonical, metadata.is_dir()))
2607        })
2608        .collect::<Result<Vec<_>, WasmBuildError>>()?;
2609    let safe_generated_roots = std::iter::once(spec.target_dir.clone())
2610        .chain(std::iter::once(spec.workspace_root.join("target")))
2611        .chain(
2612            inputs
2613                .iter()
2614                .filter(|(_, path)| path.is_dir())
2615                .map(|(_, path)| path.join("target")),
2616        )
2617        .filter_map(|path| canonicalize_allow_missing(&path).ok())
2618        .filter(|root| {
2619            !resolved_inputs
2620                .iter()
2621                .any(|(input, _is_directory)| input.starts_with(root))
2622        })
2623        .collect::<Vec<_>>();
2624    if safe_generated_roots
2625        .iter()
2626        .any(|root| shared_target.starts_with(root))
2627    {
2628        return Ok(());
2629    }
2630
2631    for (input, is_directory) in resolved_inputs {
2632        if shared_target == input
2633            || (is_directory && shared_target.starts_with(&input))
2634            || input.starts_with(&shared_target)
2635        {
2636            return Err(WasmBuildError::InvalidSpec {
2637                message: format!(
2638                    "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
2639                    shared_target.display()
2640                ),
2641            });
2642        }
2643    }
2644    Ok(())
2645}
2646
2647fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
2648    let absolute = if path.is_absolute() {
2649        path.to_owned()
2650    } else {
2651        std::env::current_dir()?.join(path)
2652    };
2653    let mut unresolved = Vec::<OsString>::new();
2654    let mut existing = absolute.as_path();
2655    loop {
2656        match existing.canonicalize() {
2657            Ok(mut canonical) => {
2658                for component in unresolved.into_iter().rev() {
2659                    canonical.push(component);
2660                }
2661                return Ok(canonical);
2662            }
2663            Err(error) if error.kind() == io::ErrorKind::NotFound => {
2664                let Some(name) = existing.file_name() else {
2665                    return Err(error);
2666                };
2667                unresolved.push(name.to_owned());
2668                existing = existing.parent().ok_or(error)?;
2669            }
2670            Err(error) => return Err(error),
2671        }
2672    }
2673}
2674
2675fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
2676    let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
2677        return None;
2678    };
2679    Some(if target_dir.is_absolute() {
2680        target_dir.clone()
2681    } else {
2682        spec.workspace_root.join(target_dir)
2683    })
2684}
2685
2686fn shared_incremental_target_exists(
2687    spec: &WasmBuildSpec,
2688    operation: &'static str,
2689) -> Result<bool, WasmBuildError> {
2690    let target_dir =
2691        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2692            message: "shared incremental target is not configured".to_owned(),
2693        })?;
2694    match fs::symlink_metadata(&target_dir) {
2695        Ok(metadata) if metadata.is_dir() => Ok(true),
2696        Ok(_) => Err(WasmBuildError::InvalidSpec {
2697            message: format!(
2698                "shared incremental Cargo target {} must be a directory",
2699                target_dir.display()
2700            ),
2701        }),
2702        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
2703        Err(source) => Err(WasmBuildError::Io {
2704            operation,
2705            path: target_dir,
2706            source,
2707        }),
2708    }
2709}
2710
2711fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
2712    let mut names = spec.inherited_env.clone();
2713    names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
2714    let mut environment = names
2715        .into_iter()
2716        .map(|name| {
2717            let value = std::env::var_os(&name);
2718            (name, value)
2719        })
2720        .collect::<BTreeMap<_, _>>();
2721    for (key, value) in &spec.extra_env {
2722        environment.insert(key.clone(), Some(value.clone()));
2723    }
2724    environment
2725}
2726
2727fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
2728    for (key, value) in &spec.extra_env {
2729        command.env(key, value);
2730    }
2731}
2732
2733fn run_cargo_build(
2734    spec: &WasmBuildSpec,
2735    build_target_dir: &Path,
2736    progress: &mut ProgressReporter<'_>,
2737) -> Result<(), WasmBuildError> {
2738    let mut command = Command::new(&spec.cargo_program);
2739    command
2740        .current_dir(&spec.workspace_root)
2741        .env("CARGO_TARGET_DIR", build_target_dir)
2742        .args(["build", "--target", &spec.target])
2743        .args(&spec.cargo_profile_args);
2744    apply_command_environment(&mut command, spec);
2745    for package in &spec.packages {
2746        command.args(["-p", package]);
2747    }
2748
2749    if !progress.is_observed() {
2750        let output = command
2751            .output()
2752            .map_err(|source| WasmBuildError::CommandSpawn {
2753                phase: WasmBuildPhase::CargoBuild,
2754                program: spec.cargo_program.clone(),
2755                source,
2756            })?;
2757        return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
2758    }
2759
2760    run_observed_cargo_build(spec, build_target_dir, command, progress)
2761}
2762
2763fn run_observed_cargo_build(
2764    spec: &WasmBuildSpec,
2765    build_target_dir: &Path,
2766    mut command: Command,
2767    progress: &mut ProgressReporter<'_>,
2768) -> Result<(), WasmBuildError> {
2769    command.stdout(Stdio::piped()).stderr(Stdio::piped());
2770    let started = Instant::now();
2771    let child = command
2772        .spawn()
2773        .map_err(|source| WasmBuildError::CommandSpawn {
2774            phase: WasmBuildPhase::CargoBuild,
2775            program: spec.cargo_program.clone(),
2776            source,
2777        })?;
2778    let mut child = ObservedChild::new(child);
2779    progress.emit(WasmBuildProgressEvent::CargoStarted {
2780        target_dir: build_target_dir.to_owned(),
2781    });
2782
2783    let stdout = child
2784        .child_mut()
2785        .stdout
2786        .take()
2787        .expect("Cargo stdout must be piped");
2788    let stderr = child
2789        .child_mut()
2790        .stderr
2791        .take()
2792        .expect("Cargo stderr must be piped");
2793    let (sender, chunks) = mpsc::channel();
2794    let stdout_sender = sender.clone();
2795    let stdout_reader = thread::spawn(move || {
2796        read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
2797    });
2798    let stderr_reader =
2799        thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
2800
2801    let captured = capture_observed_cargo_output(chunks, progress, started);
2802
2803    let status = child.wait().map_err(|source| WasmBuildError::Io {
2804        operation: "wait for observed cargo build",
2805        path: PathBuf::from(&spec.cargo_program),
2806        source,
2807    })?;
2808    join_output_reader(
2809        stdout_reader,
2810        "read observed cargo stdout",
2811        &spec.cargo_program,
2812    )?;
2813    join_output_reader(
2814        stderr_reader,
2815        "read observed cargo stderr",
2816        &spec.cargo_program,
2817    )?;
2818    let elapsed = started.elapsed();
2819    progress.emit(WasmBuildProgressEvent::CargoFinished {
2820        success: status.success(),
2821        code: status.code(),
2822        elapsed,
2823    });
2824
2825    ensure_command_success(
2826        WasmBuildPhase::CargoBuild,
2827        Output {
2828            status,
2829            stdout: captured.stdout,
2830            stderr: captured.stderr,
2831        },
2832    )
2833    .map(|_| ())
2834}
2835
2836struct CapturedProcessOutput {
2837    stdout: Vec<u8>,
2838    stderr: Vec<u8>,
2839}
2840
2841fn capture_observed_cargo_output(
2842    chunks: mpsc::Receiver<ProcessOutputChunk>,
2843    progress: &mut ProgressReporter<'_>,
2844    started: Instant,
2845) -> CapturedProcessOutput {
2846    let mut stdout = Vec::new();
2847    let mut stderr = Vec::new();
2848    let mut last_emitted = Instant::now();
2849    loop {
2850        let message = match progress.config.heartbeat_interval {
2851            Some(interval) => {
2852                let quiet_for = last_emitted.elapsed();
2853                if quiet_for >= interval {
2854                    progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
2855                        elapsed: started.elapsed(),
2856                    });
2857                    last_emitted = Instant::now();
2858                    None
2859                } else {
2860                    match chunks.recv_timeout(interval.saturating_sub(quiet_for)) {
2861                        Ok(chunk) => Some(chunk),
2862                        Err(RecvTimeoutError::Timeout) => {
2863                            progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
2864                                elapsed: started.elapsed(),
2865                            });
2866                            last_emitted = Instant::now();
2867                            None
2868                        }
2869                        Err(RecvTimeoutError::Disconnected) => break,
2870                    }
2871                }
2872            }
2873            None => match chunks.recv() {
2874                Ok(chunk) => Some(chunk),
2875                Err(_) => break,
2876            },
2877        };
2878        let Some(chunk) = message else {
2879            continue;
2880        };
2881        match chunk.stream {
2882            WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
2883            WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
2884        }
2885        if progress.config.emit_cargo_output {
2886            progress.emit(WasmBuildProgressEvent::CargoOutput {
2887                stream: chunk.stream,
2888                bytes: chunk.bytes,
2889            });
2890            last_emitted = Instant::now();
2891        }
2892    }
2893    CapturedProcessOutput { stdout, stderr }
2894}
2895
2896#[derive(Debug)]
2897struct ProcessOutputChunk {
2898    stream: WasmBuildOutputStream,
2899    bytes: Vec<u8>,
2900}
2901
2902fn read_process_output<R: io::Read>(
2903    mut reader: R,
2904    stream: WasmBuildOutputStream,
2905    sender: mpsc::Sender<ProcessOutputChunk>,
2906) -> io::Result<()> {
2907    let mut buffer = [0_u8; 8 * 1024];
2908    loop {
2909        let count = reader.read(&mut buffer)?;
2910        if count == 0 {
2911            return Ok(());
2912        }
2913        if sender
2914            .send(ProcessOutputChunk {
2915                stream,
2916                bytes: buffer[..count].to_vec(),
2917            })
2918            .is_err()
2919        {
2920            return Ok(());
2921        }
2922    }
2923}
2924
2925fn join_output_reader(
2926    reader: thread::JoinHandle<io::Result<()>>,
2927    operation: &'static str,
2928    cargo_program: &OsStr,
2929) -> Result<(), WasmBuildError> {
2930    let result = reader.join().map_err(|_| WasmBuildError::Io {
2931        operation,
2932        path: PathBuf::from(cargo_program),
2933        source: io::Error::other("Cargo output reader panicked"),
2934    })?;
2935    result.map_err(|source| WasmBuildError::Io {
2936        operation,
2937        path: PathBuf::from(cargo_program),
2938        source,
2939    })
2940}
2941
2942struct ObservedChild(Option<Child>);
2943
2944impl ObservedChild {
2945    const fn new(child: Child) -> Self {
2946        Self(Some(child))
2947    }
2948
2949    const fn child_mut(&mut self) -> &mut Child {
2950        self.0.as_mut().expect("observed child must be present")
2951    }
2952
2953    fn wait(&mut self) -> io::Result<ExitStatus> {
2954        let status = self.child_mut().wait()?;
2955        self.0.take();
2956        Ok(status)
2957    }
2958}
2959
2960impl Drop for ObservedChild {
2961    fn drop(&mut self) {
2962        if let Some(mut child) = self.0.take() {
2963            let _ = child.kill();
2964            let _ = child.wait();
2965        }
2966    }
2967}
2968
2969fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
2970    if output.status.success() {
2971        return Ok(output);
2972    }
2973    Err(WasmBuildError::CommandFailed {
2974        phase,
2975        status: output.status,
2976        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2977        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2978    })
2979}
2980
2981fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
2982    let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
2983    packages.sort_unstable();
2984    packages.dedup();
2985    packages
2986        .into_iter()
2987        .map(|package| {
2988            if spec.target == DEFAULT_TARGET {
2989                wasm_path(target_dir, package, &spec.profile_target_dir)
2990            } else {
2991                target_dir
2992                    .join(&spec.target)
2993                    .join(&spec.profile_target_dir)
2994                    .join(format!("{package}.wasm"))
2995            }
2996        })
2997        .collect()
2998}
2999
3000fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
3001    spec.target_dir
3002        .join(".ic-testkit/wasm-targets")
3003        .join(fingerprint.to_hex())
3004}
3005
3006fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
3007    artifacts.iter().all(|path| {
3008        fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
3009            && cache_stamp_matches(path, fingerprint)
3010    })
3011}
3012
3013fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
3014    artifacts
3015        .iter()
3016        .filter(|path| {
3017            fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
3018        })
3019        .cloned()
3020        .collect()
3021}
3022
3023fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
3024    let stamp_path = artifact_stamp_path(artifact);
3025    let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
3026        return false;
3027    };
3028    fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
3029}
3030
3031fn artifact_stamp_path(artifact: &Path) -> PathBuf {
3032    let mut name = artifact
3033        .file_name()
3034        .map_or_else(|| OsString::from("artifact"), OsString::from);
3035    name.push(".ic-testkit-build");
3036    artifact.with_file_name(name)
3037}
3038
3039fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
3040    let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
3041    Ok(format!(
3042        "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
3043    ))
3044}
3045
3046fn publish_artifact_stamps(
3047    artifacts: &[PathBuf],
3048    fingerprint: InputDigest,
3049) -> Result<(), WasmBuildError> {
3050    for artifact in artifacts {
3051        let stamp_path = artifact_stamp_path(artifact);
3052        let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
3053            WasmBuildError::Io {
3054                operation: "hash built Wasm artifact",
3055                path: artifact.clone(),
3056                source,
3057            }
3058        })?;
3059        write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
3060            operation: "publish Wasm build stamp",
3061            path: stamp_path,
3062            source,
3063        })?;
3064    }
3065    Ok(())
3066}
3067
3068fn materialize_artifacts(
3069    cached_artifacts: &[PathBuf],
3070    artifacts: &[PathBuf],
3071    fingerprint: InputDigest,
3072) -> Result<(), WasmBuildError> {
3073    for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
3074        copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
3075            operation: "publish Wasm artifact",
3076            path: artifact.clone(),
3077            source,
3078        })?;
3079    }
3080    publish_artifact_stamps(artifacts, fingerprint)
3081}
3082
3083fn copy_wasm_artifacts(
3084    source_artifacts: &[PathBuf],
3085    cached_artifacts: &[PathBuf],
3086) -> Result<(), WasmBuildError> {
3087    for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
3088        copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
3089            operation: "cache shared-incremental Wasm artifact",
3090            path: cached.clone(),
3091            source: source_error,
3092        })?;
3093    }
3094    Ok(())
3095}
3096
3097fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
3098    remove_path_if_present(path).map_err(|source| WasmBuildError::Io {
3099        operation: "remove incomplete content-addressed Cargo target directory",
3100        path: path.to_owned(),
3101        source,
3102    })
3103}
3104
3105fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
3106    fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
3107        operation,
3108        path: path.to_owned(),
3109        source,
3110    })
3111}
3112
3113fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
3114    if path.exists() {
3115        inputs.push((PathBuf::from(label), path));
3116    }
3117}
3118
3119fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
3120    value
3121        .get(field)
3122        .and_then(Value::as_str)
3123        .map(str::to_owned)
3124        .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
3125}
3126
3127fn invalid_metadata(message: &str) -> WasmBuildError {
3128    WasmBuildError::InvalidMetadata {
3129        message: message.to_owned(),
3130    }
3131}
3132
3133impl std::fmt::Display for WasmBuildPhase {
3134    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3135        formatter.write_str(match self {
3136            Self::CargoMetadata => "cargo metadata",
3137            Self::CargoIdentity => "Cargo identity",
3138            Self::RustcIdentity => "Rust compiler identity",
3139            Self::CargoBuild => "cargo build",
3140        })
3141    }
3142}
3143
3144impl std::fmt::Display for WasmBuildError {
3145    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3146        match self {
3147            Self::InvalidSpec { message } => {
3148                write!(formatter, "invalid Wasm build spec: {message}")
3149            }
3150            Self::Io {
3151                operation,
3152                path,
3153                source,
3154            } => write!(
3155                formatter,
3156                "failed to {operation} at {}: {source}",
3157                path.display()
3158            ),
3159            Self::CommandSpawn {
3160                phase,
3161                program,
3162                source,
3163            } => write!(
3164                formatter,
3165                "failed to launch {phase} using `{}`: {source}",
3166                program.to_string_lossy(),
3167            ),
3168            Self::CommandFailed {
3169                phase,
3170                status,
3171                stdout,
3172                stderr,
3173            } => write!(
3174                formatter,
3175                "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
3176            ),
3177            Self::InvalidMetadata { message } => {
3178                write!(formatter, "invalid Cargo metadata: {message}")
3179            }
3180            Self::InvalidCargoConfiguration { path, message } => write!(
3181                formatter,
3182                "invalid Cargo configuration at {}: {message}",
3183                path.display(),
3184            ),
3185            Self::MissingArtifacts { paths } => write!(
3186                formatter,
3187                "cargo build succeeded without producing: {}",
3188                paths
3189                    .iter()
3190                    .map(|path| path.display().to_string())
3191                    .collect::<Vec<_>>()
3192                    .join(", "),
3193            ),
3194            Self::InputsChangedDuringBuild { before, after } => write!(
3195                formatter,
3196                "Wasm build inputs changed while Cargo was running: {before} -> {after}",
3197            ),
3198            Self::FailedBuildCleanup {
3199                build_error,
3200                path,
3201                source,
3202            } => write!(
3203                formatter,
3204                "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
3205                path.display(),
3206            ),
3207        }
3208    }
3209}
3210
3211impl std::error::Error for WasmBuildError {
3212    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3213        match self {
3214            Self::Io { source, .. }
3215            | Self::CommandSpawn { source, .. }
3216            | Self::FailedBuildCleanup { source, .. } => Some(source),
3217            _ => None,
3218        }
3219    }
3220}
3221
3222#[cfg(test)]
3223mod tests;