Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

1use fs2::FileExt as _;
2use serde_json::Value;
3use std::{
4    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
5    ffi::{OsStr, OsString},
6    fs::{self, File, OpenOptions},
7    io,
8    path::{Path, PathBuf},
9    process::{Command, ExitStatus, Output},
10    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
11};
12use toml::Value as TomlValue;
13
14use super::{
15    digest::{
16        InputDigest, InputHasher, digest_bytes, digest_labeled_paths, os_bytes, write_atomic,
17    },
18    wasm::wasm_path,
19};
20
21const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
22const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
23const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
24# This file is a cache directory tag created by ic-testkit.\n\
25# For information about cache directory tags see https://bford.info/cachedir/\n";
26const CACHE_DIRECTORY_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55\n";
27const LAST_USED_FILE: &str = ".ic-testkit-last-used";
28const AUTOMATIC_ENVIRONMENT: &[&str] = &[
29    "CARGO_BUILD_RUSTC",
30    "CARGO_ENCODED_RUSTFLAGS",
31    "RUSTC",
32    "RUSTC_WRAPPER",
33    "RUSTC_WORKSPACE_WRAPPER",
34    "RUSTFLAGS",
35    "RUSTUP_TOOLCHAIN",
36];
37
38/// Complete caller-owned description of one cacheable Cargo Wasm build.
39///
40/// The package dependency closure, workspace manifest, lockfile, Cargo
41/// configuration, Rust toolchain files, target, profile arguments, explicit
42/// child environment, selected inherited environment, and additional watched
43/// inputs all contribute to the build fingerprint.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct WasmBuildSpec {
46    workspace_root: PathBuf,
47    target_dir: PathBuf,
48    packages: Vec<String>,
49    profile_target_dir: String,
50    cargo_profile_args: Vec<OsString>,
51    extra_env: BTreeMap<OsString, OsString>,
52    inherited_env: BTreeSet<OsString>,
53    additional_inputs: Vec<PathBuf>,
54    target: String,
55    cargo_program: OsString,
56    rustc_program: OsString,
57    prune_policy: Option<WasmBuildCachePrunePolicy>,
58}
59
60/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub enum WasmBuildOutcome {
63    /// Cargo ran and a new successful stamp was published.
64    Built(WasmBuildRecord),
65    /// Existing artifacts and their content-addressed stamp matched exactly.
66    Reused(WasmBuildRecord),
67}
68
69/// Details shared by built and reused Wasm outcomes.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct WasmBuildRecord {
72    fingerprint: InputDigest,
73    input_digest: InputDigest,
74    artifacts: Vec<PathBuf>,
75    timings: WasmBuildTimings,
76    maintenance: Option<WasmBuildCacheMaintenance>,
77}
78
79/// Timings for cache coordination, input resolution, and Cargo execution.
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub struct WasmBuildTimings {
82    lock_wait: Duration,
83    input_resolution: WasmInputResolutionTimings,
84    cargo_build: Option<Duration>,
85    cache_maintenance: Option<Duration>,
86    total: Duration,
87}
88
89/// Detailed timings for exact Wasm build-input resolution.
90#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
91pub struct WasmInputResolutionTimings {
92    tool_identity: Duration,
93    cargo_metadata: Duration,
94    input_discovery: Duration,
95    content_hashing: Duration,
96    total: Duration,
97}
98
99/// Nonfatal cache maintenance attempted as part of a Wasm build acquisition.
100#[non_exhaustive]
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum WasmBuildCacheMaintenance {
103    /// Configured retention completed under the existing build lock.
104    Pruned(WasmBuildCachePruneReport),
105    /// Configured retention failed after the requested artifacts were ready.
106    PruneFailed {
107        /// Cache error rendered without invalidating the successful build.
108        message: String,
109    },
110}
111
112/// Caller-selected retention limits for content-addressed Cargo target directories.
113///
114/// Age pruning runs before size pruning. A policy without either limit scans
115/// the cache and writes its cache-directory tag without removing entries.
116#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
117pub struct WasmBuildCachePrunePolicy {
118    max_age: Option<Duration>,
119    max_size_bytes: Option<u64>,
120}
121
122/// Summary of one lock-coordinated Wasm build-cache pruning pass.
123#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
124pub struct WasmBuildCachePruneReport {
125    entries_scanned: usize,
126    entries_removed: usize,
127    bytes_before: u64,
128    bytes_removed: u64,
129}
130
131/// External phase associated with a cacheable Wasm build failure.
132#[non_exhaustive]
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum WasmBuildPhase {
135    /// Resolving Cargo's package graph.
136    CargoMetadata,
137    /// Reading the Cargo executable identity.
138    CargoIdentity,
139    /// Reading the Rust compiler identity.
140    RustcIdentity,
141    /// Compiling the selected Wasm packages.
142    CargoBuild,
143}
144
145/// Structured failure from a cacheable Wasm build.
146#[non_exhaustive]
147#[derive(Debug)]
148pub enum WasmBuildError {
149    /// The caller supplied an incomplete or inconsistent specification.
150    InvalidSpec { message: String },
151    /// A filesystem operation failed.
152    Io {
153        operation: &'static str,
154        path: PathBuf,
155        source: io::Error,
156    },
157    /// An external command could not be launched.
158    CommandSpawn {
159        phase: WasmBuildPhase,
160        program: OsString,
161        source: io::Error,
162    },
163    /// An external command completed unsuccessfully.
164    CommandFailed {
165        phase: WasmBuildPhase,
166        status: ExitStatus,
167        stdout: String,
168        stderr: String,
169    },
170    /// Cargo metadata did not contain the expected package graph.
171    InvalidMetadata { message: String },
172    /// A discovered Cargo configuration could not be interpreted exactly.
173    InvalidCargoConfiguration { path: PathBuf, message: String },
174    /// Cargo succeeded without producing every declared Wasm artifact.
175    MissingArtifacts { paths: Vec<PathBuf> },
176    /// Declared inputs changed while Cargo was building.
177    InputsChangedDuringBuild {
178        before: InputDigest,
179        after: InputDigest,
180    },
181    /// A build failed and its incomplete fingerprint directory could not be removed.
182    FailedBuildCleanup {
183        build_error: Box<Self>,
184        path: PathBuf,
185        source: io::Error,
186    },
187}
188
189impl WasmBuildSpec {
190    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
191    ///
192    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
193    /// `release`, or the name supplied to `--profile`.
194    #[must_use]
195    pub fn new(
196        workspace_root: &Path,
197        target_dir: &Path,
198        packages: &[&str],
199        profile_target_dir: &str,
200    ) -> Self {
201        Self {
202            workspace_root: workspace_root.to_owned(),
203            target_dir: target_dir.to_owned(),
204            packages: packages
205                .iter()
206                .map(|package| (*package).to_owned())
207                .collect(),
208            profile_target_dir: profile_target_dir.to_owned(),
209            cargo_profile_args: Vec::new(),
210            extra_env: BTreeMap::new(),
211            inherited_env: BTreeSet::new(),
212            additional_inputs: Vec::new(),
213            target: DEFAULT_TARGET.to_owned(),
214            cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
215            rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
216            prune_policy: None,
217        }
218    }
219
220    /// Set Cargo profile and feature arguments used for both the build and fingerprint.
221    #[must_use]
222    pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
223        self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
224        self
225    }
226
227    /// Set deterministic child-process environment overrides.
228    #[must_use]
229    pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
230        self.extra_env = environment
231            .iter()
232            .map(|(key, value)| (OsString::from(key), OsString::from(value)))
233            .collect();
234        self
235    }
236
237    /// Add ambient environment variables whose current values affect the build.
238    ///
239    /// Common Rust and Cargo toolchain variables are included automatically.
240    /// Callers must declare application-specific variables read by build scripts.
241    #[must_use]
242    pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
243        self.inherited_env.extend(names.iter().map(OsString::from));
244        self
245    }
246
247    /// Add files or directories not discoverable through Cargo's local dependency graph.
248    ///
249    /// Relative paths are resolved from the workspace root. Use this for build
250    /// script configuration, generated schemas, or other externally read inputs.
251    #[must_use]
252    pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
253        self.additional_inputs
254            .extend(paths.iter().map(PathBuf::from));
255        self
256    }
257
258    /// Override the Cargo compilation target.
259    #[must_use]
260    pub fn with_target(mut self, target: &str) -> Self {
261        target.clone_into(&mut self.target);
262        self
263    }
264
265    /// Override the Cargo executable used by metadata, identity, and build commands.
266    #[must_use]
267    pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
268        self.cargo_program = program.into();
269        self
270    }
271
272    /// Override the Rust compiler executable used to fingerprint the toolchain.
273    #[must_use]
274    pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
275        self.rustc_program = program.into();
276        self
277    }
278
279    /// Apply cache retention under the build operation's existing process lock.
280    ///
281    /// Maintenance is best-effort: its structured result is attached to the
282    /// successful build record and cannot turn ready artifacts into a build
283    /// failure. The active fingerprint is protected from this pruning pass.
284    #[must_use]
285    pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
286        self.prune_policy = Some(policy);
287        self
288    }
289
290    /// Workspace containing the selected Cargo packages.
291    #[must_use]
292    pub fn workspace_root(&self) -> &Path {
293        &self.workspace_root
294    }
295
296    /// Cargo target directory containing artifacts, lock, and stamps.
297    #[must_use]
298    pub fn target_dir(&self) -> &Path {
299        &self.target_dir
300    }
301
302    /// Selected Cargo package names.
303    #[must_use]
304    pub fn packages(&self) -> &[String] {
305        &self.packages
306    }
307}
308
309impl WasmBuildOutcome {
310    /// Read the common build record.
311    #[must_use]
312    pub const fn record(&self) -> &WasmBuildRecord {
313        match self {
314            Self::Built(record) | Self::Reused(record) => record,
315        }
316    }
317
318    /// Report whether exact matching artifacts were reused.
319    #[must_use]
320    pub const fn is_reused(&self) -> bool {
321        matches!(self, Self::Reused(_))
322    }
323}
324
325impl WasmBuildRecord {
326    /// Exact build fingerprint used by the atomic cache stamp.
327    #[must_use]
328    pub const fn fingerprint(&self) -> InputDigest {
329        self.fingerprint
330    }
331
332    /// Exact digest of package sources, lockfile, and configuration inputs.
333    #[must_use]
334    pub const fn input_digest(&self) -> InputDigest {
335        self.input_digest
336    }
337
338    /// Expected Wasm artifacts produced or reused by the build.
339    #[must_use]
340    pub fn artifacts(&self) -> &[PathBuf] {
341        &self.artifacts
342    }
343
344    /// Phase timings captured by the cacheable build operation.
345    #[must_use]
346    pub const fn timings(&self) -> WasmBuildTimings {
347        self.timings
348    }
349
350    /// Cache maintenance attempted under the build lock, when configured.
351    #[must_use]
352    pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
353        self.maintenance.as_ref()
354    }
355}
356
357impl WasmBuildCacheMaintenance {
358    /// Successful pruning report, or `None` when maintenance failed.
359    #[must_use]
360    pub const fn prune_report(&self) -> Option<WasmBuildCachePruneReport> {
361        match self {
362            Self::Pruned(report) => Some(*report),
363            Self::PruneFailed { .. } => None,
364        }
365    }
366
367    /// Rendered maintenance failure, or `None` when pruning succeeded.
368    #[must_use]
369    pub fn failure_message(&self) -> Option<&str> {
370        match self {
371            Self::Pruned(_) => None,
372            Self::PruneFailed { message } => Some(message),
373        }
374    }
375}
376
377impl WasmBuildTimings {
378    /// Time spent waiting for the output-directory process lock.
379    #[must_use]
380    pub const fn lock_wait(self) -> Duration {
381        self.lock_wait
382    }
383
384    /// Time spent resolving toolchain identity, Cargo metadata, and exact inputs.
385    #[must_use]
386    pub const fn input_resolution(self) -> Duration {
387        self.input_resolution.total
388    }
389
390    /// Detailed tool, metadata, discovery, and hashing timings.
391    #[must_use]
392    pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
393        self.input_resolution
394    }
395
396    /// Time spent in `cargo build`, or `None` for a cache hit.
397    #[must_use]
398    pub const fn cargo_build(self) -> Option<Duration> {
399        self.cargo_build
400    }
401
402    /// Time spent on configured best-effort cache maintenance.
403    #[must_use]
404    pub const fn cache_maintenance(self) -> Option<Duration> {
405        self.cache_maintenance
406    }
407
408    /// Total operation duration, including lock coordination.
409    #[must_use]
410    pub const fn total(self) -> Duration {
411        self.total
412    }
413}
414
415impl WasmInputResolutionTimings {
416    /// Time spent reading Cargo and rustc identities.
417    #[must_use]
418    pub const fn tool_identity(self) -> Duration {
419        self.tool_identity
420    }
421
422    /// Time spent running and decoding `cargo metadata`.
423    #[must_use]
424    pub const fn cargo_metadata(self) -> Duration {
425        self.cargo_metadata
426    }
427
428    /// Time spent resolving packages, configuration, and watched paths.
429    #[must_use]
430    pub const fn input_discovery(self) -> Duration {
431        self.input_discovery
432    }
433
434    /// Time spent reading and hashing exact input contents.
435    #[must_use]
436    pub const fn content_hashing(self) -> Duration {
437        self.content_hashing
438    }
439
440    /// Complete input-resolution duration.
441    #[must_use]
442    pub const fn total(self) -> Duration {
443        self.total
444    }
445
446    const fn include(&mut self, other: Self) {
447        self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
448        self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
449        self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
450        self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
451        self.total = self.total.saturating_add(other.total);
452    }
453}
454
455impl WasmBuildCachePrunePolicy {
456    /// Create a policy that records cache metadata without removing entries.
457    #[must_use]
458    pub const fn new() -> Self {
459        Self {
460            max_age: None,
461            max_size_bytes: None,
462        }
463    }
464
465    /// Remove entries older than `max_age` before applying the size limit.
466    #[must_use]
467    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
468        self.max_age = Some(max_age);
469        self
470    }
471
472    /// Remove least-recently-used entries until retained logical size is at most `bytes`.
473    #[must_use]
474    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
475        self.max_size_bytes = Some(bytes);
476        self
477    }
478
479    /// Configured maximum entry age, if any.
480    #[must_use]
481    pub const fn max_age(self) -> Option<Duration> {
482        self.max_age
483    }
484
485    /// Configured maximum logical cache size in bytes, if any.
486    #[must_use]
487    pub const fn max_size_bytes(self) -> Option<u64> {
488        self.max_size_bytes
489    }
490}
491
492impl WasmBuildCachePruneReport {
493    /// Number of fingerprint directories considered for pruning.
494    #[must_use]
495    pub const fn entries_scanned(self) -> usize {
496        self.entries_scanned
497    }
498
499    /// Number of fingerprint directories removed.
500    #[must_use]
501    pub const fn entries_removed(self) -> usize {
502        self.entries_removed
503    }
504
505    /// Number of fingerprint directories retained.
506    #[must_use]
507    pub const fn entries_retained(self) -> usize {
508        self.entries_scanned - self.entries_removed
509    }
510
511    /// Logical bytes occupied by scanned entries before pruning.
512    #[must_use]
513    pub const fn bytes_before(self) -> u64 {
514        self.bytes_before
515    }
516
517    /// Logical bytes removed by pruning.
518    #[must_use]
519    pub const fn bytes_removed(self) -> u64 {
520        self.bytes_removed
521    }
522
523    /// Logical bytes occupied by retained entries after pruning.
524    #[must_use]
525    pub const fn bytes_retained(self) -> u64 {
526        self.bytes_before - self.bytes_removed
527    }
528}
529
530/// Build or reuse one exact set of Cargo Wasm artifacts.
531///
532/// The operation takes an exclusive process lock scoped to `target_dir`, then
533/// fingerprints all declared inputs. A cache hit requires both a matching
534/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
535/// builds never publish a successful stamp.
536pub fn build_wasm_canisters_cached(
537    spec: &WasmBuildSpec,
538) -> Result<WasmBuildOutcome, WasmBuildError> {
539    let total_started = Instant::now();
540    validate_spec(spec)?;
541    let (_lock_file, lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
542    ensure_cache_directory_tag(&spec.target_dir)?;
543
544    let resolved = build_fingerprint(spec)?;
545    let mut input_resolution = resolved.timings;
546    let fingerprint = resolved.fingerprint;
547    let artifacts = expected_artifacts(spec, &spec.target_dir);
548    let build_target_dir = spec
549        .target_dir
550        .join(".ic-testkit/wasm-targets")
551        .join(fingerprint.to_hex());
552
553    if artifact_set_matches(&artifacts, fingerprint) {
554        record_cache_entry_use_if_present(&build_target_dir)?;
555        return Ok(WasmBuildOutcome::Reused(complete_build_record(
556            spec,
557            BuildRecordInput {
558                fingerprint,
559                input_digest: resolved.input_digest,
560                artifacts,
561                lock_wait,
562                input_resolution,
563                cargo_build: None,
564                active_entry: &build_target_dir,
565            },
566            total_started,
567        )));
568    }
569
570    let cached_artifacts = expected_artifacts(spec, &build_target_dir);
571    if artifact_set_matches(&cached_artifacts, fingerprint) {
572        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
573        record_cache_entry_use(&build_target_dir)?;
574        return Ok(WasmBuildOutcome::Reused(complete_build_record(
575            spec,
576            BuildRecordInput {
577                fingerprint,
578                input_digest: resolved.input_digest,
579                artifacts,
580                lock_wait,
581                input_resolution,
582                cargo_build: None,
583                active_entry: &build_target_dir,
584            },
585            total_started,
586        )));
587    }
588
589    remove_directory_if_present(&build_target_dir)?;
590    create_dir_all(
591        &build_target_dir,
592        "create content-addressed Cargo target directory",
593    )?;
594    let incomplete_directory = IncompleteBuildDirectory::new(build_target_dir.clone());
595    let build_result = (|| {
596        let build_started = Instant::now();
597        run_cargo_build(spec, &build_target_dir)?;
598        let cargo_build = build_started.elapsed();
599        let missing = missing_artifacts(&cached_artifacts);
600        if !missing.is_empty() {
601            return Err(WasmBuildError::MissingArtifacts { paths: missing });
602        }
603
604        let verified = build_fingerprint(spec)?;
605        input_resolution.include(verified.timings);
606        if fingerprint != verified.fingerprint {
607            return Err(WasmBuildError::InputsChangedDuringBuild {
608                before: fingerprint,
609                after: verified.fingerprint,
610            });
611        }
612
613        publish_artifact_stamps(&cached_artifacts, fingerprint)?;
614        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
615        record_cache_entry_use(&build_target_dir)?;
616
617        Ok(WasmBuildOutcome::Built(complete_build_record(
618            spec,
619            BuildRecordInput {
620                fingerprint,
621                input_digest: resolved.input_digest,
622                artifacts,
623                lock_wait,
624                input_resolution,
625                cargo_build: Some(cargo_build),
626                active_entry: &build_target_dir,
627            },
628            total_started,
629        )))
630    })();
631    finish_fingerprint_build(build_result, incomplete_directory)
632}
633
634/// Prune fingerprint-specific Cargo target directories under `target_dir`.
635///
636/// Pruning uses the same exclusive process lock as builds. Entries older than
637/// the configured age are removed first, then least-recently-used entries are
638/// removed until the configured logical byte limit is met. Only direct child
639/// directories with SHA-256 fingerprint names are eligible; caller-facing
640/// artifacts and unrelated target contents are never removed.
641pub fn prune_wasm_build_cache(
642    target_dir: &Path,
643    policy: WasmBuildCachePrunePolicy,
644) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
645    let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
646    ensure_cache_directory_tag(target_dir)?;
647
648    prune_wasm_build_cache_locked(target_dir, policy, None)
649}
650
651struct BuildRecordInput<'a> {
652    fingerprint: InputDigest,
653    input_digest: InputDigest,
654    artifacts: Vec<PathBuf>,
655    lock_wait: Duration,
656    input_resolution: WasmInputResolutionTimings,
657    cargo_build: Option<Duration>,
658    active_entry: &'a Path,
659}
660
661fn complete_build_record(
662    spec: &WasmBuildSpec,
663    input: BuildRecordInput<'_>,
664    total_started: Instant,
665) -> WasmBuildRecord {
666    let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
667        let started = Instant::now();
668        let result =
669            prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry));
670        let elapsed = started.elapsed();
671        let maintenance = match result {
672            Ok(report) => WasmBuildCacheMaintenance::Pruned(report),
673            Err(error) => WasmBuildCacheMaintenance::PruneFailed {
674                message: error.to_string(),
675            },
676        };
677        (Some(maintenance), Some(elapsed))
678    });
679    WasmBuildRecord {
680        fingerprint: input.fingerprint,
681        input_digest: input.input_digest,
682        artifacts: input.artifacts,
683        timings: WasmBuildTimings {
684            lock_wait: input.lock_wait,
685            input_resolution: input.input_resolution,
686            cargo_build: input.cargo_build,
687            cache_maintenance,
688            total: total_started.elapsed(),
689        },
690        maintenance,
691    }
692}
693
694fn prune_wasm_build_cache_locked(
695    target_dir: &Path,
696    policy: WasmBuildCachePrunePolicy,
697    protected_entry: Option<&Path>,
698) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
699    let cache_root = target_dir.join(".ic-testkit/wasm-targets");
700    let mut entries = cache_entries(&cache_root)?;
701    let bytes_before = entries
702        .iter()
703        .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
704    let entries_scanned = entries.len();
705    let now = SystemTime::now();
706    let mut report = WasmBuildCachePruneReport {
707        entries_scanned,
708        entries_removed: 0,
709        bytes_before,
710        bytes_removed: 0,
711    };
712
713    if let Some(max_age) = policy.max_age {
714        for entry in &mut entries {
715            let age = now.duration_since(entry.last_used).unwrap_or_default();
716            if protected_entry != Some(entry.path.as_path()) && age > max_age {
717                remove_cache_entry(entry, &mut report)?;
718            }
719        }
720    }
721
722    if let Some(max_size_bytes) = policy.max_size_bytes {
723        entries.sort_by(|left, right| {
724            left.last_used
725                .cmp(&right.last_used)
726                .then_with(|| left.path.cmp(&right.path))
727        });
728        for entry in &mut entries {
729            if report.bytes_retained() <= max_size_bytes {
730                break;
731            }
732            if protected_entry == Some(entry.path.as_path()) {
733                continue;
734            }
735            remove_cache_entry(entry, &mut report)?;
736        }
737    }
738
739    Ok(report)
740}
741
742struct CacheEntry {
743    path: PathBuf,
744    bytes: u64,
745    last_used: SystemTime,
746    removed: bool,
747}
748
749struct IncompleteBuildDirectory {
750    path: PathBuf,
751    armed: bool,
752}
753
754impl IncompleteBuildDirectory {
755    const fn new(path: PathBuf) -> Self {
756        Self { path, armed: true }
757    }
758
759    fn preserve(mut self) {
760        self.armed = false;
761    }
762
763    fn cleanup(mut self) -> io::Result<()> {
764        let result = remove_dir_all_if_present(&self.path);
765        if result.is_ok() {
766            self.armed = false;
767        }
768        result
769    }
770}
771
772impl Drop for IncompleteBuildDirectory {
773    fn drop(&mut self) {
774        if self.armed {
775            let _ = remove_dir_all_if_present(&self.path);
776        }
777    }
778}
779
780fn finish_fingerprint_build(
781    result: Result<WasmBuildOutcome, WasmBuildError>,
782    incomplete_directory: IncompleteBuildDirectory,
783) -> Result<WasmBuildOutcome, WasmBuildError> {
784    match result {
785        Ok(outcome) => {
786            incomplete_directory.preserve();
787            Ok(outcome)
788        }
789        Err(build_error) => {
790            let path = incomplete_directory.path.clone();
791            match incomplete_directory.cleanup() {
792                Ok(()) => Err(build_error),
793                Err(source) => Err(WasmBuildError::FailedBuildCleanup {
794                    build_error: Box::new(build_error),
795                    path,
796                    source,
797                }),
798            }
799        }
800    }
801}
802
803fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
804    create_dir_all(target_dir, "create Cargo target directory")?;
805    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
806    let lock_file = open_lock_file(&lock_path)?;
807    let lock_started = Instant::now();
808    lock_file
809        .lock_exclusive()
810        .map_err(|source| WasmBuildError::Io {
811            operation: "lock Wasm build cache",
812            path: lock_path,
813            source,
814        })?;
815    Ok((lock_file, lock_started.elapsed()))
816}
817
818fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
819    let path = target_dir.join("CACHEDIR.TAG");
820    if fs::read_to_string(&path)
821        .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
822    {
823        return Ok(());
824    }
825    write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| WasmBuildError::Io {
826        operation: "write Cargo cache directory tag",
827        path,
828        source,
829    })
830}
831
832fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
833    if path.is_dir() {
834        record_cache_entry_use(path)?;
835    }
836    Ok(())
837}
838
839fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
840    write_last_used(path, SystemTime::now())
841}
842
843fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), WasmBuildError> {
844    let elapsed = last_used
845        .duration_since(UNIX_EPOCH)
846        .map_err(|source| WasmBuildError::Io {
847            operation: "record Wasm build cache use time",
848            path: path.join(LAST_USED_FILE),
849            source: io::Error::new(io::ErrorKind::InvalidInput, source),
850        })?;
851    let timestamp = elapsed.as_nanos().to_string();
852    let marker = path.join(LAST_USED_FILE);
853    write_atomic(&marker, timestamp.as_bytes()).map_err(|source| WasmBuildError::Io {
854        operation: "record Wasm build cache use time",
855        path: marker,
856        source,
857    })
858}
859
860fn cache_entries(cache_root: &Path) -> Result<Vec<CacheEntry>, WasmBuildError> {
861    let read_dir = match fs::read_dir(cache_root) {
862        Ok(read_dir) => read_dir,
863        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
864        Err(source) => {
865            return Err(WasmBuildError::Io {
866                operation: "read Wasm build cache directory",
867                path: cache_root.to_owned(),
868                source,
869            });
870        }
871    };
872    let mut entries = Vec::new();
873    for directory_entry in read_dir {
874        let directory_entry = directory_entry.map_err(|source| WasmBuildError::Io {
875            operation: "read Wasm build cache entry",
876            path: cache_root.to_owned(),
877            source,
878        })?;
879        let path = directory_entry.path();
880        let file_type = directory_entry
881            .file_type()
882            .map_err(|source| WasmBuildError::Io {
883                operation: "inspect Wasm build cache entry",
884                path: path.clone(),
885                source,
886            })?;
887        if !file_type.is_dir() || !is_fingerprint_directory(&path) {
888            continue;
889        }
890        let bytes = directory_logical_size(&path).map_err(|source| WasmBuildError::Io {
891            operation: "measure Wasm build cache entry",
892            path: path.clone(),
893            source,
894        })?;
895        let last_used = cache_entry_last_used(&path).map_err(|source| WasmBuildError::Io {
896            operation: "read Wasm build cache use time",
897            path: path.clone(),
898            source,
899        })?;
900        entries.push(CacheEntry {
901            path,
902            bytes,
903            last_used,
904            removed: false,
905        });
906    }
907    Ok(entries)
908}
909
910fn is_fingerprint_directory(path: &Path) -> bool {
911    path.file_name().is_some_and(|name| {
912        let bytes = name.as_encoded_bytes();
913        bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
914    })
915}
916
917fn directory_logical_size(path: &Path) -> io::Result<u64> {
918    let mut total = 0_u64;
919    let mut pending = vec![path.to_owned()];
920    while let Some(current) = pending.pop() {
921        let metadata = fs::symlink_metadata(&current)?;
922        if metadata.is_dir() {
923            for entry in fs::read_dir(&current)? {
924                pending.push(entry?.path());
925            }
926        } else {
927            total = total.saturating_add(metadata.len());
928        }
929    }
930    Ok(total)
931}
932
933fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
934    let marker = path.join(LAST_USED_FILE);
935    if let Ok(contents) = fs::read_to_string(&marker)
936        && let Ok(nanoseconds) = contents.parse::<u128>()
937    {
938        let seconds = nanoseconds / 1_000_000_000;
939        let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
940        if let Ok(seconds) = u64::try_from(seconds)
941            && let Some(timestamp) = UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
942        {
943            return Ok(timestamp);
944        }
945    }
946    fs::metadata(path)?.modified()
947}
948
949fn remove_cache_entry(
950    entry: &mut CacheEntry,
951    report: &mut WasmBuildCachePruneReport,
952) -> Result<(), WasmBuildError> {
953    if entry.removed {
954        return Ok(());
955    }
956    remove_dir_all_if_present(&entry.path).map_err(|source| WasmBuildError::Io {
957        operation: "prune Wasm build cache entry",
958        path: entry.path.clone(),
959        source,
960    })?;
961    entry.removed = true;
962    report.entries_removed += 1;
963    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
964    Ok(())
965}
966
967fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
968    if spec.packages.is_empty() {
969        return Err(WasmBuildError::InvalidSpec {
970            message: "at least one Cargo package is required".to_owned(),
971        });
972    }
973    if spec.profile_target_dir.is_empty() {
974        return Err(WasmBuildError::InvalidSpec {
975            message: "Cargo profile target directory must not be empty".to_owned(),
976        });
977    }
978    if spec.target.is_empty() {
979        return Err(WasmBuildError::InvalidSpec {
980            message: "Cargo compilation target must not be empty".to_owned(),
981        });
982    }
983    Ok(())
984}
985
986struct ResolvedFingerprint {
987    fingerprint: InputDigest,
988    input_digest: InputDigest,
989    timings: WasmInputResolutionTimings,
990}
991
992fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedFingerprint, WasmBuildError> {
993    let total_started = Instant::now();
994    let tool_started = Instant::now();
995    let cargo_identity = command_identity(
996        spec,
997        WasmBuildPhase::CargoIdentity,
998        &spec.cargo_program,
999        &["--version", "--verbose"],
1000    )?;
1001    let rustc_program = spec
1002        .extra_env
1003        .get(OsStr::new("RUSTC"))
1004        .unwrap_or(&spec.rustc_program);
1005    let rustc_identity =
1006        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
1007    let tool_identity = tool_started.elapsed();
1008
1009    let metadata_started = Instant::now();
1010    let metadata = cargo_metadata(spec)?;
1011    let cargo_metadata = metadata_started.elapsed();
1012
1013    let discovery_started = Instant::now();
1014    let inputs = resolve_local_inputs(spec, &metadata)?;
1015    let exclusions = source_exclusions(spec, &inputs);
1016    let input_discovery = discovery_started.elapsed();
1017
1018    let hashing_started = Instant::now();
1019    let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
1020        .map_err(|source| WasmBuildError::Io {
1021            operation: "hash Wasm build inputs",
1022            path: spec.workspace_root.clone(),
1023            source,
1024        })?;
1025    let content_hashing = hashing_started.elapsed();
1026
1027    let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
1028    let mut packages = spec.packages.clone();
1029    packages.sort();
1030    packages.dedup();
1031    for package in packages {
1032        hasher.field("package", package.as_bytes());
1033    }
1034    hasher.field("target", spec.target.as_bytes());
1035    hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
1036    for argument in &spec.cargo_profile_args {
1037        hasher.field("cargo-argument", &os_bytes(argument));
1038    }
1039    for (key, value) in effective_environment(spec) {
1040        hasher.field("environment-key", &os_bytes(&key));
1041        if let Some(value) = value {
1042            hasher.field("environment-value", &os_bytes(&value));
1043        } else {
1044            hasher.field("environment-unset", b"");
1045        }
1046    }
1047    hasher.field("cargo-identity", &cargo_identity);
1048    hasher.field("rustc-identity", &rustc_identity);
1049    hasher.field("source-input-digest", input_digest.as_bytes());
1050    Ok(ResolvedFingerprint {
1051        fingerprint: hasher.finish(),
1052        input_digest,
1053        timings: WasmInputResolutionTimings {
1054            tool_identity,
1055            cargo_metadata,
1056            input_discovery,
1057            content_hashing,
1058            total: total_started.elapsed(),
1059        },
1060    })
1061}
1062
1063fn command_identity(
1064    spec: &WasmBuildSpec,
1065    phase: WasmBuildPhase,
1066    program: &OsStr,
1067    arguments: &[&str],
1068) -> Result<Vec<u8>, WasmBuildError> {
1069    let mut command = Command::new(program);
1070    command.current_dir(&spec.workspace_root).args(arguments);
1071    apply_command_environment(&mut command, spec);
1072    let output = command
1073        .output()
1074        .map_err(|source| WasmBuildError::CommandSpawn {
1075            phase,
1076            program: program.to_owned(),
1077            source,
1078        })?;
1079    ensure_command_success(phase, output).map(|output| {
1080        let mut identity = output.stdout;
1081        identity.extend_from_slice(&output.stderr);
1082        identity
1083    })
1084}
1085
1086fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
1087    let mut command = Command::new(&spec.cargo_program);
1088    command
1089        .current_dir(&spec.workspace_root)
1090        .args(["metadata", "--format-version", "1"]);
1091    for argument in metadata_arguments(&spec.cargo_profile_args) {
1092        command.arg(argument);
1093    }
1094    apply_command_environment(&mut command, spec);
1095    let output = command
1096        .output()
1097        .map_err(|source| WasmBuildError::CommandSpawn {
1098            phase: WasmBuildPhase::CargoMetadata,
1099            program: spec.cargo_program.clone(),
1100            source,
1101        })?;
1102    let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
1103    serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
1104        message: format!("Cargo metadata was not valid JSON: {error}"),
1105    })
1106}
1107
1108fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
1109    let mut selected = Vec::new();
1110    let mut arguments = arguments.iter();
1111    while let Some(argument) = arguments.next() {
1112        let argument_text = argument.to_string_lossy();
1113        match argument_text.as_ref() {
1114            "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
1115                selected.push(argument.clone());
1116            }
1117            "--features" | "-F" | "--filter-platform" => {
1118                selected.push(argument.clone());
1119                if let Some(value) = arguments.next() {
1120                    selected.push(value.clone());
1121                }
1122            }
1123            _ if argument_text.starts_with("--features=")
1124                || argument_text.starts_with("--filter-platform=") =>
1125            {
1126                selected.push(argument.clone());
1127            }
1128            _ => {}
1129        }
1130    }
1131    selected
1132}
1133
1134#[derive(Clone)]
1135struct MetadataPackage {
1136    id: String,
1137    name: String,
1138    version: String,
1139    manifest_path: PathBuf,
1140    is_local: bool,
1141}
1142
1143fn resolve_local_inputs(
1144    spec: &WasmBuildSpec,
1145    metadata: &Value,
1146) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1147    let packages = metadata_packages(metadata)?;
1148    let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
1149    let dependencies = metadata_dependencies(metadata)?;
1150    let mut closure = BTreeSet::new();
1151    while let Some(id) = selected_ids.pop_front() {
1152        if !closure.insert(id.clone()) {
1153            continue;
1154        }
1155        if let Some(deps) = dependencies.get(&id) {
1156            selected_ids.extend(deps.iter().cloned());
1157        }
1158    }
1159
1160    let workspace_root = metadata
1161        .get("workspace_root")
1162        .and_then(Value::as_str)
1163        .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
1164    let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
1165    append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
1166    append_additional_inputs(&mut inputs, spec, &workspace_root);
1167    Ok(inputs)
1168}
1169
1170fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
1171    let packages_value = metadata
1172        .get("packages")
1173        .and_then(Value::as_array)
1174        .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
1175    let mut packages = HashMap::new();
1176    for value in packages_value {
1177        let package = MetadataPackage {
1178            id: required_string(value, "id")?,
1179            name: required_string(value, "name")?,
1180            version: required_string(value, "version")?,
1181            manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
1182            is_local: value.get("source").is_some_and(Value::is_null),
1183        };
1184        packages.insert(package.id.clone(), package);
1185    }
1186    Ok(packages)
1187}
1188
1189fn selected_package_ids(
1190    spec: &WasmBuildSpec,
1191    metadata: &Value,
1192    packages: &HashMap<String, MetadataPackage>,
1193) -> Result<VecDeque<String>, WasmBuildError> {
1194    let workspace_members = metadata
1195        .get("workspace_members")
1196        .and_then(Value::as_array)
1197        .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
1198        .iter()
1199        .filter_map(Value::as_str)
1200        .collect::<HashSet<_>>();
1201    let mut selected_ids = VecDeque::new();
1202    for requested in &spec.packages {
1203        let matches = packages
1204            .values()
1205            .filter(|package| {
1206                package.name == *requested && workspace_members.contains(package.id.as_str())
1207            })
1208            .map(|package| package.id.clone())
1209            .collect::<Vec<_>>();
1210        match matches.as_slice() {
1211            [id] => selected_ids.push_back(id.clone()),
1212            [] => {
1213                return Err(WasmBuildError::InvalidSpec {
1214                    message: format!("Cargo workspace contains no package named `{requested}`"),
1215                });
1216            }
1217            _ => {
1218                return Err(WasmBuildError::InvalidSpec {
1219                    message: format!("Cargo workspace package name `{requested}` is ambiguous"),
1220                });
1221            }
1222        }
1223    }
1224    Ok(selected_ids)
1225}
1226
1227fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
1228    let mut dependencies = HashMap::<String, Vec<String>>::new();
1229    let nodes = metadata
1230        .pointer("/resolve/nodes")
1231        .and_then(Value::as_array)
1232        .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
1233    for node in nodes {
1234        let id = required_string(node, "id")?;
1235        let deps = node
1236            .get("deps")
1237            .and_then(Value::as_array)
1238            .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
1239            .iter()
1240            .map(|dependency| required_string(dependency, "pkg"))
1241            .collect::<Result<Vec<_>, _>>()?;
1242        dependencies.insert(id, deps);
1243    }
1244    Ok(dependencies)
1245}
1246
1247fn workspace_configuration_inputs(
1248    spec: &WasmBuildSpec,
1249    workspace_root: &Path,
1250) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1251    let mut inputs = Vec::new();
1252    add_if_present(
1253        &mut inputs,
1254        "workspace/Cargo.toml",
1255        workspace_root.join("Cargo.toml"),
1256    );
1257    add_if_present(
1258        &mut inputs,
1259        "workspace/Cargo.lock",
1260        workspace_root.join("Cargo.lock"),
1261    );
1262    add_if_present(
1263        &mut inputs,
1264        "workspace/rust-toolchain.toml",
1265        workspace_root.join("rust-toolchain.toml"),
1266    );
1267    add_if_present(
1268        &mut inputs,
1269        "workspace/rust-toolchain",
1270        workspace_root.join("rust-toolchain"),
1271    );
1272    append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
1273    Ok(inputs)
1274}
1275
1276fn append_cargo_configuration_inputs(
1277    inputs: &mut Vec<(PathBuf, PathBuf)>,
1278    spec: &WasmBuildSpec,
1279    workspace_root: &Path,
1280) -> Result<(), WasmBuildError> {
1281    let invocation_root =
1282        spec.workspace_root
1283            .canonicalize()
1284            .map_err(|source| WasmBuildError::Io {
1285                operation: "resolve Cargo invocation directory",
1286                path: spec.workspace_root.clone(),
1287                source,
1288            })?;
1289    let canonical_workspace =
1290        workspace_root
1291            .canonicalize()
1292            .map_err(|source| WasmBuildError::Io {
1293                operation: "resolve Cargo workspace directory",
1294                path: workspace_root.to_owned(),
1295                source,
1296            })?;
1297
1298    let mut roots = invocation_root
1299        .ancestors()
1300        .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
1301        .collect::<Vec<_>>();
1302    if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
1303        && let Some(config) = effective_cargo_config(&cargo_home)
1304    {
1305        roots.push(config);
1306    }
1307
1308    let mut visited = BTreeSet::new();
1309    for config in roots {
1310        append_cargo_configuration_tree(
1311            inputs,
1312            &config,
1313            &canonical_workspace,
1314            &mut visited,
1315            false,
1316        )?;
1317    }
1318    Ok(())
1319}
1320
1321fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
1322    let extensionless = directory.join("config");
1323    if extensionless.exists() {
1324        return Some(extensionless);
1325    }
1326    let toml = directory.join("config.toml");
1327    toml.exists().then_some(toml)
1328}
1329
1330fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
1331    if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
1332        let cargo_home = PathBuf::from(cargo_home);
1333        return Some(if cargo_home.is_absolute() {
1334            cargo_home
1335        } else {
1336            invocation_root.join(cargo_home)
1337        });
1338    }
1339
1340    default_home_directory(spec).map(|home| {
1341        let home = if home.is_absolute() {
1342            home
1343        } else {
1344            invocation_root.join(home)
1345        };
1346        home.join(".cargo")
1347    })
1348}
1349
1350#[cfg(windows)]
1351fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
1352    command_environment_value(spec, "USERPROFILE")
1353        .or_else(|| command_environment_value(spec, "HOME"))
1354        .map(PathBuf::from)
1355}
1356
1357#[cfg(not(windows))]
1358fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
1359    command_environment_value(spec, "HOME").map(PathBuf::from)
1360}
1361
1362fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
1363    spec.extra_env
1364        .get(OsStr::new(name))
1365        .cloned()
1366        .or_else(|| std::env::var_os(name))
1367}
1368
1369fn append_cargo_configuration_tree(
1370    inputs: &mut Vec<(PathBuf, PathBuf)>,
1371    config: &Path,
1372    workspace_root: &Path,
1373    visited: &mut BTreeSet<PathBuf>,
1374    optional: bool,
1375) -> Result<(), WasmBuildError> {
1376    let canonical = match config.canonicalize() {
1377        Ok(canonical) => canonical,
1378        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
1379        Err(source) => {
1380            return Err(WasmBuildError::Io {
1381                operation: "resolve Cargo configuration",
1382                path: config.to_owned(),
1383                source,
1384            });
1385        }
1386    };
1387    if !visited.insert(canonical.clone()) {
1388        return Ok(());
1389    }
1390
1391    let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
1392        operation: "read Cargo configuration",
1393        path: canonical.clone(),
1394        source,
1395    })?;
1396    let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
1397        WasmBuildError::InvalidCargoConfiguration {
1398            path: canonical.clone(),
1399            message: error.to_string(),
1400        }
1401    })?;
1402    inputs.push((
1403        cargo_configuration_label(&canonical, workspace_root),
1404        canonical.clone(),
1405    ));
1406
1407    let Some(include) = configuration.get("include") else {
1408        return Ok(());
1409    };
1410    let parent = canonical
1411        .parent()
1412        .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
1413            path: canonical.clone(),
1414            message: "configuration path has no parent directory".to_owned(),
1415        })?;
1416    for (included, optional) in cargo_configuration_includes(include, &canonical)? {
1417        let included = if included.is_absolute() {
1418            included
1419        } else {
1420            parent.join(included)
1421        };
1422        append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
1423    }
1424    Ok(())
1425}
1426
1427fn cargo_configuration_includes(
1428    include: &TomlValue,
1429    config: &Path,
1430) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
1431    let values = match include {
1432        TomlValue::Array(values) => values.as_slice(),
1433        value => std::slice::from_ref(value),
1434    };
1435    values
1436        .iter()
1437        .map(|value| match value {
1438            TomlValue::String(path) => Ok((PathBuf::from(path), false)),
1439            TomlValue::Table(table) => {
1440                let path = table
1441                    .get("path")
1442                    .and_then(TomlValue::as_str)
1443                    .ok_or_else(|| {
1444                        invalid_cargo_configuration(
1445                            config,
1446                            "Cargo configuration include table requires a string `path`",
1447                        )
1448                    })?;
1449                let optional = table
1450                    .get("optional")
1451                    .map(|value| {
1452                        value.as_bool().ok_or_else(|| {
1453                            invalid_cargo_configuration(
1454                                config,
1455                                "Cargo configuration include `optional` must be a boolean",
1456                            )
1457                        })
1458                    })
1459                    .transpose()?
1460                    .unwrap_or(false);
1461                Ok((PathBuf::from(path), optional))
1462            }
1463            _ => Err(invalid_cargo_configuration(
1464                config,
1465                "Cargo configuration `include` must contain paths or include tables",
1466            )),
1467        })
1468        .collect()
1469}
1470
1471fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
1472    if let Ok(relative) = config.strip_prefix(workspace_root) {
1473        return PathBuf::from("cargo-config/workspace").join(relative);
1474    }
1475    let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
1476    PathBuf::from("cargo-config/external").join(location.to_hex())
1477}
1478
1479fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
1480    WasmBuildError::InvalidCargoConfiguration {
1481        path: path.to_owned(),
1482        message: message.to_owned(),
1483    }
1484}
1485
1486fn append_package_inputs(
1487    inputs: &mut Vec<(PathBuf, PathBuf)>,
1488    packages: &HashMap<String, MetadataPackage>,
1489    closure: BTreeSet<String>,
1490    workspace_root: &Path,
1491) -> Result<(), WasmBuildError> {
1492    for id in closure {
1493        let Some(package) = packages.get(&id) else {
1494            return Err(invalid_metadata(&format!(
1495                "resolved package `{id}` is missing"
1496            )));
1497        };
1498        if !package.is_local {
1499            continue;
1500        }
1501        let root = package.manifest_path.parent().ok_or_else(|| {
1502            invalid_metadata(&format!(
1503                "package `{}` manifest has no parent",
1504                package.name
1505            ))
1506        })?;
1507        let relative_manifest = package
1508            .manifest_path
1509            .strip_prefix(workspace_root)
1510            .unwrap_or(&package.manifest_path);
1511        let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
1512            .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
1513        inputs.push((label, root.to_owned()));
1514    }
1515    Ok(())
1516}
1517
1518fn append_additional_inputs(
1519    inputs: &mut Vec<(PathBuf, PathBuf)>,
1520    spec: &WasmBuildSpec,
1521    workspace_root: &Path,
1522) {
1523    for additional in &spec.additional_inputs {
1524        let path = if additional.is_absolute() {
1525            additional.clone()
1526        } else {
1527            workspace_root.join(additional)
1528        };
1529        inputs.push((PathBuf::from("additional").join(additional), path));
1530    }
1531}
1532
1533fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
1534    let mut exclusions = vec![
1535        spec.target_dir.clone(),
1536        spec.workspace_root.join("target"),
1537        spec.workspace_root.join(".git"),
1538    ];
1539    for (_, path) in inputs {
1540        if path.is_dir() {
1541            exclusions.push(path.join("target"));
1542            exclusions.push(path.join(".git"));
1543        }
1544    }
1545    exclusions
1546}
1547
1548fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
1549    let mut names = spec.inherited_env.clone();
1550    names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
1551    let mut environment = names
1552        .into_iter()
1553        .map(|name| {
1554            let value = std::env::var_os(&name);
1555            (name, value)
1556        })
1557        .collect::<BTreeMap<_, _>>();
1558    for (key, value) in &spec.extra_env {
1559        environment.insert(key.clone(), Some(value.clone()));
1560    }
1561    environment
1562}
1563
1564fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
1565    for (key, value) in &spec.extra_env {
1566        command.env(key, value);
1567    }
1568}
1569
1570fn run_cargo_build(spec: &WasmBuildSpec, build_target_dir: &Path) -> Result<(), WasmBuildError> {
1571    let mut command = Command::new(&spec.cargo_program);
1572    command
1573        .current_dir(&spec.workspace_root)
1574        .env("CARGO_TARGET_DIR", build_target_dir)
1575        .args(["build", "--target", &spec.target])
1576        .args(&spec.cargo_profile_args);
1577    apply_command_environment(&mut command, spec);
1578    for package in &spec.packages {
1579        command.args(["-p", package]);
1580    }
1581
1582    let output = command
1583        .output()
1584        .map_err(|source| WasmBuildError::CommandSpawn {
1585            phase: WasmBuildPhase::CargoBuild,
1586            program: spec.cargo_program.clone(),
1587            source,
1588        })?;
1589    ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ())
1590}
1591
1592fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
1593    if output.status.success() {
1594        return Ok(output);
1595    }
1596    Err(WasmBuildError::CommandFailed {
1597        phase,
1598        status: output.status,
1599        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1600        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1601    })
1602}
1603
1604fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
1605    let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
1606    packages.sort_unstable();
1607    packages.dedup();
1608    packages
1609        .into_iter()
1610        .map(|package| {
1611            if spec.target == DEFAULT_TARGET {
1612                wasm_path(target_dir, package, &spec.profile_target_dir)
1613            } else {
1614                target_dir
1615                    .join(&spec.target)
1616                    .join(&spec.profile_target_dir)
1617                    .join(format!("{package}.wasm"))
1618            }
1619        })
1620        .collect()
1621}
1622
1623fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
1624    artifacts.iter().all(|path| {
1625        fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
1626            && cache_stamp_matches(path, fingerprint)
1627    })
1628}
1629
1630fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
1631    artifacts
1632        .iter()
1633        .filter(|path| {
1634            fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
1635        })
1636        .cloned()
1637        .collect()
1638}
1639
1640fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
1641    let stamp_path = artifact_stamp_path(artifact);
1642    let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
1643        return false;
1644    };
1645    fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
1646}
1647
1648fn artifact_stamp_path(artifact: &Path) -> PathBuf {
1649    let mut name = artifact
1650        .file_name()
1651        .map_or_else(|| OsString::from("artifact"), OsString::from);
1652    name.push(".ic-testkit-build");
1653    artifact.with_file_name(name)
1654}
1655
1656fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
1657    let artifact_digest = digest_bytes("wasm-artifact-v1", &fs::read(artifact)?);
1658    Ok(format!(
1659        "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
1660    ))
1661}
1662
1663fn publish_artifact_stamps(
1664    artifacts: &[PathBuf],
1665    fingerprint: InputDigest,
1666) -> Result<(), WasmBuildError> {
1667    for artifact in artifacts {
1668        let stamp_path = artifact_stamp_path(artifact);
1669        let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
1670            WasmBuildError::Io {
1671                operation: "hash built Wasm artifact",
1672                path: artifact.clone(),
1673                source,
1674            }
1675        })?;
1676        write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
1677            operation: "publish Wasm build stamp",
1678            path: stamp_path,
1679            source,
1680        })?;
1681    }
1682    Ok(())
1683}
1684
1685fn materialize_artifacts(
1686    cached_artifacts: &[PathBuf],
1687    artifacts: &[PathBuf],
1688    fingerprint: InputDigest,
1689) -> Result<(), WasmBuildError> {
1690    for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
1691        let contents = fs::read(cached).map_err(|source| WasmBuildError::Io {
1692            operation: "read content-addressed Wasm artifact",
1693            path: cached.clone(),
1694            source,
1695        })?;
1696        write_atomic(artifact, &contents).map_err(|source| WasmBuildError::Io {
1697            operation: "publish Wasm artifact",
1698            path: artifact.clone(),
1699            source,
1700        })?;
1701    }
1702    publish_artifact_stamps(artifacts, fingerprint)
1703}
1704
1705fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
1706    remove_dir_all_if_present(path).map_err(|source| WasmBuildError::Io {
1707        operation: "remove incomplete content-addressed Cargo target directory",
1708        path: path.to_owned(),
1709        source,
1710    })
1711}
1712
1713fn remove_dir_all_if_present(path: &Path) -> io::Result<()> {
1714    match fs::remove_dir_all(path) {
1715        Ok(()) => Ok(()),
1716        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1717        Err(error) => Err(error),
1718    }
1719}
1720
1721fn open_lock_file(path: &Path) -> Result<File, WasmBuildError> {
1722    if let Some(parent) = path.parent() {
1723        create_dir_all(parent, "create Wasm build lock directory")?;
1724    }
1725    OpenOptions::new()
1726        .create(true)
1727        .read(true)
1728        .write(true)
1729        .truncate(false)
1730        .open(path)
1731        .map_err(|source| WasmBuildError::Io {
1732            operation: "open Wasm build lock",
1733            path: path.to_owned(),
1734            source,
1735        })
1736}
1737
1738fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
1739    fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
1740        operation,
1741        path: path.to_owned(),
1742        source,
1743    })
1744}
1745
1746fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
1747    if path.exists() {
1748        inputs.push((PathBuf::from(label), path));
1749    }
1750}
1751
1752fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
1753    value
1754        .get(field)
1755        .and_then(Value::as_str)
1756        .map(str::to_owned)
1757        .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
1758}
1759
1760fn invalid_metadata(message: &str) -> WasmBuildError {
1761    WasmBuildError::InvalidMetadata {
1762        message: message.to_owned(),
1763    }
1764}
1765
1766impl std::fmt::Display for WasmBuildPhase {
1767    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1768        formatter.write_str(match self {
1769            Self::CargoMetadata => "cargo metadata",
1770            Self::CargoIdentity => "Cargo identity",
1771            Self::RustcIdentity => "Rust compiler identity",
1772            Self::CargoBuild => "cargo build",
1773        })
1774    }
1775}
1776
1777impl std::fmt::Display for WasmBuildError {
1778    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779        match self {
1780            Self::InvalidSpec { message } => {
1781                write!(formatter, "invalid Wasm build spec: {message}")
1782            }
1783            Self::Io {
1784                operation,
1785                path,
1786                source,
1787            } => write!(
1788                formatter,
1789                "failed to {operation} at {}: {source}",
1790                path.display()
1791            ),
1792            Self::CommandSpawn {
1793                phase,
1794                program,
1795                source,
1796            } => write!(
1797                formatter,
1798                "failed to launch {phase} using `{}`: {source}",
1799                program.to_string_lossy(),
1800            ),
1801            Self::CommandFailed {
1802                phase,
1803                status,
1804                stdout,
1805                stderr,
1806            } => write!(
1807                formatter,
1808                "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
1809            ),
1810            Self::InvalidMetadata { message } => {
1811                write!(formatter, "invalid Cargo metadata: {message}")
1812            }
1813            Self::InvalidCargoConfiguration { path, message } => write!(
1814                formatter,
1815                "invalid Cargo configuration at {}: {message}",
1816                path.display(),
1817            ),
1818            Self::MissingArtifacts { paths } => write!(
1819                formatter,
1820                "cargo build succeeded without producing: {}",
1821                paths
1822                    .iter()
1823                    .map(|path| path.display().to_string())
1824                    .collect::<Vec<_>>()
1825                    .join(", "),
1826            ),
1827            Self::InputsChangedDuringBuild { before, after } => write!(
1828                formatter,
1829                "Wasm build inputs changed while Cargo was running: {before} -> {after}",
1830            ),
1831            Self::FailedBuildCleanup {
1832                build_error,
1833                path,
1834                source,
1835            } => write!(
1836                formatter,
1837                "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
1838                path.display(),
1839            ),
1840        }
1841    }
1842}
1843
1844impl std::error::Error for WasmBuildError {
1845    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1846        match self {
1847            Self::Io { source, .. }
1848            | Self::CommandSpawn { source, .. }
1849            | Self::FailedBuildCleanup { source, .. } => Some(source),
1850            _ => None,
1851        }
1852    }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::{
1858        CACHE_DIRECTORY_TAG_SIGNATURE, IncompleteBuildDirectory, WasmBuildCachePrunePolicy,
1859        WasmBuildError, WasmBuildOutcome, WasmBuildSpec, append_cargo_configuration_inputs,
1860        directory_logical_size, ensure_cache_directory_tag, finish_fingerprint_build,
1861        metadata_arguments, prune_wasm_build_cache, prune_wasm_build_cache_locked, validate_spec,
1862        write_last_used,
1863    };
1864    use std::{
1865        collections::BTreeSet,
1866        ffi::OsString,
1867        fs,
1868        path::{Path, PathBuf},
1869        sync::atomic::{AtomicU64, Ordering},
1870        time::{Duration, SystemTime, UNIX_EPOCH},
1871    };
1872
1873    static TEMP_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1874
1875    #[test]
1876    fn metadata_receives_only_resolution_arguments() {
1877        let arguments = [
1878            OsString::from("--profile"),
1879            OsString::from("fast"),
1880            OsString::from("--locked"),
1881            OsString::from("--features=alpha,beta"),
1882        ];
1883        assert_eq!(
1884            metadata_arguments(&arguments),
1885            [
1886                OsString::from("--locked"),
1887                OsString::from("--features=alpha,beta"),
1888            ]
1889        );
1890    }
1891
1892    #[test]
1893    fn build_spec_requires_at_least_one_package() {
1894        let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
1895        assert!(matches!(
1896            validate_spec(&spec),
1897            Err(WasmBuildError::InvalidSpec { .. })
1898        ));
1899    }
1900
1901    #[test]
1902    fn cache_directory_tag_is_created_at_target_root() {
1903        let target_dir = unique_temp_directory("cache-directory-tag");
1904        fs::write(target_dir.join("CACHEDIR.TAG"), "not a cache tag")
1905            .expect("write invalid cache tag");
1906
1907        ensure_cache_directory_tag(&target_dir).expect("write valid cache tag");
1908
1909        let contents =
1910            fs::read_to_string(target_dir.join("CACHEDIR.TAG")).expect("read cache directory tag");
1911        assert!(contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE));
1912        fs::remove_dir_all(target_dir).expect("remove tag test directory");
1913    }
1914
1915    #[test]
1916    fn failed_build_removes_its_incomplete_fingerprint_directory() {
1917        let target_dir = unique_temp_directory("failed-build-cleanup");
1918        let fingerprint_dir = target_dir.join("a".repeat(64));
1919        fs::create_dir_all(&fingerprint_dir).expect("create incomplete target directory");
1920        fs::write(fingerprint_dir.join("partial-output"), b"partial")
1921            .expect("write incomplete output");
1922        let failure: Result<WasmBuildOutcome, WasmBuildError> = Err(WasmBuildError::InvalidSpec {
1923            message: "synthetic build failure".to_owned(),
1924        });
1925
1926        let result = finish_fingerprint_build(
1927            failure,
1928            IncompleteBuildDirectory::new(fingerprint_dir.clone()),
1929        );
1930
1931        assert!(matches!(result, Err(WasmBuildError::InvalidSpec { .. })));
1932        assert!(!fingerprint_dir.exists());
1933        fs::remove_dir_all(target_dir).expect("remove cleanup test directory");
1934    }
1935
1936    #[test]
1937    fn age_pruning_removes_only_stale_fingerprint_directories() {
1938        let target_dir = unique_temp_directory("age-pruning");
1939        let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1940        let old = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1941        let current = create_cache_entry(&cache_root, 'b', 10, SystemTime::now());
1942        let unrelated = cache_root.join("not-a-fingerprint");
1943        fs::create_dir_all(&unrelated).expect("create unrelated directory");
1944
1945        let report = prune_wasm_build_cache(
1946            &target_dir,
1947            WasmBuildCachePrunePolicy::new().with_max_age(Duration::from_secs(60)),
1948        )
1949        .expect("prune old cache entry");
1950
1951        assert_eq!(report.entries_scanned(), 2);
1952        assert_eq!(report.entries_removed(), 1);
1953        assert_eq!(report.entries_retained(), 1);
1954        assert!(!old.exists());
1955        assert!(current.exists());
1956        assert!(unrelated.exists());
1957        assert!(target_dir.join("CACHEDIR.TAG").is_file());
1958        fs::remove_dir_all(target_dir).expect("remove age-pruning test directory");
1959    }
1960
1961    #[test]
1962    fn size_pruning_removes_least_recently_used_entries_first() {
1963        let target_dir = unique_temp_directory("size-pruning");
1964        let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1965        let oldest = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1966        let middle = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
1967        let newest = create_cache_entry(&cache_root, 'c', 10, UNIX_EPOCH + Duration::from_secs(3));
1968        let newest_bytes = directory_logical_size(&newest).expect("measure newest entry");
1969
1970        let report = prune_wasm_build_cache(
1971            &target_dir,
1972            WasmBuildCachePrunePolicy::new().with_max_size_bytes(newest_bytes),
1973        )
1974        .expect("prune cache to size");
1975
1976        assert_eq!(report.entries_scanned(), 3);
1977        assert_eq!(report.entries_removed(), 2);
1978        assert_eq!(report.entries_retained(), 1);
1979        assert!(report.bytes_retained() <= newest_bytes);
1980        assert!(!oldest.exists());
1981        assert!(!middle.exists());
1982        assert!(newest.exists());
1983        fs::remove_dir_all(target_dir).expect("remove size-pruning test directory");
1984    }
1985
1986    #[test]
1987    fn in_build_pruning_protects_the_active_fingerprint() {
1988        let target_dir = unique_temp_directory("protected-pruning");
1989        let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1990        let stale = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1991        let active = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
1992
1993        let report = prune_wasm_build_cache_locked(
1994            &target_dir,
1995            WasmBuildCachePrunePolicy::new()
1996                .with_max_age(Duration::ZERO)
1997                .with_max_size_bytes(0),
1998            Some(&active),
1999        )
2000        .expect("prune while protecting active cache entry");
2001
2002        assert_eq!(report.entries_scanned(), 2);
2003        assert_eq!(report.entries_removed(), 1);
2004        assert!(!stale.exists());
2005        assert!(active.exists());
2006        assert!(report.bytes_retained() > 0);
2007        fs::remove_dir_all(target_dir).expect("remove protected-pruning test directory");
2008    }
2009
2010    #[test]
2011    fn cargo_configuration_discovery_matches_cargo_search_and_include_rules() {
2012        let root = unique_temp_directory("cargo-configuration-discovery");
2013        let workspace = root.join("workspace");
2014        let workspace_cargo = workspace.join(".cargo");
2015        let ancestor_cargo = root.join(".cargo");
2016        let cargo_home = root.join("cargo-home");
2017        fs::create_dir_all(&workspace_cargo).expect("create workspace Cargo directory");
2018        fs::create_dir_all(&ancestor_cargo).expect("create ancestor Cargo directory");
2019        fs::create_dir_all(&cargo_home).expect("create Cargo home");
2020
2021        fs::write(
2022            workspace_cargo.join("config"),
2023            "include = [\"included.toml\", { path = \"missing.toml\", optional = true }]\n",
2024        )
2025        .expect("write effective workspace Cargo config");
2026        fs::write(
2027            workspace_cargo.join("config.toml"),
2028            "[build]\ntarget-dir = \"ignored-by-cargo\"\n",
2029        )
2030        .expect("write shadowed workspace Cargo config");
2031        fs::write(
2032            workspace_cargo.join("included.toml"),
2033            "include = \"nested.toml\"\n",
2034        )
2035        .expect("write included Cargo config");
2036        fs::write(
2037            workspace_cargo.join("nested.toml"),
2038            "[build]\nincremental = false\n",
2039        )
2040        .expect("write nested Cargo config");
2041        fs::write(
2042            ancestor_cargo.join("config.toml"),
2043            "[net]\noffline = true\n",
2044        )
2045        .expect("write ancestor Cargo config");
2046        fs::write(cargo_home.join("config"), "[term]\nquiet = true\n")
2047            .expect("write Cargo-home config");
2048
2049        let cargo_home_text = cargo_home.to_str().expect("temporary path is UTF-8");
2050        let spec = WasmBuildSpec::new(&workspace, &root.join("target"), &["fixture"], "debug")
2051            .with_extra_env(&[("CARGO_HOME", cargo_home_text)]);
2052        let mut inputs = Vec::new();
2053        append_cargo_configuration_inputs(&mut inputs, &spec, &workspace)
2054            .expect("discover effective Cargo configuration");
2055        let paths = inputs
2056            .into_iter()
2057            .map(|(_, path)| path)
2058            .collect::<BTreeSet<_>>();
2059
2060        assert!(paths.contains(&workspace_cargo.join("config").canonicalize().unwrap()));
2061        assert!(
2062            paths.contains(
2063                &workspace_cargo
2064                    .join("included.toml")
2065                    .canonicalize()
2066                    .unwrap()
2067            )
2068        );
2069        assert!(paths.contains(&workspace_cargo.join("nested.toml").canonicalize().unwrap()));
2070        assert!(paths.contains(&ancestor_cargo.join("config.toml").canonicalize().unwrap()));
2071        assert!(paths.contains(&cargo_home.join("config").canonicalize().unwrap()));
2072        assert!(!paths.contains(&workspace_cargo.join("config.toml").canonicalize().unwrap()));
2073        assert_eq!(paths.len(), 5);
2074        fs::remove_dir_all(root).expect("remove Cargo-configuration test directory");
2075    }
2076
2077    #[test]
2078    fn required_cargo_configuration_include_is_an_exact_input() {
2079        let root = unique_temp_directory("required-cargo-configuration-include");
2080        let workspace = root.join("workspace");
2081        let cargo_dir = workspace.join(".cargo");
2082        fs::create_dir_all(&cargo_dir).expect("create workspace Cargo directory");
2083        fs::write(
2084            cargo_dir.join("config.toml"),
2085            "include = \"missing.toml\"\n",
2086        )
2087        .expect("write Cargo config");
2088        let isolated_home = root.join("isolated-cargo-home");
2089        let isolated_home_text = isolated_home.to_str().expect("temporary path is UTF-8");
2090        let spec = WasmBuildSpec::new(&workspace, &root.join("target"), &["fixture"], "debug")
2091            .with_extra_env(&[("CARGO_HOME", isolated_home_text)]);
2092
2093        let error = append_cargo_configuration_inputs(&mut Vec::new(), &spec, &workspace)
2094            .expect_err("required missing include must fail input discovery");
2095
2096        assert!(matches!(error, WasmBuildError::Io { .. }));
2097        fs::remove_dir_all(root).expect("remove required-include test directory");
2098    }
2099
2100    fn create_cache_entry(
2101        cache_root: &Path,
2102        fingerprint_digit: char,
2103        payload_bytes: usize,
2104        last_used: SystemTime,
2105    ) -> PathBuf {
2106        let path = cache_root.join(fingerprint_digit.to_string().repeat(64));
2107        fs::create_dir_all(&path).expect("create cache entry");
2108        fs::write(path.join("payload"), vec![0; payload_bytes]).expect("write cache payload");
2109        write_last_used(&path, last_used).expect("write cache use time");
2110        path
2111    }
2112
2113    fn unique_temp_directory(label: &str) -> PathBuf {
2114        let sequence = TEMP_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2115        let path = std::env::temp_dir().join(format!(
2116            "ic-testkit-{label}-{}-{sequence}",
2117            std::process::id()
2118        ));
2119        if path.exists() {
2120            fs::remove_dir_all(&path).expect("remove stale test directory");
2121        }
2122        fs::create_dir_all(&path).expect("create test directory");
2123        path
2124    }
2125}