Skip to main content

ic_build/artifacts/
wasm_cache.rs

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