Skip to main content

ic_build/artifacts/
wasm_cache.rs

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