Skip to main content

ic_build/artifacts/
wasm_cache.rs

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