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