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::{
10        Arc, RwLock,
11        atomic::{AtomicUsize, Ordering},
12        mpsc::{self, RecvTimeoutError},
13    },
14    thread,
15    time::{Duration, Instant, SystemTime},
16};
17use toml::Value as TomlValue;
18
19use crate::timing::saturating_add_optional_duration;
20
21use super::{
22    cache_fs::{
23        ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
24        RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
25        canonicalize_allow_missing, directory_logical_size,
26        ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
27        lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
28        prune_direct_child_directories, record_cache_entry_use as record_entry_use,
29        record_cache_maintenance, remove_path_if_present, remove_unretained_entry,
30    },
31    digest::{
32        InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic, digest_bytes,
33        digest_file, digest_labeled_paths_composable, os_bytes, write_atomic,
34    },
35    wasm::wasm_path,
36};
37
38const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
39const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
40const AUTOMATIC_ENVIRONMENT: &[&str] = &[
41    "CARGO_BUILD_RUSTC",
42    "CARGO_ENCODED_RUSTFLAGS",
43    "RUSTC",
44    "RUSTC_WRAPPER",
45    "RUSTC_WORKSPACE_WRAPPER",
46    "RUSTFLAGS",
47    "RUSTUP_TOOLCHAIN",
48];
49
50/// Complete caller-owned description of one cacheable Cargo Wasm build.
51///
52/// The selected package graph, sources, semantic workspace projection, Cargo
53/// configuration, Rust toolchain files, target, profile arguments, explicit
54/// child environment, selected inherited environment, and additional watched
55/// inputs contribute to the build fingerprint. The complete workspace
56/// manifest and lockfile remain conservative mutation-validation inputs.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct WasmBuildSpec {
59    workspace_root: PathBuf,
60    target_dir: PathBuf,
61    packages: Vec<String>,
62    profile_target_dir: String,
63    cargo_profile_args: Vec<OsString>,
64    extra_env: BTreeMap<OsString, OsString>,
65    inherited_env: BTreeSet<OsString>,
66    additional_inputs: Vec<PathBuf>,
67    target: String,
68    cargo_program: OsString,
69    rustc_program: OsString,
70    cache_mode: WasmBuildCacheMode,
71    prune_policy: Option<ArtifactCachePrunePolicy>,
72    prune_interval: Option<Duration>,
73    shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
74}
75
76/// Failure handling for integrated shared incremental-target maintenance.
77#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
78pub enum SharedIncrementalTargetMaintenanceFailureMode {
79    /// Fail the Wasm acquisition when scheduled maintenance fails.
80    #[default]
81    Strict,
82    /// Preserve the acquisition and attach a structured failed-maintenance outcome.
83    BestEffort,
84}
85
86/// Scheduled shared incremental-target maintenance attached to a Wasm acquisition.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub struct SharedIncrementalTargetMaintenanceConfig {
89    policy: SharedIncrementalTargetPrunePolicy,
90    minimum_interval: Duration,
91    failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
92}
93
94/// Cargo-target ownership mode for one exact cached Wasm build.
95#[non_exhaustive]
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum WasmBuildCacheMode {
98    /// Build each exact fingerprint in its own content-addressed Cargo target.
99    Isolated,
100    /// Build misses in caller-owned shared Cargo incremental state, then cache final Wasm files.
101    SharedIncremental {
102        /// Mutable Cargo target directory shared across source fingerprints.
103        target_dir: PathBuf,
104    },
105}
106
107/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub enum WasmBuildOutcome {
110    /// Cargo ran and a new successful stamp was published.
111    Built(WasmBuildRecord),
112    /// Existing artifacts and their content-addressed stamp matched exactly.
113    Reused(WasmBuildRecord),
114}
115
116/// Details shared by built and reused Wasm outcomes.
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct WasmBuildRecord {
119    fingerprint: InputDigest,
120    input_digest: InputDigest,
121    retention: RetainedCacheEntry,
122    artifacts: Vec<PathBuf>,
123    timings: WasmBuildTimings,
124    maintenance: Option<ArtifactCacheMaintenance>,
125    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
126}
127
128/// Timings for cache coordination, input resolution, and Cargo execution.
129#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
130pub struct WasmBuildTimings {
131    lock_wait: Duration,
132    shared_incremental_lock_wait: Option<Duration>,
133    input_resolution: WasmInputResolutionTimings,
134    cargo_build: Option<Duration>,
135    cache_maintenance: Option<Duration>,
136    total: Duration,
137}
138
139/// Detailed timings for exact Wasm build-input resolution.
140#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
141pub struct WasmInputResolutionTimings {
142    tool_identity: Duration,
143    cargo_metadata: Duration,
144    input_discovery: Duration,
145    content_hashing: Duration,
146    total: Duration,
147}
148
149/// Primary phase in which one cacheable Wasm acquisition failed.
150#[non_exhaustive]
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum WasmBuildFailurePhase {
153    /// The caller supplied an invalid build specification.
154    Specification,
155    /// Waiting for the exact-cache lock or preparing its directory.
156    ExactCacheCoordination,
157    /// Reading Cargo or rustc identity.
158    ToolIdentity,
159    /// Running or decoding Cargo metadata.
160    CargoMetadata,
161    /// Discovering selected source and configuration inputs.
162    InputDiscovery,
163    /// Hashing selected and conservative input contents.
164    ContentHashing,
165    /// Waiting for or preparing a shared incremental target.
166    SharedTargetCoordination,
167    /// Applying configured shared-target maintenance.
168    SharedTargetMaintenance,
169    /// Executing Cargo for the selected Wasm packages.
170    CargoBuild,
171    /// Validating, copying, stamping, or materializing Wasm artifacts.
172    ArtifactPublication,
173    /// Applying exact-cache retention after a successful acquisition.
174    ExactCacheMaintenance,
175    /// Removing an incomplete exact-cache entry after failure.
176    Cleanup,
177}
178
179/// Partial phase timings retained when a Wasm acquisition fails.
180#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub struct WasmBuildFailureTimings {
182    exact_cache_coordination: Duration,
183    shared_target_coordination: Option<Duration>,
184    input_resolution: WasmInputResolutionTimings,
185    shared_target_maintenance: Option<Duration>,
186    cargo_build: Option<Duration>,
187    artifact_publication: Option<Duration>,
188    exact_cache_maintenance: Option<Duration>,
189    cleanup: Option<Duration>,
190    total: Duration,
191}
192
193/// Structured phase and partial timings for one failed Wasm entry.
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub struct WasmBuildFailureDetails {
196    phase: WasmBuildFailurePhase,
197    timings: WasmBuildFailureTimings,
198}
199
200/// One exact local Cargo source or configuration input under a stable logical label.
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct CargoBuildInput {
203    label: PathBuf,
204    path: PathBuf,
205}
206
207/// Resolved exact inputs and identity for one [`WasmBuildSpec`].
208///
209/// The snapshot can be resolved again after an external operation to detect
210/// source, configuration, toolchain, argument, or environment changes.
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct ResolvedCargoBuildInputs {
213    fingerprint: InputDigest,
214    input_digest: InputDigest,
215    validation_digest: InputDigest,
216    inputs: Vec<CargoBuildInput>,
217    exclusions: Vec<PathBuf>,
218    timings: WasmInputResolutionTimings,
219}
220
221pub(super) enum WasmBuildInputReuse<'reuse> {
222    Session(&'reuse mut WasmBuildSessionState),
223    Snapshot(&'reuse WasmBuildInputSnapshotState),
224}
225
226pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
227    specs: &'a [WasmBuildSpec],
228    groups: Vec<BatchResolutionGroup>,
229    group_by_index: Vec<usize>,
230    resolved:
231        Vec<Option<Result<ResolvedCargoBuildInputs, (WasmBuildFailurePhase, WasmBuildError)>>>,
232    reuse: Option<WasmBuildInputReuse<'session>>,
233    metrics: WasmBuildBatchInputMetrics,
234}
235
236pub(super) struct WasmBuildSessionState {
237    snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
238    digest_cache: LabeledPathDigestCache,
239    snapshot_reuses: usize,
240    invalidated: bool,
241}
242
243pub(super) struct WasmBuildInputSnapshotState {
244    snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
245    preparation_metrics: WasmBuildBatchInputMetrics,
246    preparation_timings: WasmInputResolutionTimings,
247    reader_reuses: AtomicUsize,
248    invalidation: Arc<RwLock<bool>>,
249}
250
251// Keep the large failure-timing payload inline at this internal return boundary.
252pub(super) struct WasmBuildBatchAttempt {
253    pub(super) result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
254}
255
256struct BatchResolutionGroup {
257    indexes: Vec<usize>,
258}
259
260struct ResolvedLocalInputs {
261    validation_inputs: Vec<(PathBuf, PathBuf)>,
262    fingerprint: LocalInputFingerprint,
263}
264
265enum LocalInputFingerprint {
266    Conservative,
267    Projected {
268        inputs: Vec<(PathBuf, PathBuf)>,
269        workspace: InputDigest,
270    },
271}
272
273#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
274pub(super) struct WasmBuildBatchInputMetrics {
275    pub(super) runs: usize,
276    pub(super) reuses: usize,
277    pub(super) session_reuses: usize,
278    pub(super) prepared_reuses: usize,
279}
280
281#[derive(Eq, PartialEq)]
282struct BatchResolutionKey {
283    workspace_root: PathBuf,
284    cargo_program: OsString,
285    rustc_program: OsString,
286    metadata_arguments: Vec<OsString>,
287    environment: BTreeMap<OsString, Option<OsString>>,
288}
289
290/// Lock-coordinated disk-usage observation for a caller-owned shared Cargo target.
291#[derive(Clone, Debug, Eq, PartialEq)]
292pub struct SharedIncrementalTargetInspection {
293    target_dir: PathBuf,
294    logical_size_bytes: u64,
295    last_used: SystemTime,
296    lock_wait: Duration,
297}
298
299/// Whole-target retention limits for caller-owned shared Cargo state.
300///
301/// Unlike immutable fingerprint entries, a shared Cargo target has no safe
302/// per-entry LRU boundary. When either configured limit is exceeded,
303/// maintenance clears every other target child while preserving
304/// `ic-testkit`'s coordination metadata and the target root. Callers must not
305/// colocate unrelated data that needs to survive a clear.
306#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
307pub struct SharedIncrementalTargetPrunePolicy {
308    max_age: Option<Duration>,
309    max_size_bytes: Option<u64>,
310}
311
312/// Result of explicit shared Cargo target maintenance.
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct SharedIncrementalTargetMaintenance {
315    target_dir: PathBuf,
316    logical_size_bytes_before: u64,
317    logical_size_bytes_after: u64,
318    last_used_before: SystemTime,
319    cleared: bool,
320    lock_wait: Duration,
321    maintenance: Duration,
322}
323
324/// Result of interval-limited shared Cargo target maintenance.
325#[non_exhaustive]
326#[derive(Clone, Debug, Eq, PartialEq)]
327pub enum SharedIncrementalTargetMaintenanceOutcome {
328    /// The configured shared target does not exist, so nothing was created or inspected.
329    Missing {
330        /// Configured target path. A missing path cannot necessarily be canonicalized.
331        target_dir: PathBuf,
332    },
333    /// A successful matching maintenance pass is still inside the requested interval.
334    Skipped {
335        /// Canonical shared Cargo target directory.
336        target_dir: PathBuf,
337        /// Time spent waiting for another process using the shared target.
338        lock_wait: Duration,
339        /// Time spent checking the small cross-process schedule marker.
340        schedule_check: Duration,
341    },
342    /// Retention was evaluated under the shared-target lock.
343    Performed {
344        /// Completed retention report.
345        maintenance: SharedIncrementalTargetMaintenance,
346        /// Time spent checking the small cross-process schedule marker.
347        schedule_check: Duration,
348    },
349    /// Integrated best-effort maintenance failed without invalidating the Wasm acquisition.
350    Failed {
351        /// Canonical shared Cargo target directory.
352        target_dir: PathBuf,
353        /// Time spent waiting for another process using the shared target.
354        lock_wait: Duration,
355        /// Rendered maintenance failure retained for diagnostics.
356        message: String,
357    },
358}
359
360/// Observation settings for one cacheable Wasm build.
361#[derive(Clone, Copy, Debug, Eq, PartialEq)]
362pub struct WasmBuildProgressConfig {
363    heartbeat_interval: Option<Duration>,
364    emit_cargo_output: bool,
365}
366
367/// Raw child-process stream attached to a Cargo progress event.
368#[derive(Clone, Copy, Debug, Eq, PartialEq)]
369pub enum WasmBuildOutputStream {
370    /// Cargo standard output.
371    Stdout,
372    /// Cargo standard error.
373    Stderr,
374}
375
376/// Final cache state reported by a successful observed build.
377#[derive(Clone, Copy, Debug, Eq, PartialEq)]
378pub enum WasmBuildProgressOutcome {
379    /// Cargo ran and exact artifacts were published.
380    Built,
381    /// Exact artifacts were reused without Cargo.
382    Reused,
383}
384
385/// Potentially long phase of one observed Wasm-cache acquisition.
386#[non_exhaustive]
387#[derive(Clone, Copy, Debug, Eq, PartialEq)]
388pub enum WasmBuildProgressPhase {
389    /// Waiting for exclusive ownership of the exact artifact cache.
390    ExactCacheLock,
391    /// Reading the Cargo executable identity.
392    CargoIdentity,
393    /// Reading the Rust compiler identity.
394    RustcIdentity,
395    /// Resolving Cargo's package graph.
396    CargoMetadata,
397    /// Discovering local source and configuration inputs.
398    InputDiscovery,
399    /// Hashing exact source and configuration contents.
400    ContentHashing,
401    /// Waiting for exclusive ownership of a shared incremental Cargo target.
402    SharedTargetLock,
403    /// Inspecting or clearing a shared incremental Cargo target.
404    SharedTargetMaintenance,
405    /// Compiling the selected Wasm packages.
406    CargoBuild,
407    /// Validating, copying, hashing, or stamping exact artifacts.
408    ArtifactPublication,
409    /// Applying retention to immutable exact-cache entries.
410    ExactCacheMaintenance,
411}
412
413/// Structured progress emitted by an observed cacheable Wasm build.
414#[non_exhaustive]
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub enum WasmBuildProgressEvent {
417    /// One build/cache acquisition started.
418    Started,
419    /// One exact Cargo input-resolution pass completed.
420    InputsResolved {
421        /// Complete exact build fingerprint.
422        fingerprint: InputDigest,
423        /// Semantic selected-source/configuration digest.
424        input_digest: InputDigest,
425        /// Time spent on this resolution pass.
426        elapsed: Duration,
427    },
428    /// No reusable exact entry existed for this fingerprint.
429    CacheMiss {
430        /// Missing exact fingerprint.
431        fingerprint: InputDigest,
432    },
433    /// Exact artifacts were found and materialized when necessary.
434    CacheHit {
435        /// Reused exact fingerprint.
436        fingerprint: InputDigest,
437    },
438    /// The build is about to wait for a caller-owned shared Cargo target.
439    SharedTargetLockStarted {
440        /// Shared target selected by the build specification.
441        target_dir: PathBuf,
442    },
443    /// Exclusive shared-target ownership was acquired.
444    SharedTargetLockAcquired {
445        /// Canonical shared target directory.
446        target_dir: PathBuf,
447        /// Time spent waiting for another process.
448        wait: Duration,
449    },
450    /// Scheduled shared-target retention is about to be evaluated under lock.
451    SharedTargetMaintenanceStarted {
452        /// Canonical shared target selected by the build specification.
453        target_dir: PathBuf,
454    },
455    /// Scheduled shared-target retention completed or was skipped.
456    SharedTargetMaintenanceFinished {
457        /// Structured retention result attached to the successful acquisition.
458        outcome: SharedIncrementalTargetMaintenanceOutcome,
459    },
460    /// Cargo compilation started.
461    CargoStarted {
462        /// Cargo target receiving compilation state.
463        target_dir: PathBuf,
464    },
465    /// One raw Cargo output chunk was read without lossy UTF-8 conversion.
466    CargoOutput {
467        /// Child-process stream that produced the bytes.
468        stream: WasmBuildOutputStream,
469        /// Raw output bytes in per-stream read order.
470        bytes: Vec<u8>,
471    },
472    /// The current acquisition phase remained active without another event.
473    Heartbeat {
474        /// Phase that is still making or waiting for progress.
475        phase: WasmBuildProgressPhase,
476        /// Time elapsed since this phase started.
477        elapsed: Duration,
478    },
479    /// Cargo exited and all captured output was drained.
480    CargoFinished {
481        /// Whether Cargo reported success.
482        success: bool,
483        /// Portable exit code when the platform exposes one.
484        code: Option<i32>,
485        /// Complete Cargo execution duration.
486        elapsed: Duration,
487    },
488    /// The complete cacheable build operation succeeded.
489    Finished {
490        /// Whether Cargo ran or an exact entry was reused.
491        outcome: WasmBuildProgressOutcome,
492        /// Exact fingerprint selected by the operation.
493        fingerprint: InputDigest,
494        /// Total operation duration.
495        elapsed: Duration,
496    },
497}
498
499impl Default for WasmBuildProgressConfig {
500    fn default() -> Self {
501        Self {
502            heartbeat_interval: Some(Duration::from_secs(10)),
503            emit_cargo_output: true,
504        }
505    }
506}
507
508impl WasmBuildProgressConfig {
509    /// Observe acquisition progress and emit a heartbeat at least every ten quiet seconds.
510    #[must_use]
511    pub fn new() -> Self {
512        Self::default()
513    }
514
515    /// Select the maximum quiet interval between phase-aware heartbeat events.
516    ///
517    /// A zero interval is rejected before any build work begins.
518    #[must_use]
519    pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
520        self.heartbeat_interval = Some(interval);
521        self
522    }
523
524    /// Disable time-based heartbeats while retaining phase and output events.
525    #[must_use]
526    pub const fn without_heartbeats(mut self) -> Self {
527        self.heartbeat_interval = None;
528        self
529    }
530
531    /// Select whether raw Cargo stdout/stderr chunks are forwarded.
532    ///
533    /// Output is always captured for structured build failures.
534    #[must_use]
535    pub const fn with_cargo_output(mut self, emit: bool) -> Self {
536        self.emit_cargo_output = emit;
537        self
538    }
539
540    /// Configured heartbeat interval, or `None` when disabled.
541    #[must_use]
542    pub const fn heartbeat_interval(self) -> Option<Duration> {
543        self.heartbeat_interval
544    }
545
546    /// Whether raw Cargo output chunks are emitted to the observer.
547    #[must_use]
548    pub const fn emits_cargo_output(self) -> bool {
549        self.emit_cargo_output
550    }
551}
552
553struct ProgressReporter<'a> {
554    config: WasmBuildProgressConfig,
555    observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
556    last_event: Instant,
557    failure_phase: Option<WasmBuildFailurePhase>,
558    failure_timings: WasmBuildFailureTimings,
559}
560
561impl ProgressReporter<'_> {
562    fn silent() -> Self {
563        Self {
564            config: WasmBuildProgressConfig {
565                heartbeat_interval: None,
566                emit_cargo_output: false,
567            },
568            observer: None,
569            last_event: Instant::now(),
570            failure_phase: None,
571            failure_timings: WasmBuildFailureTimings::default(),
572        }
573    }
574
575    fn observed(
576        config: WasmBuildProgressConfig,
577        observer: &'_ mut dyn FnMut(WasmBuildProgressEvent),
578    ) -> ProgressReporter<'_> {
579        ProgressReporter {
580            config,
581            observer: Some(observer),
582            last_event: Instant::now(),
583            failure_phase: None,
584            failure_timings: WasmBuildFailureTimings::default(),
585        }
586    }
587
588    fn emit(&mut self, event: WasmBuildProgressEvent) {
589        if let Some(observer) = &mut self.observer {
590            observer(event);
591            self.last_event = Instant::now();
592        }
593    }
594
595    const fn is_observed(&self) -> bool {
596        self.observer.is_some()
597    }
598
599    fn heartbeat_due_in(&self) -> Option<Duration> {
600        self.config
601            .heartbeat_interval
602            .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
603    }
604
605    fn emit_heartbeat(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
606        self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
607    }
608
609    fn emit_heartbeat_if_due(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
610        if self.heartbeat_due_in() == Some(Duration::ZERO) {
611            self.emit_heartbeat(phase, elapsed);
612        }
613    }
614
615    fn run_phase<T, F>(&mut self, phase: WasmBuildProgressPhase, operation: F) -> T
616    where
617        T: Send,
618        F: FnOnce() -> T + Send,
619    {
620        let started = Instant::now();
621        self.begin_phase(progress_failure_phase(phase));
622        let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
623            operation()
624        } else {
625            thread::scope(|scope| {
626                let (finished, completion) = mpsc::sync_channel(0);
627                let worker = scope.spawn(move || {
628                    let result = operation();
629                    let _ = finished.send(());
630                    result
631                });
632                loop {
633                    let wait = self
634                        .heartbeat_due_in()
635                        .expect("observed phase must have a heartbeat interval");
636                    match completion.recv_timeout(wait) {
637                        Ok(()) | Err(RecvTimeoutError::Disconnected) => {
638                            return worker
639                                .join()
640                                .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
641                        }
642                        Err(RecvTimeoutError::Timeout) => {
643                            self.emit_heartbeat(phase, started.elapsed());
644                        }
645                    }
646                }
647            })
648        };
649        self.record_phase(progress_failure_phase(phase), started.elapsed());
650        result
651    }
652
653    const fn begin_phase(&mut self, phase: WasmBuildFailurePhase) {
654        self.failure_phase = Some(phase);
655    }
656
657    fn record_phase(&mut self, phase: WasmBuildFailurePhase, elapsed: Duration) {
658        self.failure_phase = Some(phase);
659        let timings = &mut self.failure_timings;
660        match phase {
661            WasmBuildFailurePhase::Specification => {}
662            WasmBuildFailurePhase::ExactCacheCoordination => {
663                timings.exact_cache_coordination =
664                    timings.exact_cache_coordination.saturating_add(elapsed);
665            }
666            WasmBuildFailurePhase::ToolIdentity => {
667                timings.input_resolution.tool_identity = timings
668                    .input_resolution
669                    .tool_identity
670                    .saturating_add(elapsed);
671                timings.input_resolution.total =
672                    timings.input_resolution.total.saturating_add(elapsed);
673            }
674            WasmBuildFailurePhase::CargoMetadata => {
675                timings.input_resolution.cargo_metadata = timings
676                    .input_resolution
677                    .cargo_metadata
678                    .saturating_add(elapsed);
679                timings.input_resolution.total =
680                    timings.input_resolution.total.saturating_add(elapsed);
681            }
682            WasmBuildFailurePhase::InputDiscovery => {
683                timings.input_resolution.input_discovery = timings
684                    .input_resolution
685                    .input_discovery
686                    .saturating_add(elapsed);
687                timings.input_resolution.total =
688                    timings.input_resolution.total.saturating_add(elapsed);
689            }
690            WasmBuildFailurePhase::ContentHashing => {
691                timings.input_resolution.content_hashing = timings
692                    .input_resolution
693                    .content_hashing
694                    .saturating_add(elapsed);
695                timings.input_resolution.total =
696                    timings.input_resolution.total.saturating_add(elapsed);
697            }
698            WasmBuildFailurePhase::SharedTargetCoordination => {
699                timings.shared_target_coordination = Some(
700                    timings
701                        .shared_target_coordination
702                        .unwrap_or_default()
703                        .saturating_add(elapsed),
704                );
705            }
706            WasmBuildFailurePhase::SharedTargetMaintenance => {
707                timings.shared_target_maintenance = Some(
708                    timings
709                        .shared_target_maintenance
710                        .unwrap_or_default()
711                        .saturating_add(elapsed),
712                );
713            }
714            WasmBuildFailurePhase::CargoBuild => {
715                timings.cargo_build = Some(
716                    timings
717                        .cargo_build
718                        .unwrap_or_default()
719                        .saturating_add(elapsed),
720                );
721            }
722            WasmBuildFailurePhase::ArtifactPublication => {
723                timings.artifact_publication = Some(
724                    timings
725                        .artifact_publication
726                        .unwrap_or_default()
727                        .saturating_add(elapsed),
728                );
729            }
730            WasmBuildFailurePhase::ExactCacheMaintenance => {
731                timings.exact_cache_maintenance = Some(
732                    timings
733                        .exact_cache_maintenance
734                        .unwrap_or_default()
735                        .saturating_add(elapsed),
736                );
737            }
738            WasmBuildFailurePhase::Cleanup => {
739                timings.cleanup = Some(timings.cleanup.unwrap_or_default().saturating_add(elapsed));
740            }
741        }
742    }
743
744    fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
745        let phase = self
746            .failure_phase
747            .unwrap_or_else(|| classify_unobserved_failure(error));
748        let mut timings = self.failure_timings;
749        timings.total = total;
750        WasmBuildFailureDetails { phase, timings }
751    }
752}
753
754const fn progress_failure_phase(phase: WasmBuildProgressPhase) -> WasmBuildFailurePhase {
755    match phase {
756        WasmBuildProgressPhase::ExactCacheLock => WasmBuildFailurePhase::ExactCacheCoordination,
757        WasmBuildProgressPhase::CargoIdentity | WasmBuildProgressPhase::RustcIdentity => {
758            WasmBuildFailurePhase::ToolIdentity
759        }
760        WasmBuildProgressPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
761        WasmBuildProgressPhase::InputDiscovery => WasmBuildFailurePhase::InputDiscovery,
762        WasmBuildProgressPhase::ContentHashing => WasmBuildFailurePhase::ContentHashing,
763        WasmBuildProgressPhase::SharedTargetLock => WasmBuildFailurePhase::SharedTargetCoordination,
764        WasmBuildProgressPhase::SharedTargetMaintenance => {
765            WasmBuildFailurePhase::SharedTargetMaintenance
766        }
767        WasmBuildProgressPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
768        WasmBuildProgressPhase::ArtifactPublication => WasmBuildFailurePhase::ArtifactPublication,
769        WasmBuildProgressPhase::ExactCacheMaintenance => {
770            WasmBuildFailurePhase::ExactCacheMaintenance
771        }
772    }
773}
774
775const fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildFailurePhase {
776    match error {
777        WasmBuildError::InvalidSpec { .. } => WasmBuildFailurePhase::Specification,
778        WasmBuildError::CommandSpawn { phase, .. }
779        | WasmBuildError::CommandFailed { phase, .. } => match phase {
780            WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
781                WasmBuildFailurePhase::ToolIdentity
782            }
783            WasmBuildPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
784            WasmBuildPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
785        },
786        WasmBuildError::InvalidMetadata { .. } => WasmBuildFailurePhase::CargoMetadata,
787        WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildFailurePhase::InputDiscovery,
788        WasmBuildError::MissingArtifacts { .. } => WasmBuildFailurePhase::ArtifactPublication,
789        WasmBuildError::InputsChangedDuringAcquisition { .. } => {
790            WasmBuildFailurePhase::ContentHashing
791        }
792        WasmBuildError::PreparedInputSnapshotInvalidated => {
793            WasmBuildFailurePhase::ArtifactPublication
794        }
795        WasmBuildError::FailedBuildCleanup { .. } => WasmBuildFailurePhase::Cleanup,
796        WasmBuildError::Io { .. } => WasmBuildFailurePhase::ExactCacheCoordination,
797    }
798}
799
800/// External phase associated with a cacheable Wasm build failure.
801#[non_exhaustive]
802#[derive(Clone, Copy, Debug, Eq, PartialEq)]
803pub enum WasmBuildPhase {
804    /// Resolving Cargo's package graph.
805    CargoMetadata,
806    /// Reading the Cargo executable identity.
807    CargoIdentity,
808    /// Reading the Rust compiler identity.
809    RustcIdentity,
810    /// Compiling the selected Wasm packages.
811    CargoBuild,
812}
813
814/// Structured failure from a cacheable Wasm build.
815#[non_exhaustive]
816#[derive(Debug)]
817pub enum WasmBuildError {
818    /// The caller supplied an incomplete or inconsistent specification.
819    InvalidSpec { message: String },
820    /// A filesystem operation failed.
821    Io {
822        operation: &'static str,
823        path: PathBuf,
824        source: io::Error,
825    },
826    /// An external command could not be launched.
827    CommandSpawn {
828        phase: WasmBuildPhase,
829        program: OsString,
830        source: io::Error,
831    },
832    /// An external command completed unsuccessfully.
833    CommandFailed {
834        phase: WasmBuildPhase,
835        status: ExitStatus,
836        stdout: String,
837        stderr: String,
838    },
839    /// Cargo metadata did not contain the expected package graph.
840    InvalidMetadata { message: String },
841    /// A discovered Cargo configuration could not be interpreted exactly.
842    InvalidCargoConfiguration { path: PathBuf, message: String },
843    /// Cargo succeeded without producing every declared Wasm artifact.
844    MissingArtifacts { paths: Vec<PathBuf> },
845    /// Declared inputs changed while acquiring or building Wasm artifacts.
846    InputsChangedDuringAcquisition {
847        before: InputDigest,
848        after: InputDigest,
849    },
850    /// Another concurrent reader invalidated the prepared input snapshot before publication.
851    PreparedInputSnapshotInvalidated,
852    /// A build failed and its incomplete fingerprint directory could not be removed.
853    FailedBuildCleanup {
854        build_error: Box<Self>,
855        path: PathBuf,
856        source: io::Error,
857    },
858}
859
860impl WasmBuildSpec {
861    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
862    ///
863    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
864    /// `release`, or the name supplied to `--profile`.
865    /// Relative `workspace_root` and exact `target_dir` paths are resolved from
866    /// the caller's working directory. Shared targets are workspace-relative.
867    #[must_use]
868    pub fn new(
869        workspace_root: &Path,
870        target_dir: &Path,
871        packages: &[&str],
872        profile_target_dir: &str,
873    ) -> Self {
874        Self {
875            workspace_root: workspace_root.to_owned(),
876            target_dir: target_dir.to_owned(),
877            packages: packages
878                .iter()
879                .map(|package| (*package).to_owned())
880                .collect(),
881            profile_target_dir: profile_target_dir.to_owned(),
882            cargo_profile_args: Vec::new(),
883            extra_env: BTreeMap::new(),
884            inherited_env: BTreeSet::new(),
885            additional_inputs: Vec::new(),
886            target: DEFAULT_TARGET.to_owned(),
887            cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
888            rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
889            cache_mode: WasmBuildCacheMode::Isolated,
890            prune_policy: None,
891            prune_interval: None,
892            shared_incremental_maintenance_config: None,
893        }
894    }
895
896    /// Set Cargo profile and feature arguments used for the build and fingerprint.
897    ///
898    /// `--target-dir` overrides are rejected during acquisition; target
899    /// directories are selected by this specification's cache configuration.
900    #[must_use]
901    pub fn with_cargo_profile_args<I, S>(mut self, arguments: I) -> Self
902    where
903        I: IntoIterator<Item = S>,
904        S: AsRef<OsStr>,
905    {
906        self.cargo_profile_args = arguments
907            .into_iter()
908            .map(|argument| argument.as_ref().to_owned())
909            .collect();
910        self
911    }
912
913    /// Set deterministic OS-native child-process environment overrides.
914    ///
915    /// `CARGO_TARGET_DIR` is owned by the cache configuration and cannot be
916    /// overridden here. Conflicting specifications fail before acquisition.
917    #[must_use]
918    pub fn with_extra_env<I, K, V>(mut self, environment: I) -> Self
919    where
920        I: IntoIterator<Item = (K, V)>,
921        K: Into<OsString>,
922        V: Into<OsString>,
923    {
924        self.extra_env = environment
925            .into_iter()
926            .map(|(key, value)| (key.into(), value.into()))
927            .collect();
928        self
929    }
930
931    /// Add ambient environment names whose current values affect the build.
932    ///
933    /// Common Rust and Cargo toolchain variables are included automatically.
934    /// Callers must declare application-specific variables read by build scripts.
935    #[must_use]
936    pub fn with_inherited_env<I, S>(mut self, names: I) -> Self
937    where
938        I: IntoIterator<Item = S>,
939        S: Into<OsString>,
940    {
941        self.inherited_env.extend(names.into_iter().map(Into::into));
942        self
943    }
944
945    /// Add files or directories not discoverable through Cargo's local dependency graph.
946    ///
947    /// Relative paths are resolved from the workspace root. Use this for build
948    /// script configuration, generated schemas, or other externally read inputs.
949    #[must_use]
950    pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
951    where
952        I: IntoIterator<Item = P>,
953        P: Into<PathBuf>,
954    {
955        self.additional_inputs
956            .extend(paths.into_iter().map(Into::into));
957        self
958    }
959
960    /// Override the Cargo compilation target.
961    #[must_use]
962    pub fn with_target(mut self, target: &str) -> Self {
963        target.clone_into(&mut self.target);
964        self
965    }
966
967    /// Override the Cargo executable used by metadata, identity, and build commands.
968    #[must_use]
969    pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
970        self.cargo_program = program.into();
971        self
972    }
973
974    /// Override the Rust compiler executable used to fingerprint the toolchain.
975    #[must_use]
976    pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
977        self.rustc_program = program.into();
978        self
979    }
980
981    /// Build cache misses in one caller-owned shared Cargo incremental target.
982    ///
983    /// Exact final Wasm artifacts still live in the content-addressed cache.
984    /// The shared target is coordinated across processes but is never pruned
985    /// or removed by `ic-testkit` after a failed build.
986    #[must_use]
987    pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
988        self.cache_mode = WasmBuildCacheMode::SharedIncremental {
989            target_dir: target_dir.into(),
990        };
991        self
992    }
993
994    /// Schedule caller-owned shared-target retention as part of acquisition.
995    ///
996    /// This option requires [`Self::with_shared_incremental_target`]. Every
997    /// acquisition coordinates through that target, including an exact hit,
998    /// so a missing target can be created and receive its first schedule
999    /// marker immediately. Matching recent passes only check the marker; due
1000    /// passes reuse the acquisition's exact Cargo input resolution before
1001    /// evaluating retention. The structured result is attached to the build
1002    /// record and emitted through observed progress. Maintenance failures fail
1003    /// the acquisition and do not record a successful schedule marker.
1004    #[must_use]
1005    pub const fn with_shared_incremental_target_maintenance_at_most_every(
1006        mut self,
1007        policy: SharedIncrementalTargetPrunePolicy,
1008        minimum_interval: Duration,
1009    ) -> Self {
1010        self.shared_incremental_maintenance_config = Some(
1011            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1012        );
1013        self
1014    }
1015
1016    /// Attach an explicit shared-target maintenance configuration.
1017    ///
1018    /// This is the configurable counterpart to
1019    /// [`Self::with_shared_incremental_target_maintenance_at_most_every`] and
1020    /// supports strict or best-effort failure handling.
1021    #[must_use]
1022    pub const fn with_shared_incremental_target_maintenance(
1023        mut self,
1024        config: SharedIncrementalTargetMaintenanceConfig,
1025    ) -> Self {
1026        self.shared_incremental_maintenance_config = Some(config);
1027        self
1028    }
1029
1030    /// Apply cache retention under the build operation's existing process lock.
1031    ///
1032    /// Maintenance is best-effort: its structured result is attached to the
1033    /// successful build record and cannot turn ready artifacts into a build
1034    /// failure. The active fingerprint is protected from this pruning pass.
1035    #[must_use]
1036    pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
1037        self.prune_policy = Some(policy);
1038        self.prune_interval = None;
1039        self
1040    }
1041
1042    /// Apply exact-entry retention at most once per `minimum_interval`.
1043    ///
1044    /// The active fingerprint remains protected. A zero interval is equivalent
1045    /// to [`Self::with_prune_policy`]. The interval covers attempted
1046    /// maintenance, including a nonfatal failed attempt. This schedule never
1047    /// owns or scans a caller-owned shared incremental Cargo target.
1048    #[must_use]
1049    pub const fn with_prune_policy_at_most_every(
1050        mut self,
1051        policy: ArtifactCachePrunePolicy,
1052        minimum_interval: Duration,
1053    ) -> Self {
1054        self.prune_policy = Some(policy);
1055        self.prune_interval = Some(minimum_interval);
1056        self
1057    }
1058
1059    /// Workspace containing the selected Cargo packages.
1060    #[must_use]
1061    pub fn workspace_root(&self) -> &Path {
1062        &self.workspace_root
1063    }
1064
1065    /// Cargo target directory containing artifacts, lock, and stamps.
1066    #[must_use]
1067    pub fn target_dir(&self) -> &Path {
1068        &self.target_dir
1069    }
1070
1071    /// Selected Cargo package names.
1072    #[must_use]
1073    pub fn packages(&self) -> &[String] {
1074        &self.packages
1075    }
1076
1077    /// Cargo-target ownership mode used for cache misses.
1078    #[must_use]
1079    pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1080        &self.cache_mode
1081    }
1082
1083    /// Exact-entry retention policy attached to this specification, when configured.
1084    #[must_use]
1085    pub const fn prune_policy(&self) -> Option<ArtifactCachePrunePolicy> {
1086        self.prune_policy
1087    }
1088
1089    /// Minimum interval between exact-entry retention attempts, when scheduled.
1090    #[must_use]
1091    pub const fn prune_interval(&self) -> Option<Duration> {
1092        self.prune_interval
1093    }
1094
1095    /// Shared incremental-target maintenance attached to this specification.
1096    #[must_use]
1097    pub const fn shared_incremental_target_maintenance(
1098        &self,
1099    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1100        self.shared_incremental_maintenance_config
1101    }
1102}
1103
1104impl WasmBuildOutcome {
1105    /// Read the common build record.
1106    #[must_use]
1107    pub const fn record(&self) -> &WasmBuildRecord {
1108        match self {
1109            Self::Built(record) | Self::Reused(record) => record,
1110        }
1111    }
1112
1113    /// Report whether exact matching artifacts were reused.
1114    #[must_use]
1115    pub const fn is_reused(&self) -> bool {
1116        matches!(self, Self::Reused(_))
1117    }
1118}
1119
1120impl WasmBuildRecord {
1121    /// Exact build fingerprint used by the atomic cache stamp.
1122    #[must_use]
1123    pub const fn fingerprint(&self) -> InputDigest {
1124        self.fingerprint
1125    }
1126
1127    /// Semantic digest of selected package sources and configuration inputs.
1128    #[must_use]
1129    pub const fn input_digest(&self) -> InputDigest {
1130        self.input_digest
1131    }
1132
1133    /// Immutable content-addressed cache directory for this exact build.
1134    ///
1135    /// The directory is selected by the build fingerprint and contains the
1136    /// cached Wasm artifacts and their stamps. The record and its clones retain
1137    /// this entry against pruning until their last drop. A copied path alone
1138    /// does not retain ownership. Treat the contents as read-only.
1139    #[must_use]
1140    pub fn exact_cache_path(&self) -> &Path {
1141        self.retention.path()
1142    }
1143
1144    /// Exact, read-only Wasm paths retained until this record and its clones drop.
1145    ///
1146    /// Keep a record alive throughout post-link processing and reading. Configured
1147    /// materialization destinations remain mutable and are not retained.
1148    #[must_use]
1149    pub fn artifacts(&self) -> &[PathBuf] {
1150        &self.artifacts
1151    }
1152
1153    /// Phase timings captured by the cacheable build operation.
1154    #[must_use]
1155    pub const fn timings(&self) -> WasmBuildTimings {
1156        self.timings
1157    }
1158
1159    /// Cache maintenance attempted under the build lock, when configured.
1160    #[must_use]
1161    pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1162        self.maintenance.as_ref()
1163    }
1164
1165    /// Scheduled caller-owned shared-target maintenance, when configured.
1166    #[must_use]
1167    pub const fn shared_incremental_maintenance(
1168        &self,
1169    ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1170        self.shared_incremental_maintenance.as_ref()
1171    }
1172}
1173
1174impl WasmBuildTimings {
1175    /// Time spent waiting for the output-directory process lock.
1176    #[must_use]
1177    pub const fn lock_wait(self) -> Duration {
1178        self.lock_wait
1179    }
1180
1181    /// Time spent waiting for a shared incremental-target lock, when configured.
1182    #[must_use]
1183    pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1184        self.shared_incremental_lock_wait
1185    }
1186
1187    /// Detailed tool, metadata, discovery, and hashing timings.
1188    #[must_use]
1189    pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1190        self.input_resolution
1191    }
1192
1193    /// Time spent in `cargo build`, or `None` for a cache hit.
1194    #[must_use]
1195    pub const fn cargo_build(self) -> Option<Duration> {
1196        self.cargo_build
1197    }
1198
1199    /// Time spent on configured best-effort cache maintenance.
1200    #[must_use]
1201    pub const fn cache_maintenance(self) -> Option<Duration> {
1202        self.cache_maintenance
1203    }
1204
1205    /// Total operation duration, including lock coordination.
1206    #[must_use]
1207    pub const fn total(self) -> Duration {
1208        self.total
1209    }
1210
1211    pub(super) const fn saturating_add(self, other: Self) -> Self {
1212        let mut input_resolution = self.input_resolution;
1213        input_resolution.include(other.input_resolution);
1214        Self {
1215            lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1216            shared_incremental_lock_wait: saturating_add_optional_duration(
1217                self.shared_incremental_lock_wait,
1218                other.shared_incremental_lock_wait,
1219            ),
1220            input_resolution,
1221            cargo_build: saturating_add_optional_duration(self.cargo_build, other.cargo_build),
1222            cache_maintenance: saturating_add_optional_duration(
1223                self.cache_maintenance,
1224                other.cache_maintenance,
1225            ),
1226            total: self.total.saturating_add(other.total),
1227        }
1228    }
1229}
1230
1231impl WasmInputResolutionTimings {
1232    /// Time spent reading Cargo and rustc identities.
1233    #[must_use]
1234    pub const fn tool_identity(self) -> Duration {
1235        self.tool_identity
1236    }
1237
1238    /// Time spent running and decoding `cargo metadata`.
1239    #[must_use]
1240    pub const fn cargo_metadata(self) -> Duration {
1241        self.cargo_metadata
1242    }
1243
1244    /// Time spent resolving packages, configuration, and watched paths.
1245    #[must_use]
1246    pub const fn input_discovery(self) -> Duration {
1247        self.input_discovery
1248    }
1249
1250    /// Time spent reading and hashing exact input contents.
1251    #[must_use]
1252    pub const fn content_hashing(self) -> Duration {
1253        self.content_hashing
1254    }
1255
1256    /// Complete input-resolution duration.
1257    #[must_use]
1258    pub const fn total(self) -> Duration {
1259        self.total
1260    }
1261
1262    const fn include(&mut self, other: Self) {
1263        self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1264        self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1265        self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1266        self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1267        self.total = self.total.saturating_add(other.total);
1268    }
1269}
1270
1271impl WasmBuildFailureDetails {
1272    pub(super) fn specification(total: Duration) -> Self {
1273        Self {
1274            phase: WasmBuildFailurePhase::Specification,
1275            timings: WasmBuildFailureTimings {
1276                total,
1277                ..WasmBuildFailureTimings::default()
1278            },
1279        }
1280    }
1281
1282    /// Primary acquisition phase that returned the failure.
1283    #[must_use]
1284    pub const fn phase(self) -> WasmBuildFailurePhase {
1285        self.phase
1286    }
1287
1288    /// Partial phase timings retained before the failure returned.
1289    #[must_use]
1290    pub const fn timings(self) -> WasmBuildFailureTimings {
1291        self.timings
1292    }
1293}
1294
1295impl WasmBuildFailureTimings {
1296    /// Time spent coordinating the exact artifact cache before failure.
1297    #[must_use]
1298    pub const fn exact_cache_coordination(self) -> Duration {
1299        self.exact_cache_coordination
1300    }
1301
1302    /// Time spent coordinating a shared incremental target, when reached.
1303    #[must_use]
1304    pub const fn shared_target_coordination(self) -> Option<Duration> {
1305        self.shared_target_coordination
1306    }
1307
1308    /// Partial Cargo/rustc identity, metadata, discovery, and hashing timings.
1309    #[must_use]
1310    pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1311        self.input_resolution
1312    }
1313
1314    /// Time spent on shared-target maintenance, when reached.
1315    #[must_use]
1316    pub const fn shared_target_maintenance(self) -> Option<Duration> {
1317        self.shared_target_maintenance
1318    }
1319
1320    /// Time spent executing Cargo, including an unsuccessful execution.
1321    #[must_use]
1322    pub const fn cargo_build(self) -> Option<Duration> {
1323        self.cargo_build
1324    }
1325
1326    /// Time spent validating or publishing artifacts, when reached.
1327    #[must_use]
1328    pub const fn artifact_publication(self) -> Option<Duration> {
1329        self.artifact_publication
1330    }
1331
1332    /// Time spent on exact-cache maintenance, when reached.
1333    #[must_use]
1334    pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1335        self.exact_cache_maintenance
1336    }
1337
1338    /// Explicit incomplete-entry cleanup time, when failure required it.
1339    #[must_use]
1340    pub const fn cleanup(self) -> Option<Duration> {
1341        self.cleanup
1342    }
1343
1344    /// Complete failed acquisition wall time.
1345    #[must_use]
1346    pub const fn total(self) -> Duration {
1347        self.total
1348    }
1349}
1350
1351impl CargoBuildInput {
1352    /// Stable checkout-independent label used while hashing this input.
1353    #[must_use]
1354    pub fn label(&self) -> &Path {
1355        &self.label
1356    }
1357
1358    /// Resolved file or directory read by the Cargo build.
1359    #[must_use]
1360    pub fn path(&self) -> &Path {
1361        &self.path
1362    }
1363}
1364
1365impl ResolvedCargoBuildInputs {
1366    /// Exact build fingerprint including Cargo inputs, tools, arguments, and environment.
1367    #[must_use]
1368    pub const fn fingerprint(&self) -> InputDigest {
1369        self.fingerprint
1370    }
1371
1372    /// Semantic digest of selected Cargo sources and workspace configuration.
1373    ///
1374    /// Unlike [`Self::validation_digest`], this may remain unchanged after an
1375    /// unrelated host-only workspace manifest or lockfile update.
1376    #[must_use]
1377    pub const fn input_digest(&self) -> InputDigest {
1378        self.input_digest
1379    }
1380
1381    /// Conservative digest of every raw source and configuration input.
1382    ///
1383    /// This digest is used for mutation guards. It may change while
1384    /// [`Self::input_digest`] and [`Self::fingerprint`] remain unchanged.
1385    #[must_use]
1386    pub const fn validation_digest(&self) -> InputDigest {
1387        self.validation_digest
1388    }
1389
1390    /// Stable logical labels and conservative resolved validation paths.
1391    #[must_use]
1392    pub fn inputs(&self) -> &[CargoBuildInput] {
1393        &self.inputs
1394    }
1395
1396    /// Generated-state roots excluded while recursively hashing local inputs.
1397    ///
1398    /// These exclusions are derived by `ic-testkit`; callers cannot add
1399    /// arbitrary exclusions through this snapshot.
1400    #[must_use]
1401    pub fn exclusions(&self) -> &[PathBuf] {
1402        &self.exclusions
1403    }
1404
1405    /// Timings for tool identity, metadata, discovery, and content hashing.
1406    #[must_use]
1407    pub const fn timings(&self) -> WasmInputResolutionTimings {
1408        self.timings
1409    }
1410
1411    /// Resolve `spec` again and report whether its exact identity is unchanged.
1412    pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1413        resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1414    }
1415
1416    /// Rehash the already discovered Cargo source/configuration set.
1417    ///
1418    /// This is cheaper than rerunning Cargo metadata and is intended for
1419    /// before/after guards around external artifact transformations. Resolve a
1420    /// new snapshot to observe tool, argument, environment, or dependency-graph
1421    /// identity changes between separate acquisitions.
1422    pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1423        self.current_validation_digest()
1424            .map(|current| current == self.validation_digest)
1425    }
1426
1427    pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1428        let inputs = self
1429            .inputs
1430            .iter()
1431            .map(|input| (input.label.clone(), input.path.clone()))
1432            .collect::<Vec<_>>();
1433        digest_labeled_paths_composable(
1434            "wasm-source-inputs-v1",
1435            &inputs,
1436            &self.exclusions,
1437            &mut LabeledPathDigestCache::default(),
1438        )
1439        .map_err(|source| WasmBuildError::Io {
1440            operation: "rehash resolved Cargo build inputs",
1441            path: self
1442                .inputs
1443                .first()
1444                .map_or_else(PathBuf::new, |input| input.path.clone()),
1445            source,
1446        })
1447    }
1448}
1449
1450impl WasmBuildSessionState {
1451    pub(super) fn new() -> Self {
1452        Self {
1453            snapshots: Vec::new(),
1454            digest_cache: LabeledPathDigestCache::default(),
1455            snapshot_reuses: 0,
1456            invalidated: false,
1457        }
1458    }
1459
1460    pub(super) const fn snapshot_count(&self) -> usize {
1461        self.snapshots.len()
1462    }
1463
1464    pub(super) const fn snapshot_reuses(&self) -> usize {
1465        self.snapshot_reuses
1466    }
1467
1468    pub(super) const fn is_invalidated(&self) -> bool {
1469        self.invalidated
1470    }
1471
1472    fn reuse(&mut self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1473        let (_, snapshot) = self
1474            .snapshots
1475            .iter()
1476            .find(|(candidate, _)| candidate == spec)?;
1477        self.snapshot_reuses = self.snapshot_reuses.saturating_add(1);
1478        let mut snapshot = snapshot.clone();
1479        snapshot.timings = WasmInputResolutionTimings::default();
1480        Some(snapshot)
1481    }
1482
1483    fn remember(&mut self, spec: &WasmBuildSpec, resolved: &ResolvedCargoBuildInputs) {
1484        if self
1485            .snapshots
1486            .iter()
1487            .any(|(candidate, _)| candidate == spec)
1488        {
1489            return;
1490        }
1491        let mut snapshot = resolved.clone();
1492        snapshot.timings = WasmInputResolutionTimings::default();
1493        self.snapshots.push((spec.clone(), snapshot));
1494    }
1495
1496    fn invalidate(&mut self) {
1497        self.snapshots.clear();
1498        self.digest_cache = LabeledPathDigestCache::default();
1499        self.invalidated = true;
1500    }
1501}
1502
1503impl WasmBuildInputSnapshotState {
1504    pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1505        for spec in specs {
1506            validate_spec(spec)?;
1507        }
1508        let mut resolver = WasmBuildBatchInputResolver::new(specs, None);
1509        let mut snapshots = Vec::with_capacity(specs.len());
1510        let mut preparation_timings = WasmInputResolutionTimings::default();
1511        let mut progress = ProgressReporter::silent();
1512        for (index, spec) in specs.iter().enumerate() {
1513            let mut prepared_input = resolver.resolve(index, &mut progress)?;
1514            preparation_timings.include(prepared_input.timings);
1515            prepared_input.timings = WasmInputResolutionTimings::default();
1516            snapshots.push((spec.clone(), prepared_input));
1517        }
1518        Ok(Self {
1519            snapshots,
1520            preparation_metrics: resolver.metrics(),
1521            preparation_timings,
1522            reader_reuses: AtomicUsize::new(0),
1523            invalidation: Arc::new(RwLock::new(false)),
1524        })
1525    }
1526
1527    pub(super) fn contains(&self, spec: &WasmBuildSpec) -> bool {
1528        self.snapshots
1529            .iter()
1530            .any(|(candidate, _)| candidate == spec)
1531    }
1532
1533    fn reuse(&self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1534        let (_, snapshot) = self
1535            .snapshots
1536            .iter()
1537            .find(|(candidate, _)| candidate == spec)?;
1538        let mut current = self.reader_reuses.load(Ordering::Relaxed);
1539        loop {
1540            match self.reader_reuses.compare_exchange_weak(
1541                current,
1542                current.saturating_add(1),
1543                Ordering::Relaxed,
1544                Ordering::Relaxed,
1545            ) {
1546                Ok(_) => break,
1547                Err(observed) => current = observed,
1548            }
1549        }
1550        Some(snapshot.clone())
1551    }
1552
1553    pub(super) const fn specification_count(&self) -> usize {
1554        self.snapshots.len()
1555    }
1556
1557    pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1558        self.preparation_metrics
1559    }
1560
1561    pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1562        self.preparation_timings
1563    }
1564
1565    pub(super) fn reader_reuses(&self) -> usize {
1566        self.reader_reuses.load(Ordering::Relaxed)
1567    }
1568
1569    pub(super) fn is_invalidated(&self) -> bool {
1570        *self
1571            .invalidation
1572            .read()
1573            .unwrap_or_else(std::sync::PoisonError::into_inner)
1574    }
1575
1576    fn invalidate(&self) {
1577        *self
1578            .invalidation
1579            .write()
1580            .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1581    }
1582
1583    fn invalidation(&self) -> Arc<RwLock<bool>> {
1584        Arc::clone(&self.invalidation)
1585    }
1586}
1587
1588impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1589    pub(super) fn new(
1590        specs: &'a [WasmBuildSpec],
1591        mut reuse: Option<WasmBuildInputReuse<'session>>,
1592    ) -> Self {
1593        let mut keys = Vec::<BatchResolutionKey>::new();
1594        let mut groups = Vec::<BatchResolutionGroup>::new();
1595        let mut group_by_index = Vec::with_capacity(specs.len());
1596        for (index, spec) in specs.iter().enumerate() {
1597            let key = BatchResolutionKey::for_spec(spec);
1598            let group = keys
1599                .iter()
1600                .position(|candidate| *candidate == key)
1601                .unwrap_or_else(|| {
1602                    keys.push(key);
1603                    groups.push(BatchResolutionGroup {
1604                        indexes: Vec::new(),
1605                    });
1606                    groups.len() - 1
1607                });
1608            groups[group].indexes.push(index);
1609            group_by_index.push(group);
1610        }
1611        let mut metrics = WasmBuildBatchInputMetrics::default();
1612        let resolved = specs
1613            .iter()
1614            .map(|spec| match reuse.as_mut() {
1615                Some(WasmBuildInputReuse::Session(session)) => {
1616                    let reused = session.reuse(spec);
1617                    if reused.is_some() {
1618                        metrics.session_reuses = metrics.session_reuses.saturating_add(1);
1619                    }
1620                    reused.map(Ok)
1621                }
1622                Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
1623                    let reused = snapshot
1624                        .reuse(spec)
1625                        .expect("prepared input snapshot must contain every reader specification");
1626                    metrics.prepared_reuses = metrics.prepared_reuses.saturating_add(1);
1627                    Some(Ok(reused))
1628                }
1629                None => None,
1630            })
1631            .collect();
1632        Self {
1633            specs,
1634            groups,
1635            group_by_index,
1636            resolved,
1637            reuse,
1638            metrics,
1639        }
1640    }
1641
1642    pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1643        self.metrics
1644    }
1645
1646    pub(super) fn invalidate_source_lease(&mut self) {
1647        match self.reuse.as_mut() {
1648            Some(WasmBuildInputReuse::Session(session)) => {
1649                session.invalidate();
1650                // Every unresolved entry was captured before the detected source race,
1651                // including entries resolved only for this batch. Force later entries
1652                // through fresh discovery instead of consuming a now-stale snapshot.
1653                for resolved in &mut self.resolved {
1654                    *resolved = None;
1655                }
1656                self.reuse = None;
1657            }
1658            Some(WasmBuildInputReuse::Snapshot(snapshot)) => snapshot.invalidate(),
1659            None => {}
1660        }
1661    }
1662
1663    pub(super) const fn assumes_sources_immutable(&self) -> bool {
1664        self.reuse.is_some()
1665    }
1666
1667    pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1668        match self.reuse.as_ref() {
1669            Some(WasmBuildInputReuse::Snapshot(snapshot)) => Some(snapshot.invalidation()),
1670            _ => None,
1671        }
1672    }
1673
1674    fn resolve(
1675        &mut self,
1676        index: usize,
1677        progress: &mut ProgressReporter<'_>,
1678    ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1679        if self.resolved[index].is_none() {
1680            self.resolve_group(index, progress)?;
1681        }
1682        self.resolved[index]
1683            .take()
1684            .expect("resolved batch input must be populated")
1685            .map_err(|(phase, error)| {
1686                progress.begin_phase(phase);
1687                error
1688            })
1689    }
1690
1691    fn resolve_group(
1692        &mut self,
1693        active_index: usize,
1694        progress: &mut ProgressReporter<'_>,
1695    ) -> Result<(), WasmBuildError> {
1696        let total_started = Instant::now();
1697        let indexes = self.groups[self.group_by_index[active_index]]
1698            .indexes
1699            .clone();
1700        let active = &self.specs[active_index];
1701
1702        let (cargo_identity, rustc_identity, tool_identity) =
1703            resolve_tool_identity(active, progress)?;
1704
1705        let metadata_started = Instant::now();
1706        let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1707            cargo_metadata(active)
1708        })?;
1709        let cargo_metadata = metadata_started.elapsed();
1710
1711        let (discovered, input_discovery) =
1712            self.discover_group_inputs(indexes, &metadata, progress);
1713
1714        let hashing_started = Instant::now();
1715        let mut batch_digest_cache = LabeledPathDigestCache::default();
1716        let digest_cache = match self.reuse.as_mut() {
1717            Some(WasmBuildInputReuse::Session(session)) => &mut session.digest_cache,
1718            _ => &mut batch_digest_cache,
1719        };
1720        let workspace_root = active.workspace_root.clone();
1721        let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1722            discovered
1723                .into_iter()
1724                .map(|(index, inputs, exclusions)| {
1725                    let result = digest_resolved_local_inputs(
1726                        &inputs,
1727                        &exclusions,
1728                        digest_cache,
1729                        &workspace_root,
1730                        "hash batched Wasm build inputs",
1731                        "hash batched semantic Wasm build inputs",
1732                    )
1733                    .map(|(input_digest, validation_digest)| {
1734                        (
1735                            inputs.validation_inputs,
1736                            exclusions,
1737                            input_digest,
1738                            validation_digest,
1739                        )
1740                    });
1741                    (index, result)
1742                })
1743                .collect::<Vec<_>>()
1744        });
1745        let content_hashing = hashing_started.elapsed();
1746        let timings = WasmInputResolutionTimings {
1747            tool_identity,
1748            cargo_metadata,
1749            input_discovery,
1750            content_hashing,
1751            total: total_started.elapsed(),
1752        };
1753        let resolved_count = resolved_inputs
1754            .iter()
1755            .filter(|(_, result)| result.is_ok())
1756            .count();
1757        self.metrics.runs += usize::from(resolved_count > 0);
1758        self.metrics.reuses += resolved_count.saturating_sub(1);
1759        let timing_index = resolved_inputs
1760            .iter()
1761            .find(|(index, result)| *index == active_index && result.is_ok())
1762            .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1763            .map(|(index, _)| *index);
1764        for (index, result) in resolved_inputs {
1765            let (inputs, exclusions, input_digest, validation_digest) = match result {
1766                Ok(resolved) => resolved,
1767                Err(error) => {
1768                    self.resolved[index] =
1769                        Some(Err((WasmBuildFailurePhase::ContentHashing, error)));
1770                    continue;
1771                }
1772            };
1773            let spec = &self.specs[index];
1774            let resolved = ResolvedCargoBuildInputs {
1775                fingerprint: finish_build_fingerprint(
1776                    spec,
1777                    &cargo_identity,
1778                    &rustc_identity,
1779                    input_digest,
1780                ),
1781                input_digest,
1782                validation_digest,
1783                inputs: inputs
1784                    .into_iter()
1785                    .map(|(label, path)| CargoBuildInput { label, path })
1786                    .collect(),
1787                exclusions,
1788                timings: if Some(index) == timing_index {
1789                    timings
1790                } else {
1791                    WasmInputResolutionTimings::default()
1792                },
1793            };
1794            if let Some(WasmBuildInputReuse::Session(session)) = self.reuse.as_mut() {
1795                session.remember(spec, &resolved);
1796            }
1797            self.resolved[index] = Some(Ok(resolved));
1798        }
1799        Ok(())
1800    }
1801
1802    fn discover_group_inputs(
1803        &mut self,
1804        indexes: Vec<usize>,
1805        metadata: &Value,
1806        progress: &mut ProgressReporter<'_>,
1807    ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1808        let started = Instant::now();
1809        let pending = indexes
1810            .into_iter()
1811            .filter(|index| {
1812                self.resolved[*index].is_none() && validate_spec(&self.specs[*index]).is_ok()
1813            })
1814            .collect::<Vec<_>>();
1815        let results = progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
1816            let parsed = ParsedCargoMetadata::parse(metadata);
1817            pending
1818                .into_iter()
1819                .map(|index| {
1820                    let spec = &self.specs[index];
1821                    let result = (|| {
1822                        let parsed = parsed
1823                            .as_ref()
1824                            .map_err(|message| invalid_metadata(message))?;
1825                        let inputs = resolve_local_inputs(spec, parsed)?;
1826                        validate_shared_incremental_target_boundary(
1827                            spec,
1828                            &inputs.validation_inputs,
1829                        )?;
1830                        let exclusions = source_exclusions(spec, &inputs.validation_inputs);
1831                        Ok::<_, WasmBuildError>((inputs, exclusions))
1832                    })();
1833                    (index, result)
1834                })
1835                .collect::<Vec<_>>()
1836        });
1837        let mut discovered = Vec::new();
1838        for (index, result) in results {
1839            match result {
1840                Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1841                Err(error) => {
1842                    self.resolved[index] =
1843                        Some(Err((WasmBuildFailurePhase::InputDiscovery, error)));
1844                }
1845            }
1846        }
1847        (discovered, started.elapsed())
1848    }
1849}
1850
1851fn resolve_tool_identity(
1852    spec: &WasmBuildSpec,
1853    progress: &mut ProgressReporter<'_>,
1854) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1855    let started = Instant::now();
1856    let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
1857        command_identity(
1858            spec,
1859            WasmBuildPhase::CargoIdentity,
1860            &spec.cargo_program,
1861            &["--version", "--verbose"],
1862        )
1863    })?;
1864    let rustc_program = spec
1865        .extra_env
1866        .get(OsStr::new("RUSTC"))
1867        .unwrap_or(&spec.rustc_program);
1868    let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
1869        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
1870    })?;
1871    Ok((cargo_identity, rustc_identity, started.elapsed()))
1872}
1873
1874impl BatchResolutionKey {
1875    fn for_spec(spec: &WasmBuildSpec) -> Self {
1876        Self {
1877            workspace_root: spec.workspace_root.clone(),
1878            cargo_program: spec.cargo_program.clone(),
1879            rustc_program: spec
1880                .extra_env
1881                .get(OsStr::new("RUSTC"))
1882                .unwrap_or(&spec.rustc_program)
1883                .clone(),
1884            metadata_arguments: metadata_arguments(&spec.cargo_profile_args),
1885            environment: effective_environment(spec),
1886        }
1887    }
1888}
1889
1890impl SharedIncrementalTargetInspection {
1891    /// Canonical shared Cargo target directory that was inspected.
1892    #[must_use]
1893    pub fn target_dir(&self) -> &Path {
1894        &self.target_dir
1895    }
1896
1897    /// Logical bytes currently occupied by the complete shared target.
1898    #[must_use]
1899    pub const fn logical_size_bytes(&self) -> u64 {
1900        self.logical_size_bytes
1901    }
1902
1903    /// Most recent build use recorded by `ic-testkit`, or the directory mtime for older targets.
1904    #[must_use]
1905    pub const fn last_used(&self) -> SystemTime {
1906        self.last_used
1907    }
1908
1909    /// Time spent waiting for another process using the shared target.
1910    #[must_use]
1911    pub const fn lock_wait(&self) -> Duration {
1912        self.lock_wait
1913    }
1914}
1915
1916impl SharedIncrementalTargetPrunePolicy {
1917    /// Create an explicit policy without a clearing threshold.
1918    #[must_use]
1919    pub const fn new() -> Self {
1920        Self {
1921            max_age: None,
1922            max_size_bytes: None,
1923        }
1924    }
1925
1926    /// Clear shared Cargo state when its recorded use is older than `max_age`.
1927    #[must_use]
1928    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
1929        self.max_age = Some(max_age);
1930        self
1931    }
1932
1933    /// Clear shared Cargo state when its logical size exceeds `bytes`.
1934    #[must_use]
1935    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
1936        self.max_size_bytes = Some(bytes);
1937        self
1938    }
1939
1940    /// Configured maximum time since recorded build use.
1941    #[must_use]
1942    pub const fn max_age(self) -> Option<Duration> {
1943        self.max_age
1944    }
1945
1946    /// Configured maximum logical target size.
1947    #[must_use]
1948    pub const fn max_size_bytes(self) -> Option<u64> {
1949        self.max_size_bytes
1950    }
1951
1952    fn maintenance_identity(self) -> String {
1953        format!(
1954            "age={:?};size={:?}",
1955            self.max_age.map(|duration| duration.as_nanos()),
1956            self.max_size_bytes
1957        )
1958    }
1959}
1960
1961impl SharedIncrementalTargetMaintenanceConfig {
1962    /// Schedule one strict retention pass at most once per interval.
1963    #[must_use]
1964    pub const fn new(
1965        policy: SharedIncrementalTargetPrunePolicy,
1966        minimum_interval: Duration,
1967    ) -> Self {
1968        Self {
1969            policy,
1970            minimum_interval,
1971            failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1972        }
1973    }
1974
1975    /// Select whether an integrated maintenance failure fails the acquisition.
1976    #[must_use]
1977    pub const fn with_failure_mode(
1978        mut self,
1979        failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1980    ) -> Self {
1981        self.failure_mode = failure_mode;
1982        self
1983    }
1984
1985    /// Configured whole-target retention policy.
1986    #[must_use]
1987    pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
1988        self.policy
1989    }
1990
1991    /// Minimum interval between successful matching maintenance passes.
1992    #[must_use]
1993    pub const fn minimum_interval(self) -> Duration {
1994        self.minimum_interval
1995    }
1996
1997    /// Configured maintenance failure handling.
1998    #[must_use]
1999    pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2000        self.failure_mode
2001    }
2002}
2003
2004impl SharedIncrementalTargetMaintenance {
2005    /// Canonical shared Cargo target directory maintained under lock.
2006    #[must_use]
2007    pub fn target_dir(&self) -> &Path {
2008        &self.target_dir
2009    }
2010
2011    /// Logical bytes observed before applying the policy.
2012    #[must_use]
2013    pub const fn logical_size_bytes_before(&self) -> u64 {
2014        self.logical_size_bytes_before
2015    }
2016
2017    /// Logical bytes retained after applying the policy.
2018    #[must_use]
2019    pub const fn logical_size_bytes_after(&self) -> u64 {
2020        self.logical_size_bytes_after
2021    }
2022
2023    /// Most recent build use observed before applying the policy.
2024    #[must_use]
2025    pub const fn last_used_before(&self) -> SystemTime {
2026        self.last_used_before
2027    }
2028
2029    /// Whether a configured limit caused the mutable target contents to be cleared.
2030    #[must_use]
2031    pub const fn was_cleared(&self) -> bool {
2032        self.cleared
2033    }
2034
2035    /// Time spent waiting for another process using the shared target.
2036    #[must_use]
2037    pub const fn lock_wait(&self) -> Duration {
2038        self.lock_wait
2039    }
2040
2041    /// Time spent measuring and, when required, clearing the target.
2042    #[must_use]
2043    pub const fn maintenance(&self) -> Duration {
2044        self.maintenance
2045    }
2046}
2047
2048impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2049    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2050        write!(
2051            formatter,
2052            "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2053            self.target_dir.display(),
2054            if self.cleared { "cleared" } else { "retained" },
2055            self.logical_size_bytes_before,
2056            self.logical_size_bytes_after,
2057            self.lock_wait,
2058            self.maintenance,
2059        )
2060    }
2061}
2062
2063impl SharedIncrementalTargetMaintenanceOutcome {
2064    /// Configured or canonical target associated with this result.
2065    #[must_use]
2066    pub fn target_dir(&self) -> &Path {
2067        match self {
2068            Self::Missing { target_dir }
2069            | Self::Skipped { target_dir, .. }
2070            | Self::Failed { target_dir, .. } => target_dir,
2071            Self::Performed { maintenance, .. } => maintenance.target_dir(),
2072        }
2073    }
2074
2075    /// Completed maintenance report, when retention was evaluated.
2076    #[must_use]
2077    pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2078        match self {
2079            Self::Performed { maintenance, .. } => Some(maintenance),
2080            Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2081        }
2082    }
2083
2084    /// Whether retention was evaluated during this call.
2085    #[must_use]
2086    pub const fn was_performed(&self) -> bool {
2087        matches!(self, Self::Performed { .. })
2088    }
2089
2090    /// Time spent waiting for another process, when the target existed.
2091    #[must_use]
2092    pub const fn lock_wait(&self) -> Option<Duration> {
2093        match self {
2094            Self::Missing { .. } => None,
2095            Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2096            Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2097        }
2098    }
2099
2100    /// Time spent checking the schedule marker, when the target existed.
2101    #[must_use]
2102    pub const fn schedule_check(&self) -> Option<Duration> {
2103        match self {
2104            Self::Missing { .. } | Self::Failed { .. } => None,
2105            Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2106                Some(*schedule_check)
2107            }
2108        }
2109    }
2110
2111    /// Rendered integrated maintenance failure, when best-effort handling preserved acquisition.
2112    #[must_use]
2113    pub fn failure_message(&self) -> Option<&str> {
2114        match self {
2115            Self::Failed { message, .. } => Some(message),
2116            Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2117        }
2118    }
2119}
2120
2121impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2123        match self {
2124            Self::Missing { target_dir } => {
2125                write!(formatter, "target={} action=missing", target_dir.display())
2126            }
2127            Self::Skipped {
2128                target_dir,
2129                lock_wait,
2130                schedule_check,
2131            } => write!(
2132                formatter,
2133                "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2134                target_dir.display(),
2135            ),
2136            Self::Performed {
2137                maintenance,
2138                schedule_check,
2139            } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2140            Self::Failed {
2141                target_dir,
2142                lock_wait,
2143                message,
2144            } => write!(
2145                formatter,
2146                "target={} action=failed lock={lock_wait:?} error={message}",
2147                target_dir.display(),
2148            ),
2149        }
2150    }
2151}
2152
2153impl std::fmt::Display for WasmBuildTimings {
2154    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2155        write!(
2156            formatter,
2157            "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2158            self.total,
2159            self.lock_wait,
2160            self.shared_incremental_lock_wait,
2161            self.input_resolution.total,
2162            self.cargo_build,
2163            self.cache_maintenance,
2164        )
2165    }
2166}
2167
2168impl std::fmt::Display for WasmBuildOutcome {
2169    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2170        let state = if self.is_reused() { "reused" } else { "built" };
2171        write!(
2172            formatter,
2173            "{state} fingerprint={} artifacts={} {}",
2174            self.record().fingerprint,
2175            self.record().artifacts.len(),
2176            self.record().timings,
2177        )?;
2178        if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2179            write!(formatter, " shared_maintenance=({maintenance})")?;
2180        }
2181        Ok(())
2182    }
2183}
2184
2185/// Resolve the exact Cargo source, configuration, toolchain, argument, and environment identity.
2186///
2187/// This performs the same resolution used before and after cached Wasm builds
2188/// without running `cargo build`.
2189pub fn resolve_cargo_build_inputs(
2190    spec: &WasmBuildSpec,
2191) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2192    validate_spec(spec)?;
2193    build_fingerprint(spec)
2194}
2195
2196/// Inspect one configured shared Cargo target under its build coordination lock.
2197///
2198/// Returns `None` without creating anything when the caller-owned target does
2199/// not exist. This operation never removes Cargo state.
2200pub fn inspect_shared_incremental_target(
2201    spec: &WasmBuildSpec,
2202) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2203    if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2204        return Ok(None);
2205    }
2206
2207    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2208    let logical_size_bytes =
2209        directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2210            operation: "measure shared incremental Cargo target",
2211            path: canonical.clone(),
2212            source,
2213        })?;
2214    let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2215        operation: "read shared incremental Cargo target use time",
2216        path: canonical.clone(),
2217        source,
2218    })?;
2219    Ok(Some(SharedIncrementalTargetInspection {
2220        target_dir: canonical,
2221        logical_size_bytes,
2222        last_used,
2223        lock_wait,
2224    }))
2225}
2226
2227/// Apply explicit whole-target retention to caller-owned shared Cargo state.
2228///
2229/// Returns `None` without creating anything when the target does not exist.
2230/// Policy evaluation and any clearing occur under the same cross-process lock
2231/// used by shared-incremental builds. The target root, `CACHEDIR.TAG`, and
2232/// `.ic-testkit` lock metadata are preserved, so another process cannot enter
2233/// through a replacement lock while maintenance is active.
2234/// Every other target child is removed when a limit is exceeded; unrelated
2235/// data that must survive must not be colocated there. Exact Cargo input
2236/// resolution first rejects targets overlapping source or configuration.
2237///
2238/// This function is never called automatically by exact Wasm acquisitions.
2239/// Consumers retain ownership of when mutable incremental state may be lost.
2240pub fn maintain_shared_incremental_target(
2241    spec: &WasmBuildSpec,
2242    policy: SharedIncrementalTargetPrunePolicy,
2243) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2244    if !shared_incremental_target_exists(
2245        spec,
2246        "inspect shared incremental Cargo target before maintenance",
2247    )? {
2248        return Ok(None);
2249    }
2250
2251    // Reuse the exact build resolver so destructive maintenance cannot act on
2252    // a target that overlaps Cargo sources, configuration, or additional
2253    // inputs. The target itself is excluded as generated state during hashing.
2254    let _ = resolve_cargo_build_inputs(spec)?;
2255    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2256    maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2257}
2258
2259/// Apply whole-target retention at most once per interval across processes.
2260///
2261/// The schedule marker is checked under the same lock used by shared Cargo
2262/// builds. A matching successful pass inside `minimum_interval` returns
2263/// [`SharedIncrementalTargetMaintenanceOutcome::Skipped`] without resolving
2264/// Cargo inputs or traversing the target. Missing targets are not created.
2265/// Changing the policy makes maintenance immediately due, and a zero interval
2266/// always evaluates retention.
2267///
2268/// Due maintenance performs exact Cargo input resolution before inspecting or
2269/// clearing the target. Failures are returned and are not recorded as a
2270/// successful pass, so an unsafe configuration cannot be hidden by the
2271/// schedule.
2272pub fn maintain_shared_incremental_target_at_most_every(
2273    spec: &WasmBuildSpec,
2274    policy: SharedIncrementalTargetPrunePolicy,
2275    minimum_interval: Duration,
2276) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2277    let target_dir =
2278        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2279            message: "shared incremental target is not configured".to_owned(),
2280        })?;
2281    if !shared_incremental_target_exists(
2282        spec,
2283        "inspect shared incremental Cargo target before scheduled maintenance",
2284    )? {
2285        return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2286    }
2287
2288    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2289    let schedule = schedule_shared_incremental_target_maintenance(
2290        &canonical,
2291        policy,
2292        minimum_interval,
2293        lock_wait,
2294    )?;
2295    let schedule = match schedule {
2296        SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2297        SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2298    };
2299
2300    // Keep the schedule decision and maintenance in one critical section so
2301    // concurrent test binaries cannot all perform the same expensive scan.
2302    let _ = resolve_cargo_build_inputs(spec)?;
2303    perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2304}
2305
2306enum SharedIncrementalTargetMaintenanceSchedule {
2307    Skipped(SharedIncrementalTargetMaintenanceOutcome),
2308    Due(DueSharedIncrementalTargetMaintenance),
2309}
2310
2311struct DueSharedIncrementalTargetMaintenance {
2312    schedule_root: PathBuf,
2313    maintenance_identity: String,
2314    schedule_check: Duration,
2315}
2316
2317fn schedule_shared_incremental_target_maintenance(
2318    canonical: &Path,
2319    policy: SharedIncrementalTargetPrunePolicy,
2320    minimum_interval: Duration,
2321    lock_wait: Duration,
2322) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2323    let schedule_root = canonical.join(".ic-testkit");
2324    let maintenance_identity = policy.maintenance_identity();
2325    let schedule_started = Instant::now();
2326    let due = cache_maintenance_due(
2327        &schedule_root,
2328        Some(minimum_interval),
2329        &maintenance_identity,
2330    )
2331    .map_err(wasm_cache_fs_error)?;
2332    let schedule_check = schedule_started.elapsed();
2333    if !due {
2334        return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2335            SharedIncrementalTargetMaintenanceOutcome::Skipped {
2336                target_dir: canonical.to_owned(),
2337                lock_wait,
2338                schedule_check,
2339            },
2340        ));
2341    }
2342    Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2343        DueSharedIncrementalTargetMaintenance {
2344            schedule_root,
2345            maintenance_identity,
2346            schedule_check,
2347        },
2348    ))
2349}
2350
2351fn perform_due_shared_incremental_target_maintenance(
2352    canonical: &Path,
2353    policy: SharedIncrementalTargetPrunePolicy,
2354    lock_wait: Duration,
2355    due: DueSharedIncrementalTargetMaintenance,
2356) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2357    let DueSharedIncrementalTargetMaintenance {
2358        schedule_root,
2359        maintenance_identity,
2360        schedule_check,
2361    } = due;
2362    let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2363    record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2364    Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2365        maintenance,
2366        schedule_check,
2367    })
2368}
2369
2370fn maintain_shared_incremental_target_locked(
2371    canonical: &Path,
2372    policy: SharedIncrementalTargetPrunePolicy,
2373    lock_wait: Duration,
2374) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2375    let started = Instant::now();
2376    let logical_size_bytes_before =
2377        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2378            operation: "measure shared incremental Cargo target before maintenance",
2379            path: canonical.to_owned(),
2380            source,
2381        })?;
2382    let last_used_before =
2383        cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2384            operation: "read shared incremental Cargo target use time before maintenance",
2385            path: canonical.to_owned(),
2386            source,
2387        })?;
2388    let expired = policy.max_age.is_some_and(|max_age| {
2389        SystemTime::now()
2390            .duration_since(last_used_before)
2391            .is_ok_and(|age| age > max_age)
2392    });
2393    let oversized = policy
2394        .max_size_bytes
2395        .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2396    let cleared = expired || oversized;
2397    if cleared {
2398        clear_shared_incremental_target_contents(canonical)?;
2399        record_cache_entry_use(canonical)?;
2400    }
2401    let logical_size_bytes_after = if cleared {
2402        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2403            operation: "measure shared incremental Cargo target after maintenance",
2404            path: canonical.to_owned(),
2405            source,
2406        })?
2407    } else {
2408        logical_size_bytes_before
2409    };
2410    Ok(SharedIncrementalTargetMaintenance {
2411        target_dir: canonical.to_owned(),
2412        logical_size_bytes_before,
2413        logical_size_bytes_after,
2414        last_used_before,
2415        cleared,
2416        lock_wait,
2417        maintenance: started.elapsed(),
2418    })
2419}
2420
2421fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2422    let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2423        operation: "read shared incremental Cargo target for maintenance",
2424        path: target_dir.to_owned(),
2425        source,
2426    })?;
2427    for entry in entries {
2428        let path = entry
2429            .map_err(|source| WasmBuildError::Io {
2430                operation: "read shared incremental Cargo target entry for maintenance",
2431                path: target_dir.to_owned(),
2432                source,
2433            })?
2434            .path();
2435        let preserved = path
2436            .file_name()
2437            .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2438        if !preserved {
2439            remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2440                operation: "clear shared incremental Cargo target entry",
2441                path,
2442                source,
2443            })?;
2444        }
2445    }
2446    Ok(())
2447}
2448
2449/// Build or reuse one exact set of Cargo Wasm artifacts.
2450///
2451/// The operation takes an exclusive process lock scoped to `target_dir`, then
2452/// fingerprints all declared inputs. A cache hit requires both a matching
2453/// atomic stamp and every expected nonempty Wasm output. Ordinary warm hits
2454/// revalidate inputs before returning and reject mutations during acquisition.
2455/// Explicit immutable-source sessions and prepared readers skip that warm
2456/// revalidation under their source-lease contract. Failed or interrupted
2457/// builds never publish a successful stamp.
2458pub fn build_wasm_canisters_cached(
2459    spec: &WasmBuildSpec,
2460) -> Result<WasmBuildOutcome, WasmBuildError> {
2461    build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
2462}
2463
2464pub(super) fn build_wasm_canisters_cached_in_batch(
2465    spec: &WasmBuildSpec,
2466    index: usize,
2467    resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2468) -> WasmBuildBatchAttempt {
2469    let started = Instant::now();
2470    let mut progress = ProgressReporter::silent();
2471    let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2472    if result
2473        .as_ref()
2474        .is_err_and(WasmBuildError::indicates_input_change)
2475    {
2476        resolver.invalidate_source_lease();
2477    }
2478    batch_result(result, &progress, started.elapsed())
2479}
2480
2481/// Build or reuse one exact Wasm set while streaming structured progress.
2482///
2483/// Cargo output remains captured for [`WasmBuildError::CommandFailed`] and is
2484/// additionally forwarded as raw chunks when enabled. Potentially long input
2485/// resolution, lock waits, maintenance, Cargo, and publication phases emit
2486/// periodic heartbeats, so a legitimate acquisition need not appear stalled.
2487/// Observer panics propagate after joining active phase work, terminating the
2488/// Cargo child when applicable, and preserving normal cleanup.
2489pub fn build_wasm_canisters_cached_with_progress<F>(
2490    spec: &WasmBuildSpec,
2491    config: WasmBuildProgressConfig,
2492    mut observer: F,
2493) -> Result<WasmBuildOutcome, WasmBuildError>
2494where
2495    F: FnMut(WasmBuildProgressEvent),
2496{
2497    if config.heartbeat_interval == Some(Duration::ZERO) {
2498        return Err(WasmBuildError::InvalidSpec {
2499            message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
2500        });
2501    }
2502    build_wasm_canisters_cached_internal(
2503        spec,
2504        &mut ProgressReporter::observed(config, &mut observer),
2505        None,
2506    )
2507}
2508
2509pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
2510    spec: &WasmBuildSpec,
2511    index: usize,
2512    resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2513    config: WasmBuildProgressConfig,
2514    mut observer: F,
2515) -> WasmBuildBatchAttempt
2516where
2517    F: FnMut(WasmBuildProgressEvent),
2518{
2519    if config.heartbeat_interval == Some(Duration::ZERO) {
2520        return WasmBuildBatchAttempt {
2521            result: Err((
2522                WasmBuildError::InvalidSpec {
2523                    message: "Wasm build progress heartbeat interval must be greater than zero"
2524                        .to_owned(),
2525                },
2526                WasmBuildFailureDetails::specification(Duration::ZERO),
2527            )),
2528        };
2529    }
2530    let started = Instant::now();
2531    let mut progress = ProgressReporter::observed(config, &mut observer);
2532    let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2533    if result
2534        .as_ref()
2535        .is_err_and(WasmBuildError::indicates_input_change)
2536    {
2537        resolver.invalidate_source_lease();
2538    }
2539    batch_result(result, &progress, started.elapsed())
2540}
2541
2542fn batch_result(
2543    result: Result<WasmBuildOutcome, WasmBuildError>,
2544    progress: &ProgressReporter<'_>,
2545    total: Duration,
2546) -> WasmBuildBatchAttempt {
2547    WasmBuildBatchAttempt {
2548        result: result.map_err(|error| {
2549            let details = progress.failure_details(&error, total);
2550            (error, details)
2551        }),
2552    }
2553}
2554
2555fn batch_source_assumptions(
2556    batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2557) -> (bool, Option<Arc<RwLock<bool>>>) {
2558    batch_resolution.map_or((false, None), |(resolver, _)| {
2559        (
2560            resolver.assumes_sources_immutable(),
2561            resolver.prepared_invalidation(),
2562        )
2563    })
2564}
2565
2566fn build_wasm_canisters_cached_internal(
2567    spec: &WasmBuildSpec,
2568    progress: &mut ProgressReporter<'_>,
2569    mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2570) -> Result<WasmBuildOutcome, WasmBuildError> {
2571    let total_started = Instant::now();
2572    validate_spec(spec)?;
2573    let (assumes_sources_immutable, prepared_invalidation) =
2574        batch_source_assumptions(batch_resolution.as_ref());
2575    progress.emit(WasmBuildProgressEvent::Started);
2576    if spec.shared_incremental_maintenance_config.is_some() {
2577        let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2578            spec,
2579            total_started,
2580            progress,
2581            batch_resolution.take(),
2582        )?;
2583        emit_finished_progress(&outcome, progress);
2584        return Ok(outcome);
2585    }
2586    let (cache_lock, first_lock_wait) =
2587        lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2588    ensure_cache_directory_tag(&spec.target_dir)?;
2589
2590    let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2591    let isolated_acquisition =
2592        WasmAcquisitionContext::isolated(prepared_invalidation.clone(), assumes_sources_immutable);
2593    if let Some(outcome) = try_reuse_wasm_artifacts(
2594        spec,
2595        &resolved,
2596        first_lock_wait,
2597        &isolated_acquisition,
2598        total_started,
2599        progress,
2600    )? {
2601        emit_finished_progress(&outcome, progress);
2602        return Ok(outcome);
2603    }
2604    progress.emit(WasmBuildProgressEvent::CacheMiss {
2605        fingerprint: resolved.fingerprint,
2606    });
2607
2608    let outcome = match &spec.cache_mode {
2609        WasmBuildCacheMode::Isolated => {
2610            let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2611            build_wasm_cache_miss(
2612                spec,
2613                resolved,
2614                first_lock_wait,
2615                isolated_acquisition,
2616                cache_entry,
2617                total_started,
2618                progress,
2619            )
2620        }
2621        WasmBuildCacheMode::SharedIncremental { .. } => {
2622            drop(cache_lock);
2623            build_wasm_with_shared_incremental(
2624                spec,
2625                resolved,
2626                first_lock_wait,
2627                assumes_sources_immutable,
2628                prepared_invalidation,
2629                total_started,
2630                progress,
2631            )
2632        }
2633    }?;
2634    emit_finished_progress(&outcome, progress);
2635    Ok(outcome)
2636}
2637
2638fn build_wasm_with_shared_incremental(
2639    spec: &WasmBuildSpec,
2640    resolved: ResolvedCargoBuildInputs,
2641    first_lock_wait: Duration,
2642    assumes_sources_immutable: bool,
2643    prepared_invalidation: Option<Arc<RwLock<bool>>>,
2644    total_started: Instant,
2645    progress: &mut ProgressReporter<'_>,
2646) -> Result<WasmBuildOutcome, WasmBuildError> {
2647    let (shared_lock, shared_lock_wait, shared_target) =
2648        lock_shared_incremental_target_with_progress(spec, progress)?;
2649    let (_cache_lock, second_lock_wait) =
2650        lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2651    ensure_cache_directory_tag(&spec.target_dir)?;
2652
2653    let current = if assumes_sources_immutable {
2654        resolved
2655    } else {
2656        let mut current = resolve_inputs_with_progress(spec, progress)?;
2657        current.timings.include(resolved.timings);
2658        current
2659    };
2660    let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2661    let shared_incremental = WasmAcquisitionContext::shared(
2662        shared_lock_wait,
2663        None,
2664        prepared_invalidation,
2665        assumes_sources_immutable,
2666    );
2667    if let Some(outcome) = try_reuse_wasm_artifacts(
2668        spec,
2669        &current,
2670        lock_wait,
2671        &shared_incremental,
2672        total_started,
2673        progress,
2674    )? {
2675        return Ok(outcome);
2676    }
2677
2678    let outcome = build_wasm_cache_miss(
2679        spec,
2680        current,
2681        lock_wait,
2682        shared_incremental,
2683        shared_target,
2684        total_started,
2685        progress,
2686    );
2687    drop(shared_lock);
2688    outcome
2689}
2690
2691fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2692    spec: &WasmBuildSpec,
2693    total_started: Instant,
2694    progress: &mut ProgressReporter<'_>,
2695    batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2696) -> Result<WasmBuildOutcome, WasmBuildError> {
2697    let (assumes_sources_immutable, prepared_invalidation) =
2698        batch_source_assumptions(batch_resolution.as_ref());
2699    let (_shared_lock, shared_lock_wait, shared_target) =
2700        lock_shared_incremental_target_with_progress(spec, progress)?;
2701    let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2702    ensure_cache_directory_tag(&spec.target_dir)?;
2703
2704    // Resolution under both locks proves the target boundary once for the
2705    // scheduled retention pass and the following exact-cache acquisition.
2706    let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2707    let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2708        spec,
2709        &shared_target,
2710        shared_lock_wait,
2711        progress,
2712    )?;
2713    let shared_incremental = WasmAcquisitionContext::shared(
2714        shared_lock_wait,
2715        Some(shared_maintenance),
2716        prepared_invalidation,
2717        assumes_sources_immutable,
2718    );
2719    if let Some(outcome) = try_reuse_wasm_artifacts(
2720        spec,
2721        &resolved,
2722        lock_wait,
2723        &shared_incremental,
2724        total_started,
2725        progress,
2726    )? {
2727        return Ok(outcome);
2728    }
2729    progress.emit(WasmBuildProgressEvent::CacheMiss {
2730        fingerprint: resolved.fingerprint,
2731    });
2732    build_wasm_cache_miss(
2733        spec,
2734        resolved,
2735        lock_wait,
2736        shared_incremental,
2737        shared_target,
2738        total_started,
2739        progress,
2740    )
2741}
2742
2743fn perform_configured_shared_incremental_target_maintenance(
2744    spec: &WasmBuildSpec,
2745    shared_target: &Path,
2746    lock_wait: Duration,
2747    progress: &mut ProgressReporter<'_>,
2748) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2749    let config = spec
2750        .shared_incremental_maintenance_config
2751        .expect("configured shared-target maintenance must have settings");
2752    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2753        target_dir: shared_target.to_owned(),
2754    });
2755    let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2756        let schedule = schedule_shared_incremental_target_maintenance(
2757            shared_target,
2758            config.policy,
2759            config.minimum_interval,
2760            lock_wait,
2761        )?;
2762        match schedule {
2763            SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2764            SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2765                perform_due_shared_incremental_target_maintenance(
2766                    shared_target,
2767                    config.policy,
2768                    lock_wait,
2769                    due,
2770                )
2771            }
2772        }
2773    });
2774    let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2775    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2776        outcome: outcome.clone(),
2777    });
2778    Ok(outcome)
2779}
2780
2781fn integrated_shared_maintenance_result(
2782    config: SharedIncrementalTargetMaintenanceConfig,
2783    shared_target: &Path,
2784    lock_wait: Duration,
2785    result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2786) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2787    match result {
2788        Ok(outcome) => Ok(outcome),
2789        Err(error)
2790            if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2791        {
2792            Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2793                target_dir: shared_target.to_owned(),
2794                lock_wait,
2795                message: error.to_string(),
2796            })
2797        }
2798        Err(error) => Err(error),
2799    }
2800}
2801
2802fn resolve_inputs_with_progress(
2803    spec: &WasmBuildSpec,
2804    progress: &mut ProgressReporter<'_>,
2805) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2806    let resolved = build_fingerprint_with_progress(spec, progress)?;
2807    progress.emit(WasmBuildProgressEvent::InputsResolved {
2808        fingerprint: resolved.fingerprint,
2809        input_digest: resolved.input_digest,
2810        elapsed: resolved.timings.total,
2811    });
2812    Ok(resolved)
2813}
2814
2815fn resolve_initial_inputs(
2816    spec: &WasmBuildSpec,
2817    batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2818    progress: &mut ProgressReporter<'_>,
2819) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2820    let resolved = if let Some((resolver, index)) = batch_resolution {
2821        resolver.resolve(index, progress)?
2822    } else {
2823        build_fingerprint_with_progress(spec, progress)?
2824    };
2825    progress.emit(WasmBuildProgressEvent::InputsResolved {
2826        fingerprint: resolved.fingerprint,
2827        input_digest: resolved.input_digest,
2828        elapsed: resolved.timings.total,
2829    });
2830    Ok(resolved)
2831}
2832
2833fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2834    let state = if outcome.is_reused() {
2835        progress.emit(WasmBuildProgressEvent::CacheHit {
2836            fingerprint: outcome.record().fingerprint,
2837        });
2838        WasmBuildProgressOutcome::Reused
2839    } else {
2840        WasmBuildProgressOutcome::Built
2841    };
2842    progress.emit(WasmBuildProgressEvent::Finished {
2843        outcome: state,
2844        fingerprint: outcome.record().fingerprint,
2845        elapsed: outcome.record().timings.total,
2846    });
2847}
2848
2849#[derive(Clone, Debug, Default)]
2850struct WasmAcquisitionContext {
2851    assumes_sources_immutable: bool,
2852    lock_wait: Option<Duration>,
2853    maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2854    prepared_invalidation: Option<Arc<RwLock<bool>>>,
2855}
2856
2857impl WasmAcquisitionContext {
2858    fn isolated(
2859        prepared_invalidation: Option<Arc<RwLock<bool>>>,
2860        assumes_sources_immutable: bool,
2861    ) -> Self {
2862        Self {
2863            assumes_sources_immutable,
2864            prepared_invalidation,
2865            ..Self::default()
2866        }
2867    }
2868
2869    const fn shared(
2870        lock_wait: Duration,
2871        maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2872        prepared_invalidation: Option<Arc<RwLock<bool>>>,
2873        assumes_sources_immutable: bool,
2874    ) -> Self {
2875        Self {
2876            assumes_sources_immutable,
2877            lock_wait: Some(lock_wait),
2878            maintenance,
2879            prepared_invalidation,
2880        }
2881    }
2882
2883    fn lock_prepared_publication(
2884        &self,
2885    ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2886        let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2887            invalidation
2888                .read()
2889                .unwrap_or_else(std::sync::PoisonError::into_inner)
2890        });
2891        if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2892            return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2893        }
2894        Ok(guard)
2895    }
2896}
2897
2898fn try_reuse_wasm_artifacts(
2899    spec: &WasmBuildSpec,
2900    resolved: &ResolvedCargoBuildInputs,
2901    lock_wait: Duration,
2902    shared_incremental: &WasmAcquisitionContext,
2903    total_started: Instant,
2904    progress: &mut ProgressReporter<'_>,
2905) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2906    let _publication_guard = shared_incremental.lock_prepared_publication()?;
2907    let fingerprint = resolved.fingerprint;
2908    let artifacts = expected_artifacts(spec, &spec.target_dir);
2909    let cache_entry = cache_entry_directory(spec, fingerprint);
2910    let artifacts_match = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2911        artifact_set_matches(&artifacts, fingerprint)
2912    });
2913    if artifacts_match {
2914        ensure_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint, progress)?;
2915    } else {
2916        let cached_artifacts = expected_artifacts(spec, &cache_entry);
2917        let cached_artifacts_match = progress
2918            .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2919                artifact_set_matches(&cached_artifacts, fingerprint)
2920            });
2921        if !cached_artifacts_match {
2922            return Ok(None);
2923        }
2924        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2925            materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
2926            record_cache_entry_use(&cache_entry)
2927        })?;
2928    }
2929    let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
2930    Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2931        spec,
2932        BuildRecordInput {
2933            fingerprint,
2934            input_digest: resolved.input_digest,
2935            lock_wait,
2936            shared_incremental: shared_incremental.clone(),
2937            input_resolution,
2938            cargo_build: None,
2939            active_entry: &cache_entry,
2940        },
2941        total_started,
2942        progress,
2943    )?)))
2944}
2945
2946fn validate_reused_inputs(
2947    spec: &WasmBuildSpec,
2948    resolved: &ResolvedCargoBuildInputs,
2949    context: &WasmAcquisitionContext,
2950    progress: &mut ProgressReporter<'_>,
2951) -> Result<WasmInputResolutionTimings, WasmBuildError> {
2952    let mut timings = resolved.timings;
2953    if !context.assumes_sources_immutable {
2954        let verified = verify_resolved_inputs(spec, resolved, progress)?;
2955        timings.include(verified.timings);
2956    }
2957    Ok(timings)
2958}
2959
2960fn verify_resolved_inputs(
2961    spec: &WasmBuildSpec,
2962    resolved: &ResolvedCargoBuildInputs,
2963    progress: &mut ProgressReporter<'_>,
2964) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2965    let verified = resolve_inputs_with_progress(spec, progress)?;
2966    for (before, after) in [
2967        (resolved.validation_digest, verified.validation_digest),
2968        (resolved.fingerprint, verified.fingerprint),
2969    ] {
2970        if before != after {
2971            return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
2972        }
2973    }
2974    Ok(verified)
2975}
2976
2977fn ensure_exact_cache_entry(
2978    spec: &WasmBuildSpec,
2979    artifacts: &[PathBuf],
2980    cache_entry: &Path,
2981    fingerprint: InputDigest,
2982    progress: &mut ProgressReporter<'_>,
2983) -> Result<(), WasmBuildError> {
2984    let cached_artifacts = expected_artifacts(spec, cache_entry);
2985    let entry_is_current =
2986        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2987            if artifact_set_matches(&cached_artifacts, fingerprint) {
2988                record_cache_entry_use(cache_entry)?;
2989                Ok::<_, WasmBuildError>(true)
2990            } else {
2991                Ok(false)
2992            }
2993        })?;
2994    if entry_is_current {
2995        return Ok(());
2996    }
2997    progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2998        remove_unretained_entry(cache_entry).map_err(wasm_cache_fs_error)?;
2999        create_dir_all(
3000            cache_entry,
3001            "create content-addressed Cargo target directory",
3002        )
3003    })?;
3004    let incomplete = IncompleteBuildDirectory::new(cache_entry.to_owned());
3005    let result = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3006        copy_wasm_artifacts(artifacts, &cached_artifacts)?;
3007        publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3008        record_cache_entry_use(cache_entry)
3009    });
3010    match result {
3011        Ok(()) => {
3012            incomplete.preserve();
3013            Ok(())
3014        }
3015        Err(build_error) => Err(cleanup_failed_fingerprint_build(
3016            build_error,
3017            incomplete,
3018            progress,
3019        )),
3020    }
3021}
3022
3023fn build_wasm_cache_miss(
3024    spec: &WasmBuildSpec,
3025    resolved: ResolvedCargoBuildInputs,
3026    lock_wait: Duration,
3027    shared_incremental: WasmAcquisitionContext,
3028    cargo_target_dir: PathBuf,
3029    total_started: Instant,
3030    progress: &mut ProgressReporter<'_>,
3031) -> Result<WasmBuildOutcome, WasmBuildError> {
3032    let fingerprint = resolved.fingerprint;
3033    let mut input_resolution = resolved.timings;
3034    let artifacts = expected_artifacts(spec, &spec.target_dir);
3035    let cache_entry = cache_entry_directory(spec, fingerprint);
3036    let preparation_started = Instant::now();
3037    progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3038    let preparation_result = (|| {
3039        remove_unretained_entry(&cache_entry).map_err(wasm_cache_fs_error)?;
3040        create_dir_all(
3041            &cache_entry,
3042            "create content-addressed Cargo target directory",
3043        )
3044    })();
3045    progress.record_phase(
3046        WasmBuildFailurePhase::ArtifactPublication,
3047        preparation_started.elapsed(),
3048    );
3049    preparation_result?;
3050    let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
3051    let build_result = (|| {
3052        if matches!(
3053            spec.cache_mode,
3054            WasmBuildCacheMode::SharedIncremental { .. }
3055        ) {
3056            record_cache_entry_use(&cargo_target_dir)?;
3057        }
3058        let build_started = Instant::now();
3059        progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3060        let cargo_result = run_cargo_build(spec, &cargo_target_dir, progress);
3061        let cargo_build = build_started.elapsed();
3062        progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3063        cargo_result?;
3064        let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
3065        let validation_started = Instant::now();
3066        progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3067        let missing = missing_artifacts(&built_artifacts);
3068        progress.record_phase(
3069            WasmBuildFailurePhase::ArtifactPublication,
3070            validation_started.elapsed(),
3071        );
3072        if !missing.is_empty() {
3073            return Err(WasmBuildError::MissingArtifacts { paths: missing });
3074        }
3075
3076        let verified = verify_resolved_inputs(spec, &resolved, progress)?;
3077        input_resolution.include(verified.timings);
3078
3079        // Publication is the prepared snapshot's linearization boundary. A
3080        // reader that reaches it first may finish publishing; invalidation
3081        // takes the write side of this lock and therefore precedes every later
3082        // reader without racing a successful stamp into existence.
3083        let publication_guard = shared_incremental.lock_prepared_publication()?;
3084
3085        let cached_artifacts = expected_artifacts(spec, &cache_entry);
3086        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3087            if cargo_target_dir != cache_entry {
3088                copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3089            }
3090            publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3091            materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
3092            record_cache_entry_use(&cache_entry)
3093        })?;
3094        drop(publication_guard);
3095
3096        Ok(WasmBuildOutcome::Built(complete_build_record(
3097            spec,
3098            BuildRecordInput {
3099                fingerprint,
3100                input_digest: resolved.input_digest,
3101                lock_wait,
3102                shared_incremental,
3103                input_resolution,
3104                cargo_build: Some(cargo_build),
3105                active_entry: &cache_entry,
3106            },
3107            total_started,
3108            progress,
3109        )?))
3110    })();
3111    finish_fingerprint_build(build_result, incomplete_directory, progress)
3112}
3113
3114/// Prune fingerprint-specific Cargo target directories under `target_dir`.
3115///
3116/// Pruning uses the same exclusive process lock as builds. Entries older than
3117/// the configured age are removed first, then least-recently-used entries are
3118/// removed until the configured logical byte limit is met. Only direct child
3119/// directories with SHA-256 fingerprint names are eligible; caller-facing
3120/// artifacts and unrelated target contents are never removed.
3121/// Entries owned by live build records are skipped until their last owner drops.
3122pub fn prune_wasm_build_cache(
3123    target_dir: &Path,
3124    policy: ArtifactCachePrunePolicy,
3125) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3126    let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3127    ensure_cache_directory_tag(target_dir)?;
3128
3129    prune_wasm_build_cache_locked(target_dir, policy, None)
3130}
3131
3132struct BuildRecordInput<'a> {
3133    fingerprint: InputDigest,
3134    input_digest: InputDigest,
3135    lock_wait: Duration,
3136    shared_incremental: WasmAcquisitionContext,
3137    input_resolution: WasmInputResolutionTimings,
3138    cargo_build: Option<Duration>,
3139    active_entry: &'a Path,
3140}
3141
3142fn complete_build_record(
3143    spec: &WasmBuildSpec,
3144    input: BuildRecordInput<'_>,
3145    total_started: Instant,
3146    progress: &mut ProgressReporter<'_>,
3147) -> Result<WasmBuildRecord, WasmBuildError> {
3148    let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3149        RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3150    })?;
3151    let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3152        progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3153            let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3154            let identity = policy.maintenance_identity();
3155            perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3156                prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3157                    .map_err(|error| error.to_string())
3158            })
3159        })
3160    });
3161    Ok(WasmBuildRecord {
3162        fingerprint: input.fingerprint,
3163        input_digest: input.input_digest,
3164        artifacts: expected_artifacts(spec, input.active_entry),
3165        retention,
3166        timings: WasmBuildTimings {
3167            lock_wait: input.lock_wait,
3168            shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3169            input_resolution: input.input_resolution,
3170            cargo_build: input.cargo_build,
3171            cache_maintenance,
3172            total: total_started.elapsed(),
3173        },
3174        maintenance,
3175        shared_incremental_maintenance: input.shared_incremental.maintenance,
3176    })
3177}
3178
3179fn prune_wasm_build_cache_locked(
3180    target_dir: &Path,
3181    policy: ArtifactCachePrunePolicy,
3182    protected_entry: Option<&Path>,
3183) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3184    let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3185    prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3186        .map_err(wasm_cache_fs_error)
3187}
3188
3189struct IncompleteBuildDirectory {
3190    path: PathBuf,
3191    armed: bool,
3192}
3193
3194impl IncompleteBuildDirectory {
3195    const fn new(path: PathBuf) -> Self {
3196        Self { path, armed: true }
3197    }
3198
3199    fn preserve(mut self) {
3200        self.armed = false;
3201    }
3202
3203    fn cleanup(mut self) -> io::Result<()> {
3204        let result = remove_path_if_present(&self.path);
3205        if result.is_ok() {
3206            self.armed = false;
3207        }
3208        result
3209    }
3210}
3211
3212impl Drop for IncompleteBuildDirectory {
3213    fn drop(&mut self) {
3214        if self.armed {
3215            let _ = remove_path_if_present(&self.path);
3216        }
3217    }
3218}
3219
3220fn finish_fingerprint_build(
3221    result: Result<WasmBuildOutcome, WasmBuildError>,
3222    incomplete_directory: IncompleteBuildDirectory,
3223    progress: &mut ProgressReporter<'_>,
3224) -> Result<WasmBuildOutcome, WasmBuildError> {
3225    match result {
3226        Ok(outcome) => {
3227            incomplete_directory.preserve();
3228            Ok(outcome)
3229        }
3230        Err(build_error) => Err(cleanup_failed_fingerprint_build(
3231            build_error,
3232            incomplete_directory,
3233            progress,
3234        )),
3235    }
3236}
3237
3238fn cleanup_failed_fingerprint_build(
3239    build_error: WasmBuildError,
3240    incomplete_directory: IncompleteBuildDirectory,
3241    progress: &mut ProgressReporter<'_>,
3242) -> WasmBuildError {
3243    let path = incomplete_directory.path.clone();
3244    let primary_phase = progress.failure_phase;
3245    let cleanup_started = Instant::now();
3246    let cleanup = incomplete_directory.cleanup();
3247    progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3248    match cleanup {
3249        Ok(()) => {
3250            progress.failure_phase = primary_phase;
3251            build_error
3252        }
3253        Err(source) => WasmBuildError::FailedBuildCleanup {
3254            build_error: Box::new(build_error),
3255            path,
3256            source,
3257        },
3258    }
3259}
3260
3261fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3262    create_dir_all(target_dir, "create Cargo target directory")?;
3263    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3264    lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3265}
3266
3267fn lock_wasm_build_cache_with_progress(
3268    target_dir: &Path,
3269    progress: &mut ProgressReporter<'_>,
3270) -> Result<(File, Duration), WasmBuildError> {
3271    progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3272    create_dir_all(target_dir, "create Cargo target directory")?;
3273    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3274    lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3275}
3276
3277fn lock_shared_incremental_target(
3278    spec: &WasmBuildSpec,
3279) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3280    lock_shared_incremental_target_internal(spec, None)
3281}
3282
3283fn lock_shared_incremental_target_with_progress(
3284    spec: &WasmBuildSpec,
3285    progress: &mut ProgressReporter<'_>,
3286) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3287    let target_dir = shared_incremental_target(spec)
3288        .expect("validated shared acquisition must have a shared Cargo target");
3289    progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3290    let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3291    progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3292        target_dir: canonical.clone(),
3293        wait,
3294    });
3295    Ok((lock, wait, canonical))
3296}
3297
3298fn lock_shared_incremental_target_internal(
3299    spec: &WasmBuildSpec,
3300    mut progress: Option<&mut ProgressReporter<'_>>,
3301) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3302    if let Some(progress) = progress.as_deref_mut() {
3303        progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3304    }
3305    let target_dir =
3306        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3307            message: "shared incremental target is not configured".to_owned(),
3308        })?;
3309    create_dir_all(
3310        &target_dir,
3311        "create shared incremental Cargo target directory",
3312    )?;
3313    ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3314    let canonical = target_dir
3315        .canonicalize()
3316        .map_err(|source| WasmBuildError::Io {
3317            operation: "resolve shared incremental Cargo target directory",
3318            path: target_dir.clone(),
3319            source,
3320        })?;
3321    let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3322    let (lock, wait) = if let Some(progress) = progress {
3323        lock_cache_file_with_progress(
3324            &lock_path,
3325            WasmBuildProgressPhase::SharedTargetLock,
3326            progress,
3327        )?
3328    } else {
3329        lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3330    };
3331    Ok((lock, wait, canonical))
3332}
3333
3334fn lock_cache_file_with_progress(
3335    lock_path: &Path,
3336    phase: WasmBuildProgressPhase,
3337    progress: &mut ProgressReporter<'_>,
3338) -> Result<(File, Duration), WasmBuildError> {
3339    let failure_phase = progress_failure_phase(phase);
3340    let started = Instant::now();
3341    progress.begin_phase(failure_phase);
3342    let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3343        lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3344    } else {
3345        let heartbeat_interval = progress
3346            .config
3347            .heartbeat_interval
3348            .expect("observed cache lock must have a heartbeat interval");
3349        lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3350            progress.emit_heartbeat_if_due(phase, elapsed);
3351        })
3352        .map_err(wasm_cache_fs_error)
3353    };
3354    progress.record_phase(failure_phase, started.elapsed());
3355    result
3356}
3357
3358fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3359    ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3360}
3361
3362fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3363    record_entry_use(path).map_err(wasm_cache_fs_error)
3364}
3365
3366fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3367    WasmBuildError::Io {
3368        operation: error.operation,
3369        path: error.path,
3370        source: error.source,
3371    }
3372}
3373
3374fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3375    if spec.packages.is_empty() {
3376        return Err(WasmBuildError::InvalidSpec {
3377            message: "at least one Cargo package is required".to_owned(),
3378        });
3379    }
3380    if spec.profile_target_dir.is_empty() {
3381        return Err(WasmBuildError::InvalidSpec {
3382            message: "Cargo profile target directory must not be empty".to_owned(),
3383        });
3384    }
3385    if spec.target.is_empty() {
3386        return Err(WasmBuildError::InvalidSpec {
3387            message: "Cargo compilation target must not be empty".to_owned(),
3388        });
3389    }
3390    if spec.extra_env.contains_key(OsStr::new("CARGO_TARGET_DIR"))
3391        || spec.cargo_profile_args.iter().any(|argument| {
3392            argument == OsStr::new("--target-dir")
3393                || argument.as_encoded_bytes().starts_with(b"--target-dir=")
3394        })
3395    {
3396        return Err(WasmBuildError::InvalidSpec {
3397            message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3398        });
3399    }
3400    if matches!(
3401        &spec.cache_mode,
3402        WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3403    ) {
3404        return Err(WasmBuildError::InvalidSpec {
3405            message: "shared incremental Cargo target directory must not be empty".to_owned(),
3406        });
3407    }
3408    if spec.shared_incremental_maintenance_config.is_some()
3409        && !matches!(
3410            spec.cache_mode,
3411            WasmBuildCacheMode::SharedIncremental { .. }
3412        )
3413    {
3414        return Err(WasmBuildError::InvalidSpec {
3415            message:
3416                "scheduled shared-target maintenance requires a shared incremental Cargo target"
3417                    .to_owned(),
3418        });
3419    }
3420    Ok(())
3421}
3422
3423fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3424    build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3425}
3426
3427fn build_fingerprint_with_progress(
3428    spec: &WasmBuildSpec,
3429    progress: &mut ProgressReporter<'_>,
3430) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3431    let total_started = Instant::now();
3432    let (cargo_identity, rustc_identity, tool_identity) = resolve_tool_identity(spec, progress)?;
3433
3434    let metadata_started = Instant::now();
3435    let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3436        cargo_metadata(spec)
3437    })?;
3438    let cargo_metadata = metadata_started.elapsed();
3439
3440    let discovery_started = Instant::now();
3441    let (inputs, exclusions) =
3442        progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3443            let parsed = ParsedCargoMetadata::parse(&metadata)
3444                .map_err(|message| invalid_metadata(&message))?;
3445            let inputs = resolve_local_inputs(spec, &parsed)?;
3446            validate_shared_incremental_target_boundary(spec, &inputs.validation_inputs)?;
3447            let exclusions = source_exclusions(spec, &inputs.validation_inputs);
3448            Ok::<_, WasmBuildError>((inputs, exclusions))
3449        })?;
3450    let input_discovery = discovery_started.elapsed();
3451
3452    let hashing_started = Instant::now();
3453    let (input_digest, validation_digest) =
3454        progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3455            let mut cache = LabeledPathDigestCache::default();
3456            digest_resolved_local_inputs(
3457                &inputs,
3458                &exclusions,
3459                &mut cache,
3460                &spec.workspace_root,
3461                "hash Wasm build inputs",
3462                "hash semantic Wasm build inputs",
3463            )
3464        })?;
3465    let content_hashing = hashing_started.elapsed();
3466
3467    let fingerprint =
3468        finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3469    Ok(ResolvedCargoBuildInputs {
3470        fingerprint,
3471        input_digest,
3472        validation_digest,
3473        inputs: inputs
3474            .validation_inputs
3475            .into_iter()
3476            .map(|(label, path)| CargoBuildInput { label, path })
3477            .collect(),
3478        exclusions,
3479        timings: WasmInputResolutionTimings {
3480            tool_identity,
3481            cargo_metadata,
3482            input_discovery,
3483            content_hashing,
3484            total: total_started.elapsed(),
3485        },
3486    })
3487}
3488
3489fn finish_build_fingerprint(
3490    spec: &WasmBuildSpec,
3491    cargo_identity: &[u8],
3492    rustc_identity: &[u8],
3493    input_digest: InputDigest,
3494) -> InputDigest {
3495    let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3496    let mut packages = spec.packages.clone();
3497    packages.sort();
3498    packages.dedup();
3499    for package in packages {
3500        hasher.field("package", package.as_bytes());
3501    }
3502    hasher.field("target", spec.target.as_bytes());
3503    hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
3504    for argument in &spec.cargo_profile_args {
3505        hasher.field("cargo-argument", &os_bytes(argument));
3506    }
3507    for (key, value) in effective_environment(spec) {
3508        hasher.field("environment-key", &os_bytes(&key));
3509        if let Some(value) = value {
3510            hasher.field("environment-value", &os_bytes(&value));
3511        } else {
3512            hasher.field("environment-unset", b"");
3513        }
3514    }
3515    hasher.field("cargo-identity", cargo_identity);
3516    hasher.field("rustc-identity", rustc_identity);
3517    hasher.field("source-input-digest", input_digest.as_bytes());
3518    hasher.finish()
3519}
3520
3521fn command_identity(
3522    spec: &WasmBuildSpec,
3523    phase: WasmBuildPhase,
3524    program: &OsStr,
3525    arguments: &[&str],
3526) -> Result<Vec<u8>, WasmBuildError> {
3527    let mut command = Command::new(program);
3528    command.current_dir(&spec.workspace_root).args(arguments);
3529    apply_command_environment(&mut command, spec);
3530    let output = command
3531        .output()
3532        .map_err(|source| WasmBuildError::CommandSpawn {
3533            phase,
3534            program: program.to_owned(),
3535            source,
3536        })?;
3537    ensure_command_success(phase, output).map(|output| {
3538        let mut identity = output.stdout;
3539        identity.extend_from_slice(&output.stderr);
3540        identity
3541    })
3542}
3543
3544fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3545    let mut command = Command::new(&spec.cargo_program);
3546    command
3547        .current_dir(&spec.workspace_root)
3548        .args(["metadata", "--format-version", "1"]);
3549    for argument in metadata_arguments(&spec.cargo_profile_args) {
3550        command.arg(argument);
3551    }
3552    apply_command_environment(&mut command, spec);
3553    let output = command
3554        .output()
3555        .map_err(|source| WasmBuildError::CommandSpawn {
3556            phase: WasmBuildPhase::CargoMetadata,
3557            program: spec.cargo_program.clone(),
3558            source,
3559        })?;
3560    let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3561    serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3562        message: format!("Cargo metadata was not valid JSON: {error}"),
3563    })
3564}
3565
3566fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
3567    let mut selected = Vec::new();
3568    let mut arguments = arguments.iter();
3569    while let Some(argument) = arguments.next() {
3570        let argument_text = argument.to_string_lossy();
3571        match argument_text.as_ref() {
3572            "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
3573                selected.push(argument.clone());
3574            }
3575            "--features" | "-F" | "--filter-platform" => {
3576                selected.push(argument.clone());
3577                if let Some(value) = arguments.next() {
3578                    selected.push(value.clone());
3579                }
3580            }
3581            _ if argument_text.starts_with("--features=")
3582                || argument_text.starts_with("-F")
3583                || argument_text.starts_with("--filter-platform=") =>
3584            {
3585                selected.push(argument.clone());
3586            }
3587            _ => {}
3588        }
3589    }
3590    selected
3591}
3592
3593#[derive(Clone)]
3594struct MetadataPackage {
3595    id: String,
3596    name: String,
3597    version: String,
3598    manifest_path: PathBuf,
3599    is_local: bool,
3600    source: Option<String>,
3601    semantic_fields: Vec<(&'static str, Option<String>)>,
3602}
3603
3604// Parsed once per Cargo resolution context. Each specification still selects
3605// its own closure and independently validates filesystem inputs.
3606struct ParsedCargoMetadata<'a> {
3607    packages: HashMap<String, MetadataPackage>,
3608    workspace_members: HashSet<&'a str>,
3609    nodes: HashMap<String, MetadataNode<'a>>,
3610    workspace_root: Option<&'a str>,
3611}
3612
3613struct MetadataNode<'a> {
3614    dependencies: Vec<String>,
3615    value: &'a Value,
3616}
3617
3618impl<'a> ParsedCargoMetadata<'a> {
3619    fn parse(metadata: &'a Value) -> Result<Self, String> {
3620        let packages = metadata_packages(metadata)?;
3621        let workspace_members = metadata
3622            .get("workspace_members")
3623            .and_then(Value::as_array)
3624            .ok_or_else(|| "Cargo metadata has no workspace member array".to_owned())?
3625            .iter()
3626            .filter_map(Value::as_str)
3627            .collect();
3628        let values = metadata
3629            .pointer("/resolve/nodes")
3630            .and_then(Value::as_array)
3631            .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3632        let mut nodes = HashMap::new();
3633        for value in values {
3634            let id = required_string(value, "id")?;
3635            let dependencies = value
3636                .get("deps")
3637                .and_then(Value::as_array)
3638                .ok_or_else(|| "Cargo metadata dependency node has no deps array".to_owned())?
3639                .iter()
3640                .map(|dependency| required_string(dependency, "pkg"))
3641                .collect::<Result<_, _>>()?;
3642            nodes.insert(
3643                id,
3644                MetadataNode {
3645                    dependencies,
3646                    value,
3647                },
3648            );
3649        }
3650        Ok(Self {
3651            packages,
3652            workspace_members,
3653            nodes,
3654            workspace_root: metadata.get("workspace_root").and_then(Value::as_str),
3655        })
3656    }
3657}
3658
3659const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
3660    "authors",
3661    "default_run",
3662    "description",
3663    "documentation",
3664    "edition",
3665    "homepage",
3666    "license",
3667    "license_file",
3668    "links",
3669    "metadata",
3670    "name",
3671    "readme",
3672    "repository",
3673    "rust_version",
3674    "version",
3675];
3676
3677struct LockedPackageIdentity {
3678    name: String,
3679    version: String,
3680    source: String,
3681    checksum: Option<String>,
3682}
3683
3684fn resolve_local_inputs(
3685    spec: &WasmBuildSpec,
3686    metadata: &ParsedCargoMetadata<'_>,
3687) -> Result<ResolvedLocalInputs, WasmBuildError> {
3688    let mut selected_ids = selected_package_ids(spec, metadata)?;
3689    let mut closure = BTreeSet::new();
3690    while let Some(id) = selected_ids.pop_front() {
3691        if !closure.insert(id.clone()) {
3692            continue;
3693        }
3694        if let Some(node) = metadata.nodes.get(&id) {
3695            selected_ids.extend(node.dependencies.iter().cloned());
3696        }
3697    }
3698
3699    let workspace_root = metadata
3700        .workspace_root
3701        .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
3702    let projection = semantic_workspace_projection(metadata, &closure, &workspace_root)?;
3703    let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
3704    append_package_inputs(
3705        &mut validation_inputs,
3706        &metadata.packages,
3707        closure,
3708        &workspace_root,
3709    )?;
3710    append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
3711    let fingerprint = projection.map_or(LocalInputFingerprint::Conservative, |workspace| {
3712        LocalInputFingerprint::Projected {
3713            inputs: validation_inputs
3714                .iter()
3715                .filter(|(label, _)| !is_broad_workspace_input(label))
3716                .cloned()
3717                .collect(),
3718            workspace,
3719        }
3720    });
3721    Ok(ResolvedLocalInputs {
3722        validation_inputs,
3723        fingerprint,
3724    })
3725}
3726
3727fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, String> {
3728    let packages_value = metadata
3729        .get("packages")
3730        .and_then(Value::as_array)
3731        .ok_or_else(|| "Cargo metadata has no package array".to_owned())?;
3732    let mut packages = HashMap::new();
3733    for value in packages_value {
3734        let source = optional_string(value, "source")?;
3735        let package = MetadataPackage {
3736            id: required_string(value, "id")?,
3737            name: required_string(value, "name")?,
3738            version: required_string(value, "version")?,
3739            manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
3740            is_local: value.get("source").is_some_and(Value::is_null),
3741            source,
3742            semantic_fields: SEMANTIC_PACKAGE_FIELDS
3743                .iter()
3744                .map(|field| (*field, value.get(*field).map(Value::to_string)))
3745                .collect(),
3746        };
3747        packages.insert(package.id.clone(), package);
3748    }
3749    Ok(packages)
3750}
3751
3752fn selected_package_ids(
3753    spec: &WasmBuildSpec,
3754    metadata: &ParsedCargoMetadata<'_>,
3755) -> Result<VecDeque<String>, WasmBuildError> {
3756    let mut selected_ids = VecDeque::new();
3757    for requested in &spec.packages {
3758        let matches = metadata
3759            .packages
3760            .values()
3761            .filter(|package| {
3762                package.name == *requested
3763                    && metadata.workspace_members.contains(package.id.as_str())
3764            })
3765            .map(|package| package.id.clone())
3766            .collect::<Vec<_>>();
3767        match matches.as_slice() {
3768            [id] => selected_ids.push_back(id.clone()),
3769            [] => {
3770                return Err(WasmBuildError::InvalidSpec {
3771                    message: format!("Cargo workspace contains no package named `{requested}`"),
3772                });
3773            }
3774            _ => {
3775                return Err(WasmBuildError::InvalidSpec {
3776                    message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3777                });
3778            }
3779        }
3780    }
3781    Ok(selected_ids)
3782}
3783
3784fn semantic_workspace_projection(
3785    metadata: &ParsedCargoMetadata<'_>,
3786    closure: &BTreeSet<String>,
3787    workspace_root: &Path,
3788) -> Result<Option<InputDigest>, WasmBuildError> {
3789    // A workspace-root or external local package cannot be separated from the
3790    // broad root safely; `None` keeps the complete-input fingerprint.
3791    let locked_packages = locked_package_identities(workspace_root)?;
3792    let mut identities = HashMap::new();
3793    for id in closure {
3794        let package = metadata
3795            .packages
3796            .get(id)
3797            .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3798        let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
3799        else {
3800            return Ok(None);
3801        };
3802        identities.insert(id.as_str(), identity);
3803    }
3804
3805    let mut projected_packages = closure
3806        .iter()
3807        .map(|id| {
3808            let package = metadata
3809                .packages
3810                .get(id)
3811                .expect("selected package closure was validated above");
3812            let identity = identities[id.as_str()];
3813            let node = metadata.nodes.get(id).ok_or_else(|| {
3814                invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3815            })?;
3816            let projection = semantic_package_projection(package, node.value, &identities)?;
3817            Ok::<_, WasmBuildError>((identity, projection))
3818        })
3819        .collect::<Result<Vec<_>, _>>()?;
3820    projected_packages.sort_by_key(|(identity, _)| *identity);
3821
3822    let root_manifest = workspace_root.join("Cargo.toml");
3823    let root_contents =
3824        fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3825            operation: "read workspace manifest for semantic projection",
3826            path: root_manifest.clone(),
3827            source,
3828        })?;
3829    let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3830        invalid_metadata(&format!(
3831            "workspace manifest could not be projected as TOML: {error}"
3832        ))
3833    })?;
3834
3835    let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3836    for (identity, projection) in projected_packages {
3837        hasher.field("package-identity", identity.as_bytes());
3838        hasher.field("package-projection", projection.as_bytes());
3839    }
3840    hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3841    hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3842    let workspace = root.get("workspace").and_then(TomlValue::as_table);
3843    hash_toml_setting(
3844        &mut hasher,
3845        "workspace-resolver",
3846        workspace.and_then(|table| table.get("resolver")),
3847    );
3848    hash_toml_setting(
3849        &mut hasher,
3850        "workspace-lints",
3851        workspace.and_then(|table| table.get("lints")),
3852    );
3853    Ok(Some(hasher.finish()))
3854}
3855
3856fn locked_package_identities(
3857    workspace_root: &Path,
3858) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3859    let lockfile = workspace_root.join("Cargo.lock");
3860    let contents = match fs::read_to_string(&lockfile) {
3861        Ok(contents) => contents,
3862        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3863        Err(source) => {
3864            return Err(WasmBuildError::Io {
3865                operation: "read Cargo lockfile for semantic projection",
3866                path: lockfile,
3867                source,
3868            });
3869        }
3870    };
3871    let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3872        invalid_metadata(&format!(
3873            "Cargo lockfile could not be projected as TOML: {error}"
3874        ))
3875    })?;
3876    let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3877        return Ok(Vec::new());
3878    };
3879    packages
3880        .iter()
3881        .filter_map(|package| {
3882            let Some(table) = package.as_table() else {
3883                return Some(Err(invalid_metadata(
3884                    "Cargo lockfile package entry is not a table",
3885                )));
3886            };
3887            let source = table.get("source")?.as_str().map(str::to_owned);
3888            Some(
3889                source
3890                    .ok_or_else(|| {
3891                        invalid_metadata("Cargo lockfile package source is not a string")
3892                    })
3893                    .and_then(|source| {
3894                        Ok(LockedPackageIdentity {
3895                            name: required_toml_string(table, "name", "Cargo lockfile package")?,
3896                            version: required_toml_string(
3897                                table,
3898                                "version",
3899                                "Cargo lockfile package",
3900                            )?,
3901                            source,
3902                            checksum: optional_toml_string(
3903                                table,
3904                                "checksum",
3905                                "Cargo lockfile package",
3906                            )?,
3907                        })
3908                    }),
3909            )
3910        })
3911        .collect()
3912}
3913
3914fn required_toml_string(
3915    table: &toml::Table,
3916    field: &str,
3917    context: &str,
3918) -> Result<String, WasmBuildError> {
3919    table
3920        .get(field)
3921        .and_then(TomlValue::as_str)
3922        .map(str::to_owned)
3923        .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
3924}
3925
3926fn optional_toml_string(
3927    table: &toml::Table,
3928    field: &str,
3929    context: &str,
3930) -> Result<Option<String>, WasmBuildError> {
3931    match table.get(field) {
3932        None => Ok(None),
3933        Some(TomlValue::String(value)) => Ok(Some(value.clone())),
3934        Some(_) => Err(invalid_metadata(&format!(
3935            "{context} `{field}` is not a string"
3936        ))),
3937    }
3938}
3939
3940fn semantic_package_identity(
3941    package: &MetadataPackage,
3942    workspace_root: &Path,
3943    locked_packages: &[LockedPackageIdentity],
3944) -> Option<InputDigest> {
3945    let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3946    hasher.field("name", package.name.as_bytes());
3947    hasher.field("version", package.version.as_bytes());
3948    if package.is_local {
3949        let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3950        let package_root = package.manifest_path.parent()?;
3951        if package_root == workspace_root {
3952            return None;
3953        }
3954        hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3955    } else {
3956        let metadata_source = package.source.as_deref()?;
3957        let locked = locked_packages.iter().find(|locked| {
3958            locked.name == package.name
3959                && locked.version == package.version
3960                && locked.source == metadata_source
3961        })?;
3962        match locked.source.as_str() {
3963            source if source.starts_with("registry+") && locked.checksum.is_some() => {}
3964            source if source.starts_with("git+") && source.contains('#') => {}
3965            _ => return None,
3966        }
3967        hasher.field("external-package-id", package.id.as_bytes());
3968        hasher.field("external-source", locked.source.as_bytes());
3969        hasher.field(
3970            "external-checksum",
3971            locked.checksum.as_deref().unwrap_or_default().as_bytes(),
3972        );
3973    }
3974    Some(hasher.finish())
3975}
3976
3977fn semantic_package_projection(
3978    package: &MetadataPackage,
3979    node: &Value,
3980    identities: &HashMap<&str, InputDigest>,
3981) -> Result<InputDigest, WasmBuildError> {
3982    // These are the effective package values Cargo can expose to compilation
3983    // through CARGO_PKG_* variables. Local manifests and external checksums
3984    // cover the remaining package definition.
3985    let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
3986    for (field, value) in &package.semantic_fields {
3987        hasher.field("package-field-name", field.as_bytes());
3988        match value {
3989            Some(value) => hasher.field("package-field-value", value.as_bytes()),
3990            None => hasher.field("package-field-missing", b""),
3991        }
3992    }
3993
3994    let mut features = node
3995        .get("features")
3996        .and_then(Value::as_array)
3997        .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
3998        .iter()
3999        .map(|feature| {
4000            feature.as_str().map(str::to_owned).ok_or_else(|| {
4001                invalid_metadata("Cargo metadata dependency feature is not a string")
4002            })
4003        })
4004        .collect::<Result<Vec<_>, _>>()?;
4005    features.sort();
4006    for feature in features {
4007        hasher.field("enabled-feature", feature.as_bytes());
4008    }
4009
4010    let mut dependencies = node
4011        .get("deps")
4012        .and_then(Value::as_array)
4013        .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
4014        .iter()
4015        .map(|dependency| {
4016            let name = required_string(dependency, "name")
4017                .map_err(|message| invalid_metadata(&message))?;
4018            let package_id =
4019                required_string(dependency, "pkg").map_err(|message| invalid_metadata(&message))?;
4020            let identity = identities
4021                .get(package_id.as_str())
4022                .copied()
4023                .ok_or_else(|| {
4024                    invalid_metadata(&format!(
4025                        "dependency `{package_id}` is outside the selected package closure"
4026                    ))
4027                })?;
4028            let kinds = dependency
4029                .get("dep_kinds")
4030                .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
4031                .to_string();
4032            Ok::<_, WasmBuildError>((name, identity, kinds))
4033        })
4034        .collect::<Result<Vec<_>, _>>()?;
4035    dependencies.sort();
4036    for (name, identity, kinds) in dependencies {
4037        hasher.field("dependency-name", name.as_bytes());
4038        hasher.field("dependency-identity", identity.as_bytes());
4039        hasher.field("dependency-kinds", kinds.as_bytes());
4040    }
4041    Ok(hasher.finish())
4042}
4043
4044fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4045    hasher.field("workspace-setting-name", label.as_bytes());
4046    match value {
4047        Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4048        None => hasher.field("workspace-setting-missing", b""),
4049    }
4050}
4051
4052fn is_broad_workspace_input(label: &Path) -> bool {
4053    label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4054}
4055
4056fn digest_resolved_local_inputs(
4057    inputs: &ResolvedLocalInputs,
4058    exclusions: &[PathBuf],
4059    cache: &mut LabeledPathDigestCache,
4060    error_path: &Path,
4061    validation_operation: &'static str,
4062    semantic_operation: &'static str,
4063) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4064    let validation_digest = digest_labeled_paths_composable(
4065        "wasm-source-inputs-v1",
4066        &inputs.validation_inputs,
4067        exclusions,
4068        cache,
4069    )
4070    .map_err(|source| WasmBuildError::Io {
4071        operation: validation_operation,
4072        path: error_path.to_owned(),
4073        source,
4074    })?;
4075    let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4076        .map_err(|source| WasmBuildError::Io {
4077            operation: semantic_operation,
4078            path: error_path.to_owned(),
4079            source,
4080        })?;
4081    Ok((input_digest, validation_digest))
4082}
4083
4084fn semantic_input_digest(
4085    inputs: &ResolvedLocalInputs,
4086    validation_digest: InputDigest,
4087    exclusions: &[PathBuf],
4088    cache: &mut LabeledPathDigestCache,
4089) -> io::Result<InputDigest> {
4090    let LocalInputFingerprint::Projected {
4091        inputs: fingerprint_inputs,
4092        workspace,
4093    } = &inputs.fingerprint
4094    else {
4095        return Ok(validation_digest);
4096    };
4097    let path_digest = digest_labeled_paths_composable(
4098        "wasm-source-inputs-v1",
4099        fingerprint_inputs,
4100        exclusions,
4101        cache,
4102    )?;
4103    let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4104    hasher.field("path-input-digest", path_digest.as_bytes());
4105    hasher.field("workspace-projection", workspace.as_bytes());
4106    Ok(hasher.finish())
4107}
4108
4109fn workspace_configuration_inputs(
4110    spec: &WasmBuildSpec,
4111    workspace_root: &Path,
4112) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4113    let mut inputs = Vec::new();
4114    add_if_present(
4115        &mut inputs,
4116        "workspace/Cargo.toml",
4117        workspace_root.join("Cargo.toml"),
4118    );
4119    add_if_present(
4120        &mut inputs,
4121        "workspace/Cargo.lock",
4122        workspace_root.join("Cargo.lock"),
4123    );
4124    add_if_present(
4125        &mut inputs,
4126        "workspace/rust-toolchain.toml",
4127        workspace_root.join("rust-toolchain.toml"),
4128    );
4129    add_if_present(
4130        &mut inputs,
4131        "workspace/rust-toolchain",
4132        workspace_root.join("rust-toolchain"),
4133    );
4134    append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4135    Ok(inputs)
4136}
4137
4138fn append_cargo_configuration_inputs(
4139    inputs: &mut Vec<(PathBuf, PathBuf)>,
4140    spec: &WasmBuildSpec,
4141    workspace_root: &Path,
4142) -> Result<(), WasmBuildError> {
4143    let invocation_root =
4144        spec.workspace_root
4145            .canonicalize()
4146            .map_err(|source| WasmBuildError::Io {
4147                operation: "resolve Cargo invocation directory",
4148                path: spec.workspace_root.clone(),
4149                source,
4150            })?;
4151    let canonical_workspace =
4152        workspace_root
4153            .canonicalize()
4154            .map_err(|source| WasmBuildError::Io {
4155                operation: "resolve Cargo workspace directory",
4156                path: workspace_root.to_owned(),
4157                source,
4158            })?;
4159
4160    let mut roots = invocation_root
4161        .ancestors()
4162        .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
4163        .collect::<Vec<_>>();
4164    if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
4165        && let Some(config) = effective_cargo_config(&cargo_home)
4166    {
4167        roots.push(config);
4168    }
4169
4170    let mut visited = BTreeSet::new();
4171    for config in roots {
4172        append_cargo_configuration_tree(
4173            inputs,
4174            &config,
4175            &canonical_workspace,
4176            &mut visited,
4177            false,
4178        )?;
4179    }
4180    Ok(())
4181}
4182
4183fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
4184    let extensionless = directory.join("config");
4185    if extensionless.exists() {
4186        return Some(extensionless);
4187    }
4188    let toml = directory.join("config.toml");
4189    toml.exists().then_some(toml)
4190}
4191
4192fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
4193    if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
4194        let cargo_home = PathBuf::from(cargo_home);
4195        return Some(if cargo_home.is_absolute() {
4196            cargo_home
4197        } else {
4198            invocation_root.join(cargo_home)
4199        });
4200    }
4201
4202    default_home_directory(spec).map(|home| {
4203        let home = if home.is_absolute() {
4204            home
4205        } else {
4206            invocation_root.join(home)
4207        };
4208        home.join(".cargo")
4209    })
4210}
4211
4212#[cfg(windows)]
4213fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4214    command_environment_value(spec, "USERPROFILE")
4215        .or_else(|| command_environment_value(spec, "HOME"))
4216        .map(PathBuf::from)
4217}
4218
4219#[cfg(not(windows))]
4220fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4221    command_environment_value(spec, "HOME").map(PathBuf::from)
4222}
4223
4224fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4225    spec.extra_env
4226        .get(OsStr::new(name))
4227        .cloned()
4228        .or_else(|| std::env::var_os(name))
4229}
4230
4231fn append_cargo_configuration_tree(
4232    inputs: &mut Vec<(PathBuf, PathBuf)>,
4233    config: &Path,
4234    workspace_root: &Path,
4235    visited: &mut BTreeSet<PathBuf>,
4236    optional: bool,
4237) -> Result<(), WasmBuildError> {
4238    let canonical = match config.canonicalize() {
4239        Ok(canonical) => canonical,
4240        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
4241        Err(source) => {
4242            return Err(WasmBuildError::Io {
4243                operation: "resolve Cargo configuration",
4244                path: config.to_owned(),
4245                source,
4246            });
4247        }
4248    };
4249    if !visited.insert(canonical.clone()) {
4250        return Ok(());
4251    }
4252
4253    let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
4254        operation: "read Cargo configuration",
4255        path: canonical.clone(),
4256        source,
4257    })?;
4258    let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4259        WasmBuildError::InvalidCargoConfiguration {
4260            path: canonical.clone(),
4261            message: error.to_string(),
4262        }
4263    })?;
4264    inputs.push((
4265        cargo_configuration_label(&canonical, workspace_root),
4266        canonical.clone(),
4267    ));
4268
4269    let Some(include) = configuration.get("include") else {
4270        return Ok(());
4271    };
4272    let parent = canonical
4273        .parent()
4274        .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
4275            path: canonical.clone(),
4276            message: "configuration path has no parent directory".to_owned(),
4277        })?;
4278    for (included, optional) in cargo_configuration_includes(include, &canonical)? {
4279        let included = if included.is_absolute() {
4280            included
4281        } else {
4282            parent.join(included)
4283        };
4284        append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
4285    }
4286    Ok(())
4287}
4288
4289fn cargo_configuration_includes(
4290    include: &TomlValue,
4291    config: &Path,
4292) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
4293    let values = match include {
4294        TomlValue::Array(values) => values.as_slice(),
4295        value => std::slice::from_ref(value),
4296    };
4297    values
4298        .iter()
4299        .map(|value| match value {
4300            TomlValue::String(path) => Ok((PathBuf::from(path), false)),
4301            TomlValue::Table(table) => {
4302                let path = table
4303                    .get("path")
4304                    .and_then(TomlValue::as_str)
4305                    .ok_or_else(|| {
4306                        invalid_cargo_configuration(
4307                            config,
4308                            "Cargo configuration include table requires a string `path`",
4309                        )
4310                    })?;
4311                let optional = table
4312                    .get("optional")
4313                    .map(|value| {
4314                        value.as_bool().ok_or_else(|| {
4315                            invalid_cargo_configuration(
4316                                config,
4317                                "Cargo configuration include `optional` must be a boolean",
4318                            )
4319                        })
4320                    })
4321                    .transpose()?
4322                    .unwrap_or(false);
4323                Ok((PathBuf::from(path), optional))
4324            }
4325            _ => Err(invalid_cargo_configuration(
4326                config,
4327                "Cargo configuration `include` must contain paths or include tables",
4328            )),
4329        })
4330        .collect()
4331}
4332
4333fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4334    if let Ok(relative) = config.strip_prefix(workspace_root) {
4335        return PathBuf::from("cargo-config/workspace").join(relative);
4336    }
4337    let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4338    PathBuf::from("cargo-config/external").join(location.to_hex())
4339}
4340
4341fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
4342    WasmBuildError::InvalidCargoConfiguration {
4343        path: path.to_owned(),
4344        message: message.to_owned(),
4345    }
4346}
4347
4348fn append_package_inputs(
4349    inputs: &mut Vec<(PathBuf, PathBuf)>,
4350    packages: &HashMap<String, MetadataPackage>,
4351    closure: BTreeSet<String>,
4352    workspace_root: &Path,
4353) -> Result<(), WasmBuildError> {
4354    for id in closure {
4355        let Some(package) = packages.get(&id) else {
4356            return Err(invalid_metadata(&format!(
4357                "resolved package `{id}` is missing"
4358            )));
4359        };
4360        if !package.is_local {
4361            continue;
4362        }
4363        let root = package.manifest_path.parent().ok_or_else(|| {
4364            invalid_metadata(&format!(
4365                "package `{}` manifest has no parent",
4366                package.name
4367            ))
4368        })?;
4369        let relative_manifest = package
4370            .manifest_path
4371            .strip_prefix(workspace_root)
4372            .unwrap_or(&package.manifest_path);
4373        let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4374            .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4375        inputs.push((label, root.to_owned()));
4376    }
4377    Ok(())
4378}
4379
4380fn append_additional_inputs(
4381    inputs: &mut Vec<(PathBuf, PathBuf)>,
4382    spec: &WasmBuildSpec,
4383    workspace_root: &Path,
4384) {
4385    for additional in &spec.additional_inputs {
4386        let path = if additional.is_absolute() {
4387            additional.clone()
4388        } else {
4389            workspace_root.join(additional)
4390        };
4391        inputs.push((PathBuf::from("additional").join(additional), path));
4392    }
4393}
4394
4395fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4396    let mut exclusions = vec![
4397        spec.target_dir.clone(),
4398        spec.workspace_root.join("target"),
4399        spec.workspace_root.join(".git"),
4400    ];
4401    if let Some(shared_target) = shared_incremental_target(spec) {
4402        exclusions.push(shared_target);
4403    }
4404    for (_, path) in inputs {
4405        if path.is_dir() {
4406            exclusions.push(path.join("target"));
4407            exclusions.push(path.join(".git"));
4408        }
4409    }
4410    exclusions
4411}
4412
4413fn validate_shared_incremental_target_boundary(
4414    spec: &WasmBuildSpec,
4415    inputs: &[(PathBuf, PathBuf)],
4416) -> Result<(), WasmBuildError> {
4417    let Some(shared_target) = shared_incremental_target(spec) else {
4418        return Ok(());
4419    };
4420    let shared_target =
4421        canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4422            operation: "resolve shared incremental Cargo target boundary",
4423            path: shared_target.clone(),
4424            source,
4425        })?;
4426    let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4427    let exact_entries =
4428        canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4429            operation: "resolve exact Wasm cache boundary",
4430            path: exact_entries,
4431            source,
4432        })?;
4433    // Maintenance preserves only the shared target's direct metadata child.
4434    // An exact cache elsewhere beneath that target would lose retained entries.
4435    if shared_target.starts_with(&exact_entries)
4436        || (exact_entries.starts_with(&shared_target)
4437            && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4438    {
4439        return Err(WasmBuildError::InvalidSpec {
4440            message: "shared incremental target must not overlap removable exact Wasm cache state"
4441                .to_owned(),
4442        });
4443    }
4444    let resolved_inputs = inputs
4445        .iter()
4446        .map(|(_, input)| {
4447            let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
4448                operation: "resolve Cargo input boundary",
4449                path: input.clone(),
4450                source,
4451            })?;
4452            let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
4453                operation: "inspect Cargo input boundary",
4454                path: canonical.clone(),
4455                source,
4456            })?;
4457            Ok((canonical, metadata.is_dir()))
4458        })
4459        .collect::<Result<Vec<_>, WasmBuildError>>()?;
4460    let safe_generated_roots = std::iter::once(spec.target_dir.clone())
4461        .chain(std::iter::once(spec.workspace_root.join("target")))
4462        .chain(
4463            inputs
4464                .iter()
4465                .filter(|(_, path)| path.is_dir())
4466                .map(|(_, path)| path.join("target")),
4467        )
4468        .filter_map(|path| canonicalize_allow_missing(&path).ok())
4469        .filter(|root| {
4470            !resolved_inputs
4471                .iter()
4472                .any(|(input, _is_directory)| input.starts_with(root))
4473        })
4474        .collect::<Vec<_>>();
4475    if safe_generated_roots
4476        .iter()
4477        .any(|root| shared_target.starts_with(root))
4478    {
4479        return Ok(());
4480    }
4481
4482    for (input, is_directory) in resolved_inputs {
4483        if shared_target == input
4484            || (is_directory && shared_target.starts_with(&input))
4485            || input.starts_with(&shared_target)
4486        {
4487            return Err(WasmBuildError::InvalidSpec {
4488                message: format!(
4489                    "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4490                    shared_target.display()
4491                ),
4492            });
4493        }
4494    }
4495    Ok(())
4496}
4497
4498pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4499    let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4500        return None;
4501    };
4502    Some(if target_dir.is_absolute() {
4503        target_dir.clone()
4504    } else {
4505        spec.workspace_root.join(target_dir)
4506    })
4507}
4508
4509fn shared_incremental_target_exists(
4510    spec: &WasmBuildSpec,
4511    operation: &'static str,
4512) -> Result<bool, WasmBuildError> {
4513    let target_dir =
4514        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4515            message: "shared incremental target is not configured".to_owned(),
4516        })?;
4517    match fs::symlink_metadata(&target_dir) {
4518        Ok(metadata) if metadata.is_dir() => Ok(true),
4519        Ok(_) => Err(WasmBuildError::InvalidSpec {
4520            message: format!(
4521                "shared incremental Cargo target {} must be a directory",
4522                target_dir.display()
4523            ),
4524        }),
4525        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4526        Err(source) => Err(WasmBuildError::Io {
4527            operation,
4528            path: target_dir,
4529            source,
4530        }),
4531    }
4532}
4533
4534fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
4535    let mut names = spec.inherited_env.clone();
4536    names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
4537    let mut environment = names
4538        .into_iter()
4539        .map(|name| {
4540            let value = std::env::var_os(&name);
4541            (name, value)
4542        })
4543        .collect::<BTreeMap<_, _>>();
4544    for (key, value) in &spec.extra_env {
4545        environment.insert(key.clone(), Some(value.clone()));
4546    }
4547    environment
4548}
4549
4550fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4551    for (key, value) in &spec.extra_env {
4552        command.env(key, value);
4553    }
4554}
4555
4556fn run_cargo_build(
4557    spec: &WasmBuildSpec,
4558    build_target_dir: &Path,
4559    progress: &mut ProgressReporter<'_>,
4560) -> Result<(), WasmBuildError> {
4561    let absolute_target_dir =
4562        canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4563            operation: "resolve Cargo build target directory",
4564            path: build_target_dir.to_owned(),
4565            source,
4566        })?;
4567    let mut command = Command::new(&spec.cargo_program);
4568    command
4569        .current_dir(&spec.workspace_root)
4570        .env("CARGO_TARGET_DIR", absolute_target_dir)
4571        .args(["build", "--target", &spec.target])
4572        .args(&spec.cargo_profile_args);
4573    apply_command_environment(&mut command, spec);
4574    for package in &spec.packages {
4575        command.args(["-p", package]);
4576    }
4577
4578    if !progress.is_observed() {
4579        let output = command
4580            .output()
4581            .map_err(|source| WasmBuildError::CommandSpawn {
4582                phase: WasmBuildPhase::CargoBuild,
4583                program: spec.cargo_program.clone(),
4584                source,
4585            })?;
4586        return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
4587    }
4588
4589    run_observed_cargo_build(spec, build_target_dir, command, progress)
4590}
4591
4592fn run_observed_cargo_build(
4593    spec: &WasmBuildSpec,
4594    build_target_dir: &Path,
4595    mut command: Command,
4596    progress: &mut ProgressReporter<'_>,
4597) -> Result<(), WasmBuildError> {
4598    command.stdout(Stdio::piped()).stderr(Stdio::piped());
4599    let started = Instant::now();
4600    let child = command
4601        .spawn()
4602        .map_err(|source| WasmBuildError::CommandSpawn {
4603            phase: WasmBuildPhase::CargoBuild,
4604            program: spec.cargo_program.clone(),
4605            source,
4606        })?;
4607    let mut child = ObservedChild::new(child);
4608    progress.emit(WasmBuildProgressEvent::CargoStarted {
4609        target_dir: build_target_dir.to_owned(),
4610    });
4611
4612    let stdout = child
4613        .child_mut()
4614        .stdout
4615        .take()
4616        .expect("Cargo stdout must be piped");
4617    let stderr = child
4618        .child_mut()
4619        .stderr
4620        .take()
4621        .expect("Cargo stderr must be piped");
4622    let (sender, chunks) = mpsc::channel();
4623    let stdout_sender = sender.clone();
4624    let stdout_reader = thread::spawn(move || {
4625        read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
4626    });
4627    let stderr_reader =
4628        thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
4629
4630    let captured = capture_observed_cargo_output(chunks, progress, started);
4631
4632    let status = child.wait().map_err(|source| WasmBuildError::Io {
4633        operation: "wait for observed cargo build",
4634        path: PathBuf::from(&spec.cargo_program),
4635        source,
4636    })?;
4637    join_output_reader(
4638        stdout_reader,
4639        "read observed cargo stdout",
4640        &spec.cargo_program,
4641    )?;
4642    join_output_reader(
4643        stderr_reader,
4644        "read observed cargo stderr",
4645        &spec.cargo_program,
4646    )?;
4647    let elapsed = started.elapsed();
4648    progress.emit(WasmBuildProgressEvent::CargoFinished {
4649        success: status.success(),
4650        code: status.code(),
4651        elapsed,
4652    });
4653
4654    ensure_command_success(
4655        WasmBuildPhase::CargoBuild,
4656        Output {
4657            status,
4658            stdout: captured.stdout,
4659            stderr: captured.stderr,
4660        },
4661    )
4662    .map(|_| ())
4663}
4664
4665struct CapturedProcessOutput {
4666    stdout: Vec<u8>,
4667    stderr: Vec<u8>,
4668}
4669
4670fn capture_observed_cargo_output(
4671    chunks: mpsc::Receiver<ProcessOutputChunk>,
4672    progress: &mut ProgressReporter<'_>,
4673    started: Instant,
4674) -> CapturedProcessOutput {
4675    let mut stdout = Vec::new();
4676    let mut stderr = Vec::new();
4677    loop {
4678        let message = match progress.heartbeat_due_in() {
4679            Some(wait) => match chunks.recv_timeout(wait) {
4680                Ok(chunk) => Some(chunk),
4681                Err(RecvTimeoutError::Timeout) => {
4682                    progress.emit_heartbeat(WasmBuildProgressPhase::CargoBuild, started.elapsed());
4683                    None
4684                }
4685                Err(RecvTimeoutError::Disconnected) => break,
4686            },
4687            None => match chunks.recv() {
4688                Ok(chunk) => Some(chunk),
4689                Err(_) => break,
4690            },
4691        };
4692        let Some(chunk) = message else {
4693            continue;
4694        };
4695        match chunk.stream {
4696            WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
4697            WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
4698        }
4699        if progress.config.emit_cargo_output {
4700            progress.emit(WasmBuildProgressEvent::CargoOutput {
4701                stream: chunk.stream,
4702                bytes: chunk.bytes,
4703            });
4704        }
4705    }
4706    CapturedProcessOutput { stdout, stderr }
4707}
4708
4709#[derive(Debug)]
4710struct ProcessOutputChunk {
4711    stream: WasmBuildOutputStream,
4712    bytes: Vec<u8>,
4713}
4714
4715fn read_process_output<R: io::Read>(
4716    mut reader: R,
4717    stream: WasmBuildOutputStream,
4718    sender: mpsc::Sender<ProcessOutputChunk>,
4719) -> io::Result<()> {
4720    let mut buffer = [0_u8; 8 * 1024];
4721    loop {
4722        let count = match reader.read(&mut buffer) {
4723            Ok(count) => count,
4724            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
4725            Err(error) => return Err(error),
4726        };
4727        if count == 0 {
4728            return Ok(());
4729        }
4730        if sender
4731            .send(ProcessOutputChunk {
4732                stream,
4733                bytes: buffer[..count].to_vec(),
4734            })
4735            .is_err()
4736        {
4737            return Ok(());
4738        }
4739    }
4740}
4741
4742fn join_output_reader(
4743    reader: thread::JoinHandle<io::Result<()>>,
4744    operation: &'static str,
4745    cargo_program: &OsStr,
4746) -> Result<(), WasmBuildError> {
4747    let result = reader.join().map_err(|_| WasmBuildError::Io {
4748        operation,
4749        path: PathBuf::from(cargo_program),
4750        source: io::Error::other("Cargo output reader panicked"),
4751    })?;
4752    result.map_err(|source| WasmBuildError::Io {
4753        operation,
4754        path: PathBuf::from(cargo_program),
4755        source,
4756    })
4757}
4758
4759struct ObservedChild(Option<Child>);
4760
4761impl ObservedChild {
4762    const fn new(child: Child) -> Self {
4763        Self(Some(child))
4764    }
4765
4766    const fn child_mut(&mut self) -> &mut Child {
4767        self.0.as_mut().expect("observed child must be present")
4768    }
4769
4770    fn wait(&mut self) -> io::Result<ExitStatus> {
4771        let status = self.child_mut().wait()?;
4772        self.0.take();
4773        Ok(status)
4774    }
4775}
4776
4777impl Drop for ObservedChild {
4778    fn drop(&mut self) {
4779        if let Some(mut child) = self.0.take() {
4780            let _ = child.kill();
4781            let _ = child.wait();
4782        }
4783    }
4784}
4785
4786fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
4787    if output.status.success() {
4788        return Ok(output);
4789    }
4790    Err(WasmBuildError::CommandFailed {
4791        phase,
4792        status: output.status,
4793        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4794        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4795    })
4796}
4797
4798fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
4799    let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
4800    packages.sort_unstable();
4801    packages.dedup();
4802    packages
4803        .into_iter()
4804        .map(|package| {
4805            if spec.target == DEFAULT_TARGET {
4806                wasm_path(target_dir, package, &spec.profile_target_dir)
4807            } else {
4808                target_dir
4809                    .join(&spec.target)
4810                    .join(&spec.profile_target_dir)
4811                    .join(format!("{package}.wasm"))
4812            }
4813        })
4814        .collect()
4815}
4816
4817fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4818    spec.target_dir
4819        .join(".ic-testkit/wasm-targets")
4820        .join(fingerprint.to_hex())
4821}
4822
4823fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
4824    artifacts.iter().all(|path| {
4825        fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
4826            && cache_stamp_matches(path, fingerprint)
4827    })
4828}
4829
4830fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4831    artifacts
4832        .iter()
4833        .filter(|path| {
4834            fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4835        })
4836        .cloned()
4837        .collect()
4838}
4839
4840fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
4841    let stamp_path = artifact_stamp_path(artifact);
4842    let Ok(stamp) = fs::read_to_string(stamp_path) else {
4843        return false;
4844    };
4845    // A different build cannot be a hit, regardless of artifact contents.
4846    // Check its small stamp before reading and hashing the entire Wasm file.
4847    if !stamp.starts_with(&artifact_stamp_header(fingerprint)) {
4848        return false;
4849    }
4850    let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
4851        return false;
4852    };
4853    stamp == expected
4854}
4855
4856fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4857    let mut name = artifact
4858        .file_name()
4859        .map_or_else(|| OsString::from("artifact"), OsString::from);
4860    name.push(".ic-testkit-build");
4861    artifact.with_file_name(name)
4862}
4863
4864fn artifact_stamp_header(fingerprint: InputDigest) -> String {
4865    format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
4866}
4867
4868fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
4869    let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
4870    Ok(format!(
4871        "{}artifact-sha256:{artifact_digest}\n",
4872        artifact_stamp_header(fingerprint),
4873    ))
4874}
4875
4876fn publish_artifact_stamps(
4877    artifacts: &[PathBuf],
4878    fingerprint: InputDigest,
4879) -> Result<(), WasmBuildError> {
4880    for artifact in artifacts {
4881        let stamp_path = artifact_stamp_path(artifact);
4882        let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
4883            WasmBuildError::Io {
4884                operation: "hash built Wasm artifact",
4885                path: artifact.clone(),
4886                source,
4887            }
4888        })?;
4889        write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
4890            operation: "publish Wasm build stamp",
4891            path: stamp_path,
4892            source,
4893        })?;
4894    }
4895    Ok(())
4896}
4897
4898fn materialize_artifacts(
4899    cached_artifacts: &[PathBuf],
4900    artifacts: &[PathBuf],
4901    fingerprint: InputDigest,
4902) -> Result<(), WasmBuildError> {
4903    for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
4904        copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
4905            operation: "publish Wasm artifact",
4906            path: artifact.clone(),
4907            source,
4908        })?;
4909    }
4910    publish_artifact_stamps(artifacts, fingerprint)
4911}
4912
4913fn copy_wasm_artifacts(
4914    source_artifacts: &[PathBuf],
4915    cached_artifacts: &[PathBuf],
4916) -> Result<(), WasmBuildError> {
4917    for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
4918        copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4919            operation: "cache shared-incremental Wasm artifact",
4920            path: cached.clone(),
4921            source: source_error,
4922        })?;
4923    }
4924    Ok(())
4925}
4926
4927fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4928    fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4929        operation,
4930        path: path.to_owned(),
4931        source,
4932    })
4933}
4934
4935fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4936    if path.exists() {
4937        inputs.push((PathBuf::from(label), path));
4938    }
4939}
4940
4941fn required_string(value: &Value, field: &str) -> Result<String, String> {
4942    value
4943        .get(field)
4944        .and_then(Value::as_str)
4945        .map(str::to_owned)
4946        .ok_or_else(|| format!("Cargo metadata field `{field}` is missing"))
4947}
4948
4949fn optional_string(value: &Value, field: &str) -> Result<Option<String>, String> {
4950    match value.get(field) {
4951        None | Some(Value::Null) => Ok(None),
4952        Some(Value::String(value)) => Ok(Some(value.clone())),
4953        Some(_) => Err(format!(
4954            "Cargo metadata field `{field}` is not a string or null"
4955        )),
4956    }
4957}
4958
4959fn invalid_metadata(message: &str) -> WasmBuildError {
4960    WasmBuildError::InvalidMetadata {
4961        message: message.to_owned(),
4962    }
4963}
4964
4965impl WasmBuildError {
4966    fn indicates_input_change(&self) -> bool {
4967        match self {
4968            Self::InputsChangedDuringAcquisition { .. } => true,
4969            Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4970            _ => false,
4971        }
4972    }
4973}
4974
4975impl std::fmt::Display for WasmBuildPhase {
4976    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4977        formatter.write_str(match self {
4978            Self::CargoMetadata => "cargo metadata",
4979            Self::CargoIdentity => "Cargo identity",
4980            Self::RustcIdentity => "Rust compiler identity",
4981            Self::CargoBuild => "cargo build",
4982        })
4983    }
4984}
4985
4986impl std::fmt::Display for WasmBuildProgressPhase {
4987    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4988        formatter.write_str(match self {
4989            Self::ExactCacheLock => "exact cache lock",
4990            Self::CargoIdentity => "Cargo identity",
4991            Self::RustcIdentity => "Rust compiler identity",
4992            Self::CargoMetadata => "Cargo metadata",
4993            Self::InputDiscovery => "input discovery",
4994            Self::ContentHashing => "content hashing",
4995            Self::SharedTargetLock => "shared target lock",
4996            Self::SharedTargetMaintenance => "shared target maintenance",
4997            Self::CargoBuild => "Cargo build",
4998            Self::ArtifactPublication => "artifact publication",
4999            Self::ExactCacheMaintenance => "exact cache maintenance",
5000        })
5001    }
5002}
5003
5004impl std::fmt::Display for WasmBuildError {
5005    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5006        match self {
5007            Self::InvalidSpec { message } => {
5008                write!(formatter, "invalid Wasm build spec: {message}")
5009            }
5010            Self::Io {
5011                operation,
5012                path,
5013                source,
5014            } => write!(
5015                formatter,
5016                "failed to {operation} at {}: {source}",
5017                path.display()
5018            ),
5019            Self::CommandSpawn {
5020                phase,
5021                program,
5022                source,
5023            } => write!(
5024                formatter,
5025                "failed to launch {phase} using `{}`: {source}",
5026                program.to_string_lossy(),
5027            ),
5028            Self::CommandFailed {
5029                phase,
5030                status,
5031                stdout,
5032                stderr,
5033            } => write!(
5034                formatter,
5035                "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
5036            ),
5037            Self::InvalidMetadata { message } => {
5038                write!(formatter, "invalid Cargo metadata: {message}")
5039            }
5040            Self::InvalidCargoConfiguration { path, message } => write!(
5041                formatter,
5042                "invalid Cargo configuration at {}: {message}",
5043                path.display(),
5044            ),
5045            Self::MissingArtifacts { paths } => write!(
5046                formatter,
5047                "cargo build succeeded without producing: {}",
5048                paths
5049                    .iter()
5050                    .map(|path| path.display().to_string())
5051                    .collect::<Vec<_>>()
5052                    .join(", "),
5053            ),
5054            Self::InputsChangedDuringAcquisition { before, after } => write!(
5055                formatter,
5056                "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5057            ),
5058            Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5059                "the prepared Wasm input snapshot was invalidated before artifact publication",
5060            ),
5061            Self::FailedBuildCleanup {
5062                build_error,
5063                path,
5064                source,
5065            } => write!(
5066                formatter,
5067                "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5068                path.display(),
5069            ),
5070        }
5071    }
5072}
5073
5074impl std::error::Error for WasmBuildError {
5075    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5076        match self {
5077            Self::Io { source, .. }
5078            | Self::CommandSpawn { source, .. }
5079            | Self::FailedBuildCleanup { source, .. } => Some(source),
5080            _ => None,
5081        }
5082    }
5083}
5084
5085#[cfg(test)]
5086mod tests;