Skip to main content

ic_build/artifacts/
wasm_cache.rs

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