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