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