Skip to main content

ic_build/artifacts/
wasm_cache.rs

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