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