Skip to main content

ic_build/artifacts/
wasm_cache.rs

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