Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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