Skip to main content

ic_build/artifacts/
wasm_cache.rs

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