Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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