Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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