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