Skip to main content

ic_build/artifacts/
wasm_cache.rs

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