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