Skip to main content

ic_build/artifacts/
wasm_cache.rs

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