Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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