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