Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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