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