Skip to main content

ic_build/artifacts/
wasm_cache.rs

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