Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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