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