Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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