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