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