Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

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