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