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