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