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