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