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