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