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