Skip to main content

ic_build/artifacts/
wasm_cache.rs

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