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