Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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