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