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};
12
13use super::{
14    digest::{
15        InputDigest, InputHasher, digest_bytes, digest_labeled_paths, os_bytes, write_atomic,
16    },
17    wasm::wasm_path,
18};
19
20const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
21const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
22const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
23# This file is a cache directory tag created by ic-testkit.\n\
24# For information about cache directory tags see https://bford.info/cachedir/\n";
25const CACHE_DIRECTORY_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55\n";
26const LAST_USED_FILE: &str = ".ic-testkit-last-used";
27const AUTOMATIC_ENVIRONMENT: &[&str] = &[
28    "CARGO_BUILD_RUSTC",
29    "CARGO_ENCODED_RUSTFLAGS",
30    "RUSTC",
31    "RUSTC_WRAPPER",
32    "RUSTC_WORKSPACE_WRAPPER",
33    "RUSTFLAGS",
34    "RUSTUP_TOOLCHAIN",
35];
36
37/// Complete caller-owned description of one cacheable Cargo Wasm build.
38///
39/// The package dependency closure, workspace manifest, lockfile, Cargo
40/// configuration, Rust toolchain files, target, profile arguments, explicit
41/// child environment, selected inherited environment, and additional watched
42/// inputs all contribute to the build fingerprint.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct WasmBuildSpec {
45    workspace_root: PathBuf,
46    target_dir: PathBuf,
47    packages: Vec<String>,
48    profile_target_dir: String,
49    cargo_profile_args: Vec<OsString>,
50    extra_env: BTreeMap<OsString, OsString>,
51    inherited_env: BTreeSet<OsString>,
52    additional_inputs: Vec<PathBuf>,
53    target: String,
54    cargo_program: OsString,
55    rustc_program: OsString,
56}
57
58/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum WasmBuildOutcome {
61    /// Cargo ran and a new successful stamp was published.
62    Built(WasmBuildRecord),
63    /// Existing artifacts and their content-addressed stamp matched exactly.
64    Reused(WasmBuildRecord),
65}
66
67/// Details shared by built and reused Wasm outcomes.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct WasmBuildRecord {
70    fingerprint: InputDigest,
71    input_digest: InputDigest,
72    artifacts: Vec<PathBuf>,
73    timings: WasmBuildTimings,
74}
75
76/// Timings for cache coordination, input resolution, and Cargo execution.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct WasmBuildTimings {
79    lock_wait: Duration,
80    input_resolution: Duration,
81    cargo_build: Option<Duration>,
82    total: Duration,
83}
84
85/// Caller-selected retention limits for content-addressed Cargo target directories.
86///
87/// Age pruning runs before size pruning. A policy without either limit scans
88/// the cache and writes its cache-directory tag without removing entries.
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
90pub struct WasmBuildCachePrunePolicy {
91    max_age: Option<Duration>,
92    max_size_bytes: Option<u64>,
93}
94
95/// Summary of one lock-coordinated Wasm build-cache pruning pass.
96#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
97pub struct WasmBuildCachePruneReport {
98    entries_scanned: usize,
99    entries_removed: usize,
100    bytes_before: u64,
101    bytes_removed: u64,
102}
103
104/// External phase associated with a cacheable Wasm build failure.
105#[non_exhaustive]
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum WasmBuildPhase {
108    /// Resolving Cargo's package graph.
109    CargoMetadata,
110    /// Reading the Cargo executable identity.
111    CargoIdentity,
112    /// Reading the Rust compiler identity.
113    RustcIdentity,
114    /// Compiling the selected Wasm packages.
115    CargoBuild,
116}
117
118/// Structured failure from a cacheable Wasm build.
119#[non_exhaustive]
120#[derive(Debug)]
121pub enum WasmBuildError {
122    /// The caller supplied an incomplete or inconsistent specification.
123    InvalidSpec { message: String },
124    /// A filesystem operation failed.
125    Io {
126        operation: &'static str,
127        path: PathBuf,
128        source: io::Error,
129    },
130    /// An external command could not be launched.
131    CommandSpawn {
132        phase: WasmBuildPhase,
133        program: OsString,
134        source: io::Error,
135    },
136    /// An external command completed unsuccessfully.
137    CommandFailed {
138        phase: WasmBuildPhase,
139        status: ExitStatus,
140        stdout: String,
141        stderr: String,
142    },
143    /// Cargo metadata did not contain the expected package graph.
144    InvalidMetadata { message: String },
145    /// Cargo succeeded without producing every declared Wasm artifact.
146    MissingArtifacts { paths: Vec<PathBuf> },
147    /// Declared inputs changed while Cargo was building.
148    InputsChangedDuringBuild {
149        before: InputDigest,
150        after: InputDigest,
151    },
152    /// A build failed and its incomplete fingerprint directory could not be removed.
153    FailedBuildCleanup {
154        build_error: Box<Self>,
155        path: PathBuf,
156        source: io::Error,
157    },
158}
159
160impl WasmBuildSpec {
161    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
162    ///
163    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
164    /// `release`, or the name supplied to `--profile`.
165    #[must_use]
166    pub fn new(
167        workspace_root: &Path,
168        target_dir: &Path,
169        packages: &[&str],
170        profile_target_dir: &str,
171    ) -> Self {
172        Self {
173            workspace_root: workspace_root.to_owned(),
174            target_dir: target_dir.to_owned(),
175            packages: packages
176                .iter()
177                .map(|package| (*package).to_owned())
178                .collect(),
179            profile_target_dir: profile_target_dir.to_owned(),
180            cargo_profile_args: Vec::new(),
181            extra_env: BTreeMap::new(),
182            inherited_env: BTreeSet::new(),
183            additional_inputs: Vec::new(),
184            target: DEFAULT_TARGET.to_owned(),
185            cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
186            rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
187        }
188    }
189
190    /// Set Cargo profile and feature arguments used for both the build and fingerprint.
191    #[must_use]
192    pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
193        self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
194        self
195    }
196
197    /// Set deterministic child-process environment overrides.
198    #[must_use]
199    pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
200        self.extra_env = environment
201            .iter()
202            .map(|(key, value)| (OsString::from(key), OsString::from(value)))
203            .collect();
204        self
205    }
206
207    /// Add ambient environment variables whose current values affect the build.
208    ///
209    /// Common Rust and Cargo toolchain variables are included automatically.
210    /// Callers must declare application-specific variables read by build scripts.
211    #[must_use]
212    pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
213        self.inherited_env.extend(names.iter().map(OsString::from));
214        self
215    }
216
217    /// Add files or directories not discoverable through Cargo's local dependency graph.
218    ///
219    /// Relative paths are resolved from the workspace root. Use this for build
220    /// script configuration, generated schemas, or other externally read inputs.
221    #[must_use]
222    pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
223        self.additional_inputs
224            .extend(paths.iter().map(PathBuf::from));
225        self
226    }
227
228    /// Override the Cargo compilation target.
229    #[must_use]
230    pub fn with_target(mut self, target: &str) -> Self {
231        target.clone_into(&mut self.target);
232        self
233    }
234
235    /// Override the Cargo executable used by metadata, identity, and build commands.
236    #[must_use]
237    pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
238        self.cargo_program = program.into();
239        self
240    }
241
242    /// Override the Rust compiler executable used to fingerprint the toolchain.
243    #[must_use]
244    pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
245        self.rustc_program = program.into();
246        self
247    }
248
249    /// Workspace containing the selected Cargo packages.
250    #[must_use]
251    pub fn workspace_root(&self) -> &Path {
252        &self.workspace_root
253    }
254
255    /// Cargo target directory containing artifacts, lock, and stamps.
256    #[must_use]
257    pub fn target_dir(&self) -> &Path {
258        &self.target_dir
259    }
260
261    /// Selected Cargo package names.
262    #[must_use]
263    pub fn packages(&self) -> &[String] {
264        &self.packages
265    }
266}
267
268impl WasmBuildOutcome {
269    /// Read the common build record.
270    #[must_use]
271    pub const fn record(&self) -> &WasmBuildRecord {
272        match self {
273            Self::Built(record) | Self::Reused(record) => record,
274        }
275    }
276
277    /// Report whether exact matching artifacts were reused.
278    #[must_use]
279    pub const fn is_reused(&self) -> bool {
280        matches!(self, Self::Reused(_))
281    }
282}
283
284impl WasmBuildRecord {
285    /// Exact build fingerprint used by the atomic cache stamp.
286    #[must_use]
287    pub const fn fingerprint(&self) -> InputDigest {
288        self.fingerprint
289    }
290
291    /// Exact digest of package sources, lockfile, and configuration inputs.
292    #[must_use]
293    pub const fn input_digest(&self) -> InputDigest {
294        self.input_digest
295    }
296
297    /// Expected Wasm artifacts produced or reused by the build.
298    #[must_use]
299    pub fn artifacts(&self) -> &[PathBuf] {
300        &self.artifacts
301    }
302
303    /// Phase timings captured by the cacheable build operation.
304    #[must_use]
305    pub const fn timings(&self) -> WasmBuildTimings {
306        self.timings
307    }
308}
309
310impl WasmBuildTimings {
311    /// Time spent waiting for the output-directory process lock.
312    #[must_use]
313    pub const fn lock_wait(self) -> Duration {
314        self.lock_wait
315    }
316
317    /// Time spent resolving toolchain identity, Cargo metadata, and exact inputs.
318    #[must_use]
319    pub const fn input_resolution(self) -> Duration {
320        self.input_resolution
321    }
322
323    /// Time spent in `cargo build`, or `None` for a cache hit.
324    #[must_use]
325    pub const fn cargo_build(self) -> Option<Duration> {
326        self.cargo_build
327    }
328
329    /// Total operation duration, including lock coordination.
330    #[must_use]
331    pub const fn total(self) -> Duration {
332        self.total
333    }
334}
335
336impl WasmBuildCachePrunePolicy {
337    /// Create a policy that records cache metadata without removing entries.
338    #[must_use]
339    pub const fn new() -> Self {
340        Self {
341            max_age: None,
342            max_size_bytes: None,
343        }
344    }
345
346    /// Remove entries older than `max_age` before applying the size limit.
347    #[must_use]
348    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
349        self.max_age = Some(max_age);
350        self
351    }
352
353    /// Remove least-recently-used entries until retained logical size is at most `bytes`.
354    #[must_use]
355    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
356        self.max_size_bytes = Some(bytes);
357        self
358    }
359
360    /// Configured maximum entry age, if any.
361    #[must_use]
362    pub const fn max_age(self) -> Option<Duration> {
363        self.max_age
364    }
365
366    /// Configured maximum logical cache size in bytes, if any.
367    #[must_use]
368    pub const fn max_size_bytes(self) -> Option<u64> {
369        self.max_size_bytes
370    }
371}
372
373impl WasmBuildCachePruneReport {
374    /// Number of fingerprint directories considered for pruning.
375    #[must_use]
376    pub const fn entries_scanned(self) -> usize {
377        self.entries_scanned
378    }
379
380    /// Number of fingerprint directories removed.
381    #[must_use]
382    pub const fn entries_removed(self) -> usize {
383        self.entries_removed
384    }
385
386    /// Number of fingerprint directories retained.
387    #[must_use]
388    pub const fn entries_retained(self) -> usize {
389        self.entries_scanned - self.entries_removed
390    }
391
392    /// Logical bytes occupied by scanned entries before pruning.
393    #[must_use]
394    pub const fn bytes_before(self) -> u64 {
395        self.bytes_before
396    }
397
398    /// Logical bytes removed by pruning.
399    #[must_use]
400    pub const fn bytes_removed(self) -> u64 {
401        self.bytes_removed
402    }
403
404    /// Logical bytes occupied by retained entries after pruning.
405    #[must_use]
406    pub const fn bytes_retained(self) -> u64 {
407        self.bytes_before - self.bytes_removed
408    }
409}
410
411/// Build or reuse one exact set of Cargo Wasm artifacts.
412///
413/// The operation takes an exclusive process lock scoped to `target_dir`, then
414/// fingerprints all declared inputs. A cache hit requires both a matching
415/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
416/// builds never publish a successful stamp.
417pub fn build_wasm_canisters_cached(
418    spec: &WasmBuildSpec,
419) -> Result<WasmBuildOutcome, WasmBuildError> {
420    let total_started = Instant::now();
421    validate_spec(spec)?;
422    let (_lock_file, lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
423    ensure_cache_directory_tag(&spec.target_dir)?;
424
425    let input_started = Instant::now();
426    let resolved = build_fingerprint(spec)?;
427    let mut input_resolution = input_started.elapsed();
428    let fingerprint = resolved.fingerprint;
429    let artifacts = expected_artifacts(spec, &spec.target_dir);
430    let build_target_dir = spec
431        .target_dir
432        .join(".ic-testkit/wasm-targets")
433        .join(fingerprint.to_hex());
434
435    if artifact_set_matches(&artifacts, fingerprint) {
436        record_cache_entry_use_if_present(&build_target_dir)?;
437        return Ok(WasmBuildOutcome::Reused(WasmBuildRecord {
438            fingerprint,
439            input_digest: resolved.input_digest,
440            artifacts,
441            timings: WasmBuildTimings {
442                lock_wait,
443                input_resolution,
444                cargo_build: None,
445                total: total_started.elapsed(),
446            },
447        }));
448    }
449
450    let cached_artifacts = expected_artifacts(spec, &build_target_dir);
451    if artifact_set_matches(&cached_artifacts, fingerprint) {
452        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
453        record_cache_entry_use(&build_target_dir)?;
454        return Ok(WasmBuildOutcome::Reused(WasmBuildRecord {
455            fingerprint,
456            input_digest: resolved.input_digest,
457            artifacts,
458            timings: WasmBuildTimings {
459                lock_wait,
460                input_resolution,
461                cargo_build: None,
462                total: total_started.elapsed(),
463            },
464        }));
465    }
466
467    remove_directory_if_present(&build_target_dir)?;
468    create_dir_all(
469        &build_target_dir,
470        "create content-addressed Cargo target directory",
471    )?;
472    let incomplete_directory = IncompleteBuildDirectory::new(build_target_dir.clone());
473    let build_result = (|| {
474        let build_started = Instant::now();
475        run_cargo_build(spec, &build_target_dir)?;
476        let cargo_build = build_started.elapsed();
477        let missing = missing_artifacts(&cached_artifacts);
478        if !missing.is_empty() {
479            return Err(WasmBuildError::MissingArtifacts { paths: missing });
480        }
481
482        let verification_started = Instant::now();
483        let verified = build_fingerprint(spec)?;
484        input_resolution += verification_started.elapsed();
485        if fingerprint != verified.fingerprint {
486            return Err(WasmBuildError::InputsChangedDuringBuild {
487                before: fingerprint,
488                after: verified.fingerprint,
489            });
490        }
491
492        publish_artifact_stamps(&cached_artifacts, fingerprint)?;
493        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
494        record_cache_entry_use(&build_target_dir)?;
495
496        Ok(WasmBuildOutcome::Built(WasmBuildRecord {
497            fingerprint,
498            input_digest: resolved.input_digest,
499            artifacts,
500            timings: WasmBuildTimings {
501                lock_wait,
502                input_resolution,
503                cargo_build: Some(cargo_build),
504                total: total_started.elapsed(),
505            },
506        }))
507    })();
508    finish_fingerprint_build(build_result, incomplete_directory)
509}
510
511/// Prune fingerprint-specific Cargo target directories under `target_dir`.
512///
513/// Pruning uses the same exclusive process lock as builds. Entries older than
514/// the configured age are removed first, then least-recently-used entries are
515/// removed until the configured logical byte limit is met. Only direct child
516/// directories with SHA-256 fingerprint names are eligible; caller-facing
517/// artifacts and unrelated target contents are never removed.
518pub fn prune_wasm_build_cache(
519    target_dir: &Path,
520    policy: WasmBuildCachePrunePolicy,
521) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
522    let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
523    ensure_cache_directory_tag(target_dir)?;
524
525    let cache_root = target_dir.join(".ic-testkit/wasm-targets");
526    let mut entries = cache_entries(&cache_root)?;
527    let bytes_before = entries
528        .iter()
529        .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
530    let entries_scanned = entries.len();
531    let now = SystemTime::now();
532    let mut report = WasmBuildCachePruneReport {
533        entries_scanned,
534        entries_removed: 0,
535        bytes_before,
536        bytes_removed: 0,
537    };
538
539    if let Some(max_age) = policy.max_age {
540        for entry in &mut entries {
541            let age = now.duration_since(entry.last_used).unwrap_or_default();
542            if age > max_age {
543                remove_cache_entry(entry, &mut report)?;
544            }
545        }
546    }
547
548    if let Some(max_size_bytes) = policy.max_size_bytes {
549        entries.sort_by(|left, right| {
550            left.last_used
551                .cmp(&right.last_used)
552                .then_with(|| left.path.cmp(&right.path))
553        });
554        for entry in &mut entries {
555            if report.bytes_retained() <= max_size_bytes {
556                break;
557            }
558            remove_cache_entry(entry, &mut report)?;
559        }
560    }
561
562    Ok(report)
563}
564
565struct CacheEntry {
566    path: PathBuf,
567    bytes: u64,
568    last_used: SystemTime,
569    removed: bool,
570}
571
572struct IncompleteBuildDirectory {
573    path: PathBuf,
574    armed: bool,
575}
576
577impl IncompleteBuildDirectory {
578    const fn new(path: PathBuf) -> Self {
579        Self { path, armed: true }
580    }
581
582    fn preserve(mut self) {
583        self.armed = false;
584    }
585
586    fn cleanup(mut self) -> io::Result<()> {
587        let result = remove_dir_all_if_present(&self.path);
588        if result.is_ok() {
589            self.armed = false;
590        }
591        result
592    }
593}
594
595impl Drop for IncompleteBuildDirectory {
596    fn drop(&mut self) {
597        if self.armed {
598            let _ = remove_dir_all_if_present(&self.path);
599        }
600    }
601}
602
603fn finish_fingerprint_build(
604    result: Result<WasmBuildOutcome, WasmBuildError>,
605    incomplete_directory: IncompleteBuildDirectory,
606) -> Result<WasmBuildOutcome, WasmBuildError> {
607    match result {
608        Ok(outcome) => {
609            incomplete_directory.preserve();
610            Ok(outcome)
611        }
612        Err(build_error) => {
613            let path = incomplete_directory.path.clone();
614            match incomplete_directory.cleanup() {
615                Ok(()) => Err(build_error),
616                Err(source) => Err(WasmBuildError::FailedBuildCleanup {
617                    build_error: Box::new(build_error),
618                    path,
619                    source,
620                }),
621            }
622        }
623    }
624}
625
626fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
627    create_dir_all(target_dir, "create Cargo target directory")?;
628    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
629    let lock_file = open_lock_file(&lock_path)?;
630    let lock_started = Instant::now();
631    lock_file
632        .lock_exclusive()
633        .map_err(|source| WasmBuildError::Io {
634            operation: "lock Wasm build cache",
635            path: lock_path,
636            source,
637        })?;
638    Ok((lock_file, lock_started.elapsed()))
639}
640
641fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
642    let path = target_dir.join("CACHEDIR.TAG");
643    if fs::read_to_string(&path)
644        .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
645    {
646        return Ok(());
647    }
648    write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| WasmBuildError::Io {
649        operation: "write Cargo cache directory tag",
650        path,
651        source,
652    })
653}
654
655fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
656    if path.is_dir() {
657        record_cache_entry_use(path)?;
658    }
659    Ok(())
660}
661
662fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
663    write_last_used(path, SystemTime::now())
664}
665
666fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), WasmBuildError> {
667    let elapsed = last_used
668        .duration_since(UNIX_EPOCH)
669        .map_err(|source| WasmBuildError::Io {
670            operation: "record Wasm build cache use time",
671            path: path.join(LAST_USED_FILE),
672            source: io::Error::new(io::ErrorKind::InvalidInput, source),
673        })?;
674    let timestamp = elapsed.as_nanos().to_string();
675    let marker = path.join(LAST_USED_FILE);
676    write_atomic(&marker, timestamp.as_bytes()).map_err(|source| WasmBuildError::Io {
677        operation: "record Wasm build cache use time",
678        path: marker,
679        source,
680    })
681}
682
683fn cache_entries(cache_root: &Path) -> Result<Vec<CacheEntry>, WasmBuildError> {
684    let read_dir = match fs::read_dir(cache_root) {
685        Ok(read_dir) => read_dir,
686        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
687        Err(source) => {
688            return Err(WasmBuildError::Io {
689                operation: "read Wasm build cache directory",
690                path: cache_root.to_owned(),
691                source,
692            });
693        }
694    };
695    let mut entries = Vec::new();
696    for directory_entry in read_dir {
697        let directory_entry = directory_entry.map_err(|source| WasmBuildError::Io {
698            operation: "read Wasm build cache entry",
699            path: cache_root.to_owned(),
700            source,
701        })?;
702        let path = directory_entry.path();
703        let file_type = directory_entry
704            .file_type()
705            .map_err(|source| WasmBuildError::Io {
706                operation: "inspect Wasm build cache entry",
707                path: path.clone(),
708                source,
709            })?;
710        if !file_type.is_dir() || !is_fingerprint_directory(&path) {
711            continue;
712        }
713        let bytes = directory_logical_size(&path).map_err(|source| WasmBuildError::Io {
714            operation: "measure Wasm build cache entry",
715            path: path.clone(),
716            source,
717        })?;
718        let last_used = cache_entry_last_used(&path).map_err(|source| WasmBuildError::Io {
719            operation: "read Wasm build cache use time",
720            path: path.clone(),
721            source,
722        })?;
723        entries.push(CacheEntry {
724            path,
725            bytes,
726            last_used,
727            removed: false,
728        });
729    }
730    Ok(entries)
731}
732
733fn is_fingerprint_directory(path: &Path) -> bool {
734    path.file_name().is_some_and(|name| {
735        let bytes = name.as_encoded_bytes();
736        bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
737    })
738}
739
740fn directory_logical_size(path: &Path) -> io::Result<u64> {
741    let mut total = 0_u64;
742    let mut pending = vec![path.to_owned()];
743    while let Some(current) = pending.pop() {
744        let metadata = fs::symlink_metadata(&current)?;
745        if metadata.is_dir() {
746            for entry in fs::read_dir(&current)? {
747                pending.push(entry?.path());
748            }
749        } else {
750            total = total.saturating_add(metadata.len());
751        }
752    }
753    Ok(total)
754}
755
756fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
757    let marker = path.join(LAST_USED_FILE);
758    if let Ok(contents) = fs::read_to_string(&marker)
759        && let Ok(nanoseconds) = contents.parse::<u128>()
760    {
761        let seconds = nanoseconds / 1_000_000_000;
762        let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
763        if let Ok(seconds) = u64::try_from(seconds)
764            && let Some(timestamp) = UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
765        {
766            return Ok(timestamp);
767        }
768    }
769    fs::metadata(path)?.modified()
770}
771
772fn remove_cache_entry(
773    entry: &mut CacheEntry,
774    report: &mut WasmBuildCachePruneReport,
775) -> Result<(), WasmBuildError> {
776    if entry.removed {
777        return Ok(());
778    }
779    remove_dir_all_if_present(&entry.path).map_err(|source| WasmBuildError::Io {
780        operation: "prune Wasm build cache entry",
781        path: entry.path.clone(),
782        source,
783    })?;
784    entry.removed = true;
785    report.entries_removed += 1;
786    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
787    Ok(())
788}
789
790fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
791    if spec.packages.is_empty() {
792        return Err(WasmBuildError::InvalidSpec {
793            message: "at least one Cargo package is required".to_owned(),
794        });
795    }
796    if spec.profile_target_dir.is_empty() {
797        return Err(WasmBuildError::InvalidSpec {
798            message: "Cargo profile target directory must not be empty".to_owned(),
799        });
800    }
801    if spec.target.is_empty() {
802        return Err(WasmBuildError::InvalidSpec {
803            message: "Cargo compilation target must not be empty".to_owned(),
804        });
805    }
806    Ok(())
807}
808
809struct ResolvedFingerprint {
810    fingerprint: InputDigest,
811    input_digest: InputDigest,
812}
813
814fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedFingerprint, WasmBuildError> {
815    let cargo_identity = command_identity(
816        spec,
817        WasmBuildPhase::CargoIdentity,
818        &spec.cargo_program,
819        &["--version", "--verbose"],
820    )?;
821    let rustc_program = spec
822        .extra_env
823        .get(OsStr::new("RUSTC"))
824        .unwrap_or(&spec.rustc_program);
825    let rustc_identity =
826        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
827    let metadata = cargo_metadata(spec)?;
828    let inputs = resolve_local_inputs(spec, &metadata)?;
829    let exclusions = source_exclusions(spec, &inputs);
830    let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
831        .map_err(|source| WasmBuildError::Io {
832            operation: "hash Wasm build inputs",
833            path: spec.workspace_root.clone(),
834            source,
835        })?;
836
837    let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
838    let mut packages = spec.packages.clone();
839    packages.sort();
840    packages.dedup();
841    for package in packages {
842        hasher.field("package", package.as_bytes());
843    }
844    hasher.field("target", spec.target.as_bytes());
845    hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
846    for argument in &spec.cargo_profile_args {
847        hasher.field("cargo-argument", &os_bytes(argument));
848    }
849    for (key, value) in effective_environment(spec) {
850        hasher.field("environment-key", &os_bytes(&key));
851        if let Some(value) = value {
852            hasher.field("environment-value", &os_bytes(&value));
853        } else {
854            hasher.field("environment-unset", b"");
855        }
856    }
857    hasher.field("cargo-identity", &cargo_identity);
858    hasher.field("rustc-identity", &rustc_identity);
859    hasher.field("source-input-digest", input_digest.as_bytes());
860    Ok(ResolvedFingerprint {
861        fingerprint: hasher.finish(),
862        input_digest,
863    })
864}
865
866fn command_identity(
867    spec: &WasmBuildSpec,
868    phase: WasmBuildPhase,
869    program: &OsStr,
870    arguments: &[&str],
871) -> Result<Vec<u8>, WasmBuildError> {
872    let mut command = Command::new(program);
873    command.current_dir(&spec.workspace_root).args(arguments);
874    apply_command_environment(&mut command, spec);
875    let output = command
876        .output()
877        .map_err(|source| WasmBuildError::CommandSpawn {
878            phase,
879            program: program.to_owned(),
880            source,
881        })?;
882    ensure_command_success(phase, output).map(|output| {
883        let mut identity = output.stdout;
884        identity.extend_from_slice(&output.stderr);
885        identity
886    })
887}
888
889fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
890    let mut command = Command::new(&spec.cargo_program);
891    command
892        .current_dir(&spec.workspace_root)
893        .args(["metadata", "--format-version", "1"]);
894    for argument in metadata_arguments(&spec.cargo_profile_args) {
895        command.arg(argument);
896    }
897    apply_command_environment(&mut command, spec);
898    let output = command
899        .output()
900        .map_err(|source| WasmBuildError::CommandSpawn {
901            phase: WasmBuildPhase::CargoMetadata,
902            program: spec.cargo_program.clone(),
903            source,
904        })?;
905    let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
906    serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
907        message: format!("Cargo metadata was not valid JSON: {error}"),
908    })
909}
910
911fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
912    let mut selected = Vec::new();
913    let mut arguments = arguments.iter();
914    while let Some(argument) = arguments.next() {
915        let argument_text = argument.to_string_lossy();
916        match argument_text.as_ref() {
917            "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
918                selected.push(argument.clone());
919            }
920            "--features" | "-F" | "--filter-platform" => {
921                selected.push(argument.clone());
922                if let Some(value) = arguments.next() {
923                    selected.push(value.clone());
924                }
925            }
926            _ if argument_text.starts_with("--features=")
927                || argument_text.starts_with("--filter-platform=") =>
928            {
929                selected.push(argument.clone());
930            }
931            _ => {}
932        }
933    }
934    selected
935}
936
937#[derive(Clone)]
938struct MetadataPackage {
939    id: String,
940    name: String,
941    version: String,
942    manifest_path: PathBuf,
943    is_local: bool,
944}
945
946fn resolve_local_inputs(
947    spec: &WasmBuildSpec,
948    metadata: &Value,
949) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
950    let packages = metadata_packages(metadata)?;
951    let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
952    let dependencies = metadata_dependencies(metadata)?;
953    let mut closure = BTreeSet::new();
954    while let Some(id) = selected_ids.pop_front() {
955        if !closure.insert(id.clone()) {
956            continue;
957        }
958        if let Some(deps) = dependencies.get(&id) {
959            selected_ids.extend(deps.iter().cloned());
960        }
961    }
962
963    let workspace_root = metadata
964        .get("workspace_root")
965        .and_then(Value::as_str)
966        .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
967    let mut inputs = workspace_configuration_inputs(&workspace_root);
968    append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
969    append_additional_inputs(&mut inputs, spec, &workspace_root);
970    Ok(inputs)
971}
972
973fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
974    let packages_value = metadata
975        .get("packages")
976        .and_then(Value::as_array)
977        .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
978    let mut packages = HashMap::new();
979    for value in packages_value {
980        let package = MetadataPackage {
981            id: required_string(value, "id")?,
982            name: required_string(value, "name")?,
983            version: required_string(value, "version")?,
984            manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
985            is_local: value.get("source").is_some_and(Value::is_null),
986        };
987        packages.insert(package.id.clone(), package);
988    }
989    Ok(packages)
990}
991
992fn selected_package_ids(
993    spec: &WasmBuildSpec,
994    metadata: &Value,
995    packages: &HashMap<String, MetadataPackage>,
996) -> Result<VecDeque<String>, WasmBuildError> {
997    let workspace_members = metadata
998        .get("workspace_members")
999        .and_then(Value::as_array)
1000        .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
1001        .iter()
1002        .filter_map(Value::as_str)
1003        .collect::<HashSet<_>>();
1004    let mut selected_ids = VecDeque::new();
1005    for requested in &spec.packages {
1006        let matches = packages
1007            .values()
1008            .filter(|package| {
1009                package.name == *requested && workspace_members.contains(package.id.as_str())
1010            })
1011            .map(|package| package.id.clone())
1012            .collect::<Vec<_>>();
1013        match matches.as_slice() {
1014            [id] => selected_ids.push_back(id.clone()),
1015            [] => {
1016                return Err(WasmBuildError::InvalidSpec {
1017                    message: format!("Cargo workspace contains no package named `{requested}`"),
1018                });
1019            }
1020            _ => {
1021                return Err(WasmBuildError::InvalidSpec {
1022                    message: format!("Cargo workspace package name `{requested}` is ambiguous"),
1023                });
1024            }
1025        }
1026    }
1027    Ok(selected_ids)
1028}
1029
1030fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
1031    let mut dependencies = HashMap::<String, Vec<String>>::new();
1032    let nodes = metadata
1033        .pointer("/resolve/nodes")
1034        .and_then(Value::as_array)
1035        .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
1036    for node in nodes {
1037        let id = required_string(node, "id")?;
1038        let deps = node
1039            .get("deps")
1040            .and_then(Value::as_array)
1041            .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
1042            .iter()
1043            .map(|dependency| required_string(dependency, "pkg"))
1044            .collect::<Result<Vec<_>, _>>()?;
1045        dependencies.insert(id, deps);
1046    }
1047    Ok(dependencies)
1048}
1049
1050fn workspace_configuration_inputs(workspace_root: &Path) -> Vec<(PathBuf, PathBuf)> {
1051    let mut inputs = Vec::new();
1052    add_if_present(
1053        &mut inputs,
1054        "workspace/Cargo.toml",
1055        workspace_root.join("Cargo.toml"),
1056    );
1057    add_if_present(
1058        &mut inputs,
1059        "workspace/Cargo.lock",
1060        workspace_root.join("Cargo.lock"),
1061    );
1062    add_if_present(
1063        &mut inputs,
1064        "workspace/.cargo/config.toml",
1065        workspace_root.join(".cargo/config.toml"),
1066    );
1067    add_if_present(
1068        &mut inputs,
1069        "workspace/.cargo/config",
1070        workspace_root.join(".cargo/config"),
1071    );
1072    add_if_present(
1073        &mut inputs,
1074        "workspace/rust-toolchain.toml",
1075        workspace_root.join("rust-toolchain.toml"),
1076    );
1077    add_if_present(
1078        &mut inputs,
1079        "workspace/rust-toolchain",
1080        workspace_root.join("rust-toolchain"),
1081    );
1082    inputs
1083}
1084
1085fn append_package_inputs(
1086    inputs: &mut Vec<(PathBuf, PathBuf)>,
1087    packages: &HashMap<String, MetadataPackage>,
1088    closure: BTreeSet<String>,
1089    workspace_root: &Path,
1090) -> Result<(), WasmBuildError> {
1091    for id in closure {
1092        let Some(package) = packages.get(&id) else {
1093            return Err(invalid_metadata(&format!(
1094                "resolved package `{id}` is missing"
1095            )));
1096        };
1097        if !package.is_local {
1098            continue;
1099        }
1100        let root = package.manifest_path.parent().ok_or_else(|| {
1101            invalid_metadata(&format!(
1102                "package `{}` manifest has no parent",
1103                package.name
1104            ))
1105        })?;
1106        let relative_manifest = package
1107            .manifest_path
1108            .strip_prefix(workspace_root)
1109            .unwrap_or(&package.manifest_path);
1110        let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
1111            .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
1112        inputs.push((label, root.to_owned()));
1113    }
1114    Ok(())
1115}
1116
1117fn append_additional_inputs(
1118    inputs: &mut Vec<(PathBuf, PathBuf)>,
1119    spec: &WasmBuildSpec,
1120    workspace_root: &Path,
1121) {
1122    for additional in &spec.additional_inputs {
1123        let path = if additional.is_absolute() {
1124            additional.clone()
1125        } else {
1126            workspace_root.join(additional)
1127        };
1128        inputs.push((PathBuf::from("additional").join(additional), path));
1129    }
1130}
1131
1132fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
1133    let mut exclusions = vec![
1134        spec.target_dir.clone(),
1135        spec.workspace_root.join("target"),
1136        spec.workspace_root.join(".git"),
1137    ];
1138    for (_, path) in inputs {
1139        if path.is_dir() {
1140            exclusions.push(path.join("target"));
1141            exclusions.push(path.join(".git"));
1142        }
1143    }
1144    exclusions
1145}
1146
1147fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
1148    let mut names = spec.inherited_env.clone();
1149    names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
1150    let mut environment = names
1151        .into_iter()
1152        .map(|name| {
1153            let value = std::env::var_os(&name);
1154            (name, value)
1155        })
1156        .collect::<BTreeMap<_, _>>();
1157    for (key, value) in &spec.extra_env {
1158        environment.insert(key.clone(), Some(value.clone()));
1159    }
1160    environment
1161}
1162
1163fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
1164    for (key, value) in &spec.extra_env {
1165        command.env(key, value);
1166    }
1167}
1168
1169fn run_cargo_build(spec: &WasmBuildSpec, build_target_dir: &Path) -> Result<(), WasmBuildError> {
1170    let mut command = Command::new(&spec.cargo_program);
1171    command
1172        .current_dir(&spec.workspace_root)
1173        .env("CARGO_TARGET_DIR", build_target_dir)
1174        .args(["build", "--target", &spec.target])
1175        .args(&spec.cargo_profile_args);
1176    apply_command_environment(&mut command, spec);
1177    for package in &spec.packages {
1178        command.args(["-p", package]);
1179    }
1180
1181    let output = command
1182        .output()
1183        .map_err(|source| WasmBuildError::CommandSpawn {
1184            phase: WasmBuildPhase::CargoBuild,
1185            program: spec.cargo_program.clone(),
1186            source,
1187        })?;
1188    ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ())
1189}
1190
1191fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
1192    if output.status.success() {
1193        return Ok(output);
1194    }
1195    Err(WasmBuildError::CommandFailed {
1196        phase,
1197        status: output.status,
1198        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1199        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1200    })
1201}
1202
1203fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
1204    let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
1205    packages.sort_unstable();
1206    packages.dedup();
1207    packages
1208        .into_iter()
1209        .map(|package| {
1210            if spec.target == DEFAULT_TARGET {
1211                wasm_path(target_dir, package, &spec.profile_target_dir)
1212            } else {
1213                target_dir
1214                    .join(&spec.target)
1215                    .join(&spec.profile_target_dir)
1216                    .join(format!("{package}.wasm"))
1217            }
1218        })
1219        .collect()
1220}
1221
1222fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
1223    artifacts.iter().all(|path| {
1224        fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
1225            && cache_stamp_matches(path, fingerprint)
1226    })
1227}
1228
1229fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
1230    artifacts
1231        .iter()
1232        .filter(|path| {
1233            fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
1234        })
1235        .cloned()
1236        .collect()
1237}
1238
1239fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
1240    let stamp_path = artifact_stamp_path(artifact);
1241    let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
1242        return false;
1243    };
1244    fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
1245}
1246
1247fn artifact_stamp_path(artifact: &Path) -> PathBuf {
1248    let mut name = artifact
1249        .file_name()
1250        .map_or_else(|| OsString::from("artifact"), OsString::from);
1251    name.push(".ic-testkit-build");
1252    artifact.with_file_name(name)
1253}
1254
1255fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
1256    let artifact_digest = digest_bytes("wasm-artifact-v1", &fs::read(artifact)?);
1257    Ok(format!(
1258        "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
1259    ))
1260}
1261
1262fn publish_artifact_stamps(
1263    artifacts: &[PathBuf],
1264    fingerprint: InputDigest,
1265) -> Result<(), WasmBuildError> {
1266    for artifact in artifacts {
1267        let stamp_path = artifact_stamp_path(artifact);
1268        let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
1269            WasmBuildError::Io {
1270                operation: "hash built Wasm artifact",
1271                path: artifact.clone(),
1272                source,
1273            }
1274        })?;
1275        write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
1276            operation: "publish Wasm build stamp",
1277            path: stamp_path,
1278            source,
1279        })?;
1280    }
1281    Ok(())
1282}
1283
1284fn materialize_artifacts(
1285    cached_artifacts: &[PathBuf],
1286    artifacts: &[PathBuf],
1287    fingerprint: InputDigest,
1288) -> Result<(), WasmBuildError> {
1289    for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
1290        let contents = fs::read(cached).map_err(|source| WasmBuildError::Io {
1291            operation: "read content-addressed Wasm artifact",
1292            path: cached.clone(),
1293            source,
1294        })?;
1295        write_atomic(artifact, &contents).map_err(|source| WasmBuildError::Io {
1296            operation: "publish Wasm artifact",
1297            path: artifact.clone(),
1298            source,
1299        })?;
1300    }
1301    publish_artifact_stamps(artifacts, fingerprint)
1302}
1303
1304fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
1305    remove_dir_all_if_present(path).map_err(|source| WasmBuildError::Io {
1306        operation: "remove incomplete content-addressed Cargo target directory",
1307        path: path.to_owned(),
1308        source,
1309    })
1310}
1311
1312fn remove_dir_all_if_present(path: &Path) -> io::Result<()> {
1313    match fs::remove_dir_all(path) {
1314        Ok(()) => Ok(()),
1315        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1316        Err(error) => Err(error),
1317    }
1318}
1319
1320fn open_lock_file(path: &Path) -> Result<File, WasmBuildError> {
1321    if let Some(parent) = path.parent() {
1322        create_dir_all(parent, "create Wasm build lock directory")?;
1323    }
1324    OpenOptions::new()
1325        .create(true)
1326        .read(true)
1327        .write(true)
1328        .truncate(false)
1329        .open(path)
1330        .map_err(|source| WasmBuildError::Io {
1331            operation: "open Wasm build lock",
1332            path: path.to_owned(),
1333            source,
1334        })
1335}
1336
1337fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
1338    fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
1339        operation,
1340        path: path.to_owned(),
1341        source,
1342    })
1343}
1344
1345fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
1346    if path.exists() {
1347        inputs.push((PathBuf::from(label), path));
1348    }
1349}
1350
1351fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
1352    value
1353        .get(field)
1354        .and_then(Value::as_str)
1355        .map(str::to_owned)
1356        .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
1357}
1358
1359fn invalid_metadata(message: &str) -> WasmBuildError {
1360    WasmBuildError::InvalidMetadata {
1361        message: message.to_owned(),
1362    }
1363}
1364
1365impl std::fmt::Display for WasmBuildPhase {
1366    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1367        formatter.write_str(match self {
1368            Self::CargoMetadata => "cargo metadata",
1369            Self::CargoIdentity => "Cargo identity",
1370            Self::RustcIdentity => "Rust compiler identity",
1371            Self::CargoBuild => "cargo build",
1372        })
1373    }
1374}
1375
1376impl std::fmt::Display for WasmBuildError {
1377    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1378        match self {
1379            Self::InvalidSpec { message } => {
1380                write!(formatter, "invalid Wasm build spec: {message}")
1381            }
1382            Self::Io {
1383                operation,
1384                path,
1385                source,
1386            } => write!(
1387                formatter,
1388                "failed to {operation} at {}: {source}",
1389                path.display()
1390            ),
1391            Self::CommandSpawn {
1392                phase,
1393                program,
1394                source,
1395            } => write!(
1396                formatter,
1397                "failed to launch {phase} using `{}`: {source}",
1398                program.to_string_lossy(),
1399            ),
1400            Self::CommandFailed {
1401                phase,
1402                status,
1403                stdout,
1404                stderr,
1405            } => write!(
1406                formatter,
1407                "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
1408            ),
1409            Self::InvalidMetadata { message } => {
1410                write!(formatter, "invalid Cargo metadata: {message}")
1411            }
1412            Self::MissingArtifacts { paths } => write!(
1413                formatter,
1414                "cargo build succeeded without producing: {}",
1415                paths
1416                    .iter()
1417                    .map(|path| path.display().to_string())
1418                    .collect::<Vec<_>>()
1419                    .join(", "),
1420            ),
1421            Self::InputsChangedDuringBuild { before, after } => write!(
1422                formatter,
1423                "Wasm build inputs changed while Cargo was running: {before} -> {after}",
1424            ),
1425            Self::FailedBuildCleanup {
1426                build_error,
1427                path,
1428                source,
1429            } => write!(
1430                formatter,
1431                "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
1432                path.display(),
1433            ),
1434        }
1435    }
1436}
1437
1438impl std::error::Error for WasmBuildError {
1439    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1440        match self {
1441            Self::Io { source, .. }
1442            | Self::CommandSpawn { source, .. }
1443            | Self::FailedBuildCleanup { source, .. } => Some(source),
1444            _ => None,
1445        }
1446    }
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use super::{
1452        CACHE_DIRECTORY_TAG_SIGNATURE, IncompleteBuildDirectory, WasmBuildCachePrunePolicy,
1453        WasmBuildError, WasmBuildOutcome, WasmBuildSpec, directory_logical_size,
1454        ensure_cache_directory_tag, finish_fingerprint_build, metadata_arguments,
1455        prune_wasm_build_cache, validate_spec, write_last_used,
1456    };
1457    use std::{
1458        ffi::OsString,
1459        fs,
1460        path::{Path, PathBuf},
1461        sync::atomic::{AtomicU64, Ordering},
1462        time::{Duration, SystemTime, UNIX_EPOCH},
1463    };
1464
1465    static TEMP_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1466
1467    #[test]
1468    fn metadata_receives_only_resolution_arguments() {
1469        let arguments = [
1470            OsString::from("--profile"),
1471            OsString::from("fast"),
1472            OsString::from("--locked"),
1473            OsString::from("--features=alpha,beta"),
1474        ];
1475        assert_eq!(
1476            metadata_arguments(&arguments),
1477            [
1478                OsString::from("--locked"),
1479                OsString::from("--features=alpha,beta"),
1480            ]
1481        );
1482    }
1483
1484    #[test]
1485    fn build_spec_requires_at_least_one_package() {
1486        let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
1487        assert!(matches!(
1488            validate_spec(&spec),
1489            Err(WasmBuildError::InvalidSpec { .. })
1490        ));
1491    }
1492
1493    #[test]
1494    fn cache_directory_tag_is_created_at_target_root() {
1495        let target_dir = unique_temp_directory("cache-directory-tag");
1496        fs::write(target_dir.join("CACHEDIR.TAG"), "not a cache tag")
1497            .expect("write invalid cache tag");
1498
1499        ensure_cache_directory_tag(&target_dir).expect("write valid cache tag");
1500
1501        let contents =
1502            fs::read_to_string(target_dir.join("CACHEDIR.TAG")).expect("read cache directory tag");
1503        assert!(contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE));
1504        fs::remove_dir_all(target_dir).expect("remove tag test directory");
1505    }
1506
1507    #[test]
1508    fn failed_build_removes_its_incomplete_fingerprint_directory() {
1509        let target_dir = unique_temp_directory("failed-build-cleanup");
1510        let fingerprint_dir = target_dir.join("a".repeat(64));
1511        fs::create_dir_all(&fingerprint_dir).expect("create incomplete target directory");
1512        fs::write(fingerprint_dir.join("partial-output"), b"partial")
1513            .expect("write incomplete output");
1514        let failure: Result<WasmBuildOutcome, WasmBuildError> = Err(WasmBuildError::InvalidSpec {
1515            message: "synthetic build failure".to_owned(),
1516        });
1517
1518        let result = finish_fingerprint_build(
1519            failure,
1520            IncompleteBuildDirectory::new(fingerprint_dir.clone()),
1521        );
1522
1523        assert!(matches!(result, Err(WasmBuildError::InvalidSpec { .. })));
1524        assert!(!fingerprint_dir.exists());
1525        fs::remove_dir_all(target_dir).expect("remove cleanup test directory");
1526    }
1527
1528    #[test]
1529    fn age_pruning_removes_only_stale_fingerprint_directories() {
1530        let target_dir = unique_temp_directory("age-pruning");
1531        let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1532        let old = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1533        let current = create_cache_entry(&cache_root, 'b', 10, SystemTime::now());
1534        let unrelated = cache_root.join("not-a-fingerprint");
1535        fs::create_dir_all(&unrelated).expect("create unrelated directory");
1536
1537        let report = prune_wasm_build_cache(
1538            &target_dir,
1539            WasmBuildCachePrunePolicy::new().with_max_age(Duration::from_secs(60)),
1540        )
1541        .expect("prune old cache entry");
1542
1543        assert_eq!(report.entries_scanned(), 2);
1544        assert_eq!(report.entries_removed(), 1);
1545        assert_eq!(report.entries_retained(), 1);
1546        assert!(!old.exists());
1547        assert!(current.exists());
1548        assert!(unrelated.exists());
1549        assert!(target_dir.join("CACHEDIR.TAG").is_file());
1550        fs::remove_dir_all(target_dir).expect("remove age-pruning test directory");
1551    }
1552
1553    #[test]
1554    fn size_pruning_removes_least_recently_used_entries_first() {
1555        let target_dir = unique_temp_directory("size-pruning");
1556        let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1557        let oldest = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
1558        let middle = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
1559        let newest = create_cache_entry(&cache_root, 'c', 10, UNIX_EPOCH + Duration::from_secs(3));
1560        let newest_bytes = directory_logical_size(&newest).expect("measure newest entry");
1561
1562        let report = prune_wasm_build_cache(
1563            &target_dir,
1564            WasmBuildCachePrunePolicy::new().with_max_size_bytes(newest_bytes),
1565        )
1566        .expect("prune cache to size");
1567
1568        assert_eq!(report.entries_scanned(), 3);
1569        assert_eq!(report.entries_removed(), 2);
1570        assert_eq!(report.entries_retained(), 1);
1571        assert!(report.bytes_retained() <= newest_bytes);
1572        assert!(!oldest.exists());
1573        assert!(!middle.exists());
1574        assert!(newest.exists());
1575        fs::remove_dir_all(target_dir).expect("remove size-pruning test directory");
1576    }
1577
1578    fn create_cache_entry(
1579        cache_root: &Path,
1580        fingerprint_digit: char,
1581        payload_bytes: usize,
1582        last_used: SystemTime,
1583    ) -> PathBuf {
1584        let path = cache_root.join(fingerprint_digit.to_string().repeat(64));
1585        fs::create_dir_all(&path).expect("create cache entry");
1586        fs::write(path.join("payload"), vec![0; payload_bytes]).expect("write cache payload");
1587        write_last_used(&path, last_used).expect("write cache use time");
1588        path
1589    }
1590
1591    fn unique_temp_directory(label: &str) -> PathBuf {
1592        let sequence = TEMP_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1593        let path = std::env::temp_dir().join(format!(
1594            "ic-testkit-{label}-{}-{sequence}",
1595            std::process::id()
1596        ));
1597        if path.exists() {
1598            fs::remove_dir_all(&path).expect("remove stale test directory");
1599        }
1600        fs::create_dir_all(&path).expect("create test directory");
1601        path
1602    }
1603}