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