Skip to main content

ic_build/artifacts/
wasm_cache.rs

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