Skip to main content

ic_build/artifacts/
wasm_cache.rs

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