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