Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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