Skip to main content

ic_build/artifacts/
transaction.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    ffi::{OsStr, OsString},
4    fmt::Write as _,
5    fs::{self, File},
6    io,
7    path::{Path, PathBuf},
8    sync::atomic::{AtomicU64, Ordering},
9    time::{Duration, Instant},
10};
11
12mod boundaries;
13use boundaries::CargoInputBoundaries;
14
15use super::{
16    cache_fs::{
17        ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
18        LAST_USED_FILE, RetainedCacheEntry, canonicalize_allow_missing, directory_logical_size,
19        ensure_cache_directory_tag, is_sha256_directory, lock_cache_file,
20        perform_scheduled_cache_maintenance, prune_direct_child_directories,
21        record_cache_entry_use, remove_path_if_present, remove_unretained_entry,
22        try_lock_cache_file,
23    },
24    digest::{
25        FileDigest, InputDigest, InputHasher, copy_file_atomic, digest_bytes, digest_file,
26        digest_labeled_paths, materialize_verified_output, os_bytes, read_file_with_limit,
27    },
28    staging::OwnedDirectory,
29    wasm_cache::{
30        ResolvedCargoBuildInputs, WasmBuildError, WasmBuildSpec, resolve_cargo_build_inputs,
31    },
32};
33
34const ARTIFACT_CACHE_FORMAT: &str = "ic-testkit-artifact-set-v1";
35const MANIFEST_FILE: &str = "manifest.ic-testkit";
36const OUTPUT_FILE_SUFFIX: &str = ".artifact";
37const MAX_PREPARATION_RETRIES: usize = 3;
38static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
39
40/// Built-in validation applied to one transactional artifact output.
41#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
42pub enum ArtifactOutputValidation {
43    /// Require a regular file; an empty file is valid.
44    RegularFile,
45    /// Require a nonempty regular file.
46    #[default]
47    NonEmptyFile,
48}
49
50/// Complete caller-owned description of one transactional artifact set.
51///
52/// Declared inputs and tools must not be located inside `cache_root`. Output
53/// destinations must remain outside it, resolve to distinct non-nested paths,
54/// and not overlap a declared input or tool. Cargo-derived cache/output paths must
55/// likewise remain outside resolved inputs unless covered by their exact
56/// generated-state exclusions.
57#[derive(Clone, Eq, PartialEq)]
58pub struct ArtifactCacheSpec {
59    cache_root: PathBuf,
60    namespace: String,
61    recipe_id: String,
62    coordination_scope: String,
63    inputs: Vec<LabeledPath>,
64    tools: Vec<LabeledPath>,
65    arguments: Vec<OsString>,
66    environment: BTreeMap<OsString, Option<OsString>>,
67    // Builders keep label/name order canonical; duplicates remain for validation.
68    identities: Vec<LabeledIdentity>,
69    cargo_build_inputs: Vec<CargoBuildInputSet>,
70    outputs: Vec<OutputSpec>,
71    prune_policy: Option<ArtifactCachePrunePolicy>,
72    prune_interval: Option<Duration>,
73}
74
75/// Result of preparing a transactional artifact-set acquisition.
76pub enum ArtifactCachePreparation {
77    /// A complete verified entry was materialized without running the caller's build.
78    Reused(ArtifactCacheRecord),
79    /// The caller must populate and commit a transaction-owned staging directory.
80    Build(ArtifactBuildTransaction),
81}
82
83/// Whether an artifact transaction published or reused its exact output set.
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub enum ArtifactCacheOutcome {
86    /// The caller populated staging and the complete result was published.
87    Built(ArtifactCacheRecord),
88    /// A complete existing entry was verified and materialized.
89    Reused(ArtifactCacheRecord),
90}
91
92/// Details shared by built and reused transactional artifact outcomes.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct ArtifactCacheRecord {
95    key: InputDigest,
96    input_digest: InputDigest,
97    artifacts: Vec<ArtifactCacheArtifact>,
98    _retention: RetainedCacheEntry,
99    timings: ArtifactCacheTimings,
100    maintenance: Option<ArtifactCacheMaintenance>,
101}
102
103/// One read-only exact artifact retained by its owning acquisition record.
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct ArtifactCacheArtifact {
106    name: String,
107    path: PathBuf,
108}
109
110/// Phase timings for one transactional artifact-cache acquisition.
111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
112pub struct ArtifactCacheTimings {
113    coordination_lock_wait: Duration,
114    content_lock_wait: Duration,
115    namespace_lock_wait: Duration,
116    input_capture: Duration,
117    cache_lookup: Duration,
118    caller_build: Option<Duration>,
119    output_validation: Duration,
120    publication: Duration,
121    materialization: Duration,
122    maintenance: Option<Duration>,
123    total: Duration,
124}
125
126/// Owned miss transaction holding the recipe and content-key process locks.
127pub struct ArtifactBuildTransaction {
128    spec: Box<ArtifactCacheSpec>,
129    resolved: ResolvedKey,
130    // Drop the owned staging child before releasing either process lock below.
131    staging_directory: OwnedDirectory,
132    entry_directory: PathBuf,
133    namespace_directory: PathBuf,
134    _coordination_lock: File,
135    _content_lock: File,
136    timings: ArtifactCacheTimings,
137    total_started: Instant,
138    caller_build_started: Instant,
139}
140
141/// Structured failure from transactional artifact caching.
142#[non_exhaustive]
143#[derive(Debug)]
144pub enum ArtifactCacheError {
145    /// The caller supplied an incomplete, ambiguous, or unsafe specification.
146    InvalidSpec {
147        /// Diagnostic describing the rejected input or evidence.
148        message: String,
149    },
150    /// A filesystem operation failed.
151    Io {
152        /// Filesystem or process operation that failed.
153        operation: &'static str,
154        /// Exact path involved in the failed operation.
155        path: PathBuf,
156        /// Original underlying error, including its typed failure evidence.
157        source: io::Error,
158    },
159    /// Inputs repeatedly changed while preparing a stable cache acquisition.
160    InputsChangedDuringPreparation {
161        /// Input identity captured before the operation.
162        before: InputDigest,
163        /// Input identity observed during revalidation.
164        after: InputDigest,
165    },
166    /// Inputs changed while the caller owned a build transaction.
167    InputsChangedDuringAcquisition {
168        /// Input identity captured before the operation.
169        before: InputDigest,
170        /// Input identity observed during revalidation.
171        after: InputDigest,
172    },
173    /// A resolved Cargo source/configuration set changed after it was captured.
174    CargoBuildInputsChanged {
175        /// Caller-selected logical input-set label.
176        label: String,
177        /// Semantic Cargo fingerprint or conservative validation digest captured.
178        before: InputDigest,
179        /// Semantic fingerprint or conservative validation digest revalidated.
180        after: InputDigest,
181    },
182    /// Rehashing a resolved Cargo input set failed.
183    CargoBuildInputRevalidation {
184        /// Caller-selected logical input-set label.
185        label: String,
186        /// Underlying exact Cargo input error.
187        source: WasmBuildError,
188    },
189    /// One or more declared staged outputs were missing or failed validation.
190    InvalidOutputs {
191        /// Logical names and paths of outputs that failed validation.
192        outputs: Vec<(String, PathBuf)>,
193    },
194    /// A logical output name was not declared by the transaction specification.
195    UnknownOutput {
196        /// Undeclared logical output name.
197        name: String,
198    },
199    /// Transaction failure cleanup also failed.
200    FailedTransactionCleanup {
201        /// Original transaction error before cleanup was attempted.
202        transaction_error: Box<Self>,
203        /// Exact path involved in the failed operation.
204        path: PathBuf,
205        /// Original underlying error, including its typed failure evidence.
206        source: io::Error,
207    },
208}
209
210#[derive(Clone, Debug, Eq, PartialEq)]
211struct LabeledPath {
212    label: String,
213    path: PathBuf,
214}
215
216#[derive(Clone, Eq, PartialEq)]
217struct LabeledIdentity {
218    label: String,
219    value: Vec<u8>,
220}
221
222#[derive(Clone, Eq, PartialEq)]
223struct CargoBuildInputSet {
224    label: String,
225    build_spec: WasmBuildSpec,
226    resolved: ResolvedCargoBuildInputs,
227}
228
229#[derive(Clone, Debug, Eq, PartialEq)]
230struct OutputSpec {
231    name: String,
232    destination: PathBuf,
233    validation: ArtifactOutputValidation,
234}
235
236#[derive(Clone, Copy)]
237struct ResolvedKey {
238    key: InputDigest,
239    input_digest: InputDigest,
240}
241
242impl ArtifactCacheSpec {
243    /// Describe one external artifact recipe using non-secret stable identifiers.
244    ///
245    /// The namespace selects an independent content store. `recipe_id` must be
246    /// bumped whenever undeclared pipeline semantics change. The default
247    /// coordination scope is the namespace.
248    #[must_use]
249    pub fn new(cache_root: &Path, namespace: &str, recipe_id: &str) -> Self {
250        Self {
251            cache_root: cache_root.to_owned(),
252            namespace: namespace.to_owned(),
253            recipe_id: recipe_id.to_owned(),
254            coordination_scope: namespace.to_owned(),
255            inputs: Vec::new(),
256            tools: Vec::new(),
257            arguments: Vec::new(),
258            environment: BTreeMap::new(),
259            identities: Vec::new(),
260            cargo_build_inputs: Vec::new(),
261            outputs: Vec::new(),
262            prune_policy: None,
263            prune_interval: None,
264        }
265    }
266
267    /// Serialize recipes that share caller-owned mutable external build state.
268    #[must_use]
269    pub fn with_coordination_scope(mut self, coordination_scope: &str) -> Self {
270        coordination_scope.clone_into(&mut self.coordination_scope);
271        self
272    }
273
274    /// Add one exact input file or directory outside the cache root under a stable label.
275    #[must_use]
276    pub fn with_input(mut self, label: &str, path: &Path) -> Self {
277        self.inputs.push(LabeledPath {
278            label: label.to_owned(),
279            path: path.to_owned(),
280        });
281        self
282    }
283
284    /// Add one exact executable or tool path outside the cache root under a stable label.
285    #[must_use]
286    pub fn with_tool(mut self, label: &str, path: &Path) -> Self {
287        self.tools.push(LabeledPath {
288            label: label.to_owned(),
289            path: path.to_owned(),
290        });
291        self
292    }
293
294    /// Set OS-native ordered command arguments that contribute to the content key.
295    #[must_use]
296    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
297    where
298        I: IntoIterator<Item = S>,
299        S: AsRef<OsStr>,
300    {
301        self.arguments = arguments
302            .into_iter()
303            .map(|argument| argument.as_ref().to_owned())
304            .collect();
305        self
306    }
307
308    /// Set OS-native environment values that contribute to the content key.
309    ///
310    /// This records identity only. The caller must apply these values to the
311    /// external command executed on a cache miss.
312    #[must_use]
313    pub fn with_environment<I, K, V>(mut self, environment: I) -> Self
314    where
315        I: IntoIterator<Item = (K, V)>,
316        K: Into<OsString>,
317        V: Into<OsString>,
318    {
319        self.environment.extend(
320            environment
321                .into_iter()
322                .map(|(name, value)| (name.into(), Some(value.into()))),
323        );
324        self
325    }
326
327    /// Record OS-native environment names whose unset state contributes to the content key.
328    ///
329    /// The caller must also remove these names from the external command's
330    /// environment on a cache miss.
331    #[must_use]
332    pub fn with_unset_environment<I, S>(mut self, names: I) -> Self
333    where
334        I: IntoIterator<Item = S>,
335        S: Into<OsString>,
336    {
337        self.environment
338            .extend(names.into_iter().map(|name| (name.into(), None)));
339        self
340    }
341
342    /// Add opaque non-secret identity bytes under a stable logical label.
343    ///
344    /// Labels are stored in canonical order; declaration order does not affect identity.
345    #[must_use]
346    pub fn with_identity_bytes(mut self, label: &str, value: &[u8]) -> Self {
347        self.identities.push(LabeledIdentity {
348            label: label.to_owned(),
349            value: value.to_vec(),
350        });
351        self.identities
352            .sort_by(|left, right| left.label.cmp(&right.label));
353        self
354    }
355
356    /// Add one exact Cargo input snapshot as transactional cache identity and guard.
357    ///
358    /// The semantic resolved fingerprint keys toolchain, arguments,
359    /// environment and the selected dependency closure. Its conservative
360    /// Cargo-aware validation paths and exclusions are rehashed automatically
361    /// during preparation, cache-hit materialization and transaction commit.
362    /// Labels are stored in canonical order; declaration order does not affect identity.
363    #[must_use]
364    pub fn with_cargo_build_inputs(
365        mut self,
366        label: &str,
367        build_spec: &WasmBuildSpec,
368        resolved: &ResolvedCargoBuildInputs,
369    ) -> Self {
370        self.cargo_build_inputs.push(CargoBuildInputSet {
371            label: label.to_owned(),
372            build_spec: build_spec.clone(),
373            resolved: resolved.clone(),
374        });
375        self.cargo_build_inputs
376            .sort_by(|left, right| left.label.cmp(&right.label));
377        self
378    }
379
380    /// Declare one nonempty regular-file output and its nonoverlapping public destination.
381    #[must_use]
382    pub fn with_output(self, name: &str, destination: &Path) -> Self {
383        self.with_output_validation(name, destination, ArtifactOutputValidation::NonEmptyFile)
384    }
385
386    /// Declare one output, nonoverlapping public destination, and validation policy.
387    #[must_use]
388    pub fn with_output_validation(
389        mut self,
390        name: &str,
391        destination: &Path,
392        validation: ArtifactOutputValidation,
393    ) -> Self {
394        self.outputs.push(OutputSpec {
395            name: name.to_owned(),
396            destination: destination.to_owned(),
397            validation,
398        });
399        self.outputs
400            .sort_by(|left, right| left.name.cmp(&right.name));
401        self
402    }
403
404    /// Apply best-effort retention while protecting the acquired entry.
405    #[must_use]
406    pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
407        self.prune_policy = Some(policy);
408        self.prune_interval = None;
409        self
410    }
411
412    /// Apply retention at most once per `minimum_interval` for this namespace.
413    ///
414    /// The small due-marker check still occurs under the namespace lock.
415    /// Successful acquisitions skip the directory scan until the interval has
416    /// elapsed. The interval applies to attempted maintenance, including a
417    /// nonfatal failed attempt. A zero interval is equivalent to
418    /// [`Self::with_prune_policy`].
419    #[must_use]
420    pub const fn with_prune_policy_at_most_every(
421        mut self,
422        policy: ArtifactCachePrunePolicy,
423        minimum_interval: Duration,
424    ) -> Self {
425        self.prune_policy = Some(policy);
426        self.prune_interval = Some(minimum_interval);
427        self
428    }
429
430    /// Caller-selected root containing cache data and coordination locks.
431    #[must_use]
432    pub fn cache_root(&self) -> &Path {
433        &self.cache_root
434    }
435
436    /// Stable caller-owned content-store namespace.
437    #[must_use]
438    pub fn namespace(&self) -> &str {
439        &self.namespace
440    }
441
442    /// Stable caller-owned external recipe identity.
443    #[must_use]
444    pub fn recipe_id(&self) -> &str {
445        &self.recipe_id
446    }
447}
448
449struct DebugValues<I>(I);
450
451impl<I> std::fmt::Debug for DebugValues<I>
452where
453    I: Iterator + Clone,
454    I::Item: std::fmt::Debug,
455{
456    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        formatter.debug_list().entries(self.0.clone()).finish()
458    }
459}
460
461impl std::fmt::Debug for ArtifactCacheSpec {
462    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        formatter
464            .debug_struct("ArtifactCacheSpec")
465            .field("cache_root", &self.cache_root)
466            .field("namespace", &self.namespace)
467            .field("recipe_id", &self.recipe_id)
468            .field("coordination_scope", &self.coordination_scope)
469            .field("inputs", &self.inputs)
470            .field("tools", &self.tools)
471            .field("argument_count", &self.arguments.len())
472            .field("environment_names", &DebugValues(self.environment.keys()))
473            .field(
474                "identity_labels",
475                &DebugValues(
476                    self.identities
477                        .iter()
478                        .map(|identity| identity.label.as_str()),
479                ),
480            )
481            .field(
482                "cargo_build_input_labels",
483                &DebugValues(
484                    self.cargo_build_inputs
485                        .iter()
486                        .map(|input| input.label.as_str()),
487                ),
488            )
489            .field("outputs", &self.outputs)
490            .field("prune_policy", &self.prune_policy)
491            .field("prune_interval", &self.prune_interval)
492            .finish()
493    }
494}
495
496impl ArtifactCachePreparation {
497    /// Return the already materialized record, or `None` for a cache miss transaction.
498    #[must_use]
499    pub const fn reused_record(&self) -> Option<&ArtifactCacheRecord> {
500        match self {
501            Self::Reused(record) => Some(record),
502            Self::Build(_) => None,
503        }
504    }
505}
506
507impl ArtifactCacheOutcome {
508    /// Read the common acquisition record.
509    #[must_use]
510    pub const fn record(&self) -> &ArtifactCacheRecord {
511        match self {
512            Self::Built(record) | Self::Reused(record) => record,
513        }
514    }
515
516    /// Report whether a complete matching artifact set was reused.
517    #[must_use]
518    pub const fn is_reused(&self) -> bool {
519        matches!(self, Self::Reused(_))
520    }
521}
522
523impl ArtifactCacheRecord {
524    /// Exact content key selecting the immutable cache entry.
525    #[must_use]
526    pub const fn key(&self) -> InputDigest {
527        self.key
528    }
529
530    /// Exact digest of declared input and tool paths.
531    #[must_use]
532    pub const fn input_digest(&self) -> InputDigest {
533        self.input_digest
534    }
535
536    /// Exact, read-only artifacts retained until this record and its clones drop.
537    ///
538    /// Keep a record alive while consuming these paths. Copying a path or an
539    /// artifact descriptor does not retain ownership. Configured materialization
540    /// destinations remain mutable and are not retained.
541    #[must_use]
542    pub fn artifacts(&self) -> &[ArtifactCacheArtifact] {
543        &self.artifacts
544    }
545
546    /// Phase timings for this acquisition.
547    #[must_use]
548    pub const fn timings(&self) -> ArtifactCacheTimings {
549        self.timings
550    }
551
552    /// Best-effort retention attempted during this acquisition, when configured.
553    #[must_use]
554    pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
555        self.maintenance.as_ref()
556    }
557}
558
559impl ArtifactCacheArtifact {
560    /// Stable logical output name.
561    #[must_use]
562    pub fn name(&self) -> &str {
563        &self.name
564    }
565
566    /// Exact cache path, valid while the acquisition record or a clone is alive.
567    #[must_use]
568    pub fn path(&self) -> &Path {
569        &self.path
570    }
571}
572
573impl ArtifactCacheTimings {
574    /// Time waiting for the caller-selected coordination scope.
575    #[must_use]
576    pub const fn coordination_lock_wait(self) -> Duration {
577        self.coordination_lock_wait
578    }
579
580    /// Time waiting for exact content-key ownership.
581    #[must_use]
582    pub const fn content_lock_wait(self) -> Duration {
583        self.content_lock_wait
584    }
585
586    /// Time waiting for short namespace mutations.
587    #[must_use]
588    pub const fn namespace_lock_wait(self) -> Duration {
589        self.namespace_lock_wait
590    }
591
592    /// Time spent hashing and verifying declared inputs.
593    #[must_use]
594    pub const fn input_capture(self) -> Duration {
595        self.input_capture
596    }
597
598    /// Time spent validating or rejecting a committed cache entry.
599    #[must_use]
600    pub const fn cache_lookup(self) -> Duration {
601        self.cache_lookup
602    }
603
604    /// Time the caller held a miss transaction before committing it.
605    #[must_use]
606    pub const fn caller_build(self) -> Option<Duration> {
607        self.caller_build
608    }
609
610    /// Time spent validating staged output files.
611    #[must_use]
612    pub const fn output_validation(self) -> Duration {
613        self.output_validation
614    }
615
616    /// Time spent publishing an immutable content entry.
617    #[must_use]
618    pub const fn publication(self) -> Duration {
619        self.publication
620    }
621
622    /// Time spent materializing caller-facing output destinations.
623    #[must_use]
624    pub const fn materialization(self) -> Duration {
625        self.materialization
626    }
627
628    /// Time spent on configured best-effort retention.
629    #[must_use]
630    pub const fn maintenance(self) -> Option<Duration> {
631        self.maintenance
632    }
633
634    /// Complete acquisition duration.
635    #[must_use]
636    pub const fn total(self) -> Duration {
637        self.total
638    }
639
640    pub(super) fn saturating_add(self, other: Self) -> Self {
641        Self {
642            coordination_lock_wait: self
643                .coordination_lock_wait
644                .saturating_add(other.coordination_lock_wait),
645            content_lock_wait: self
646                .content_lock_wait
647                .saturating_add(other.content_lock_wait),
648            namespace_lock_wait: self
649                .namespace_lock_wait
650                .saturating_add(other.namespace_lock_wait),
651            input_capture: self.input_capture.saturating_add(other.input_capture),
652            cache_lookup: self.cache_lookup.saturating_add(other.cache_lookup),
653            caller_build: [self.caller_build, other.caller_build]
654                .into_iter()
655                .flatten()
656                .reduce(Duration::saturating_add),
657            output_validation: self
658                .output_validation
659                .saturating_add(other.output_validation),
660            publication: self.publication.saturating_add(other.publication),
661            materialization: self.materialization.saturating_add(other.materialization),
662            maintenance: [self.maintenance, other.maintenance]
663                .into_iter()
664                .flatten()
665                .reduce(Duration::saturating_add),
666            total: self.total.saturating_add(other.total),
667        }
668    }
669}
670
671impl std::fmt::Display for ArtifactCacheTimings {
672    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673        write!(
674            formatter,
675            "total={:?} coordination_lock={:?} content_lock={:?} namespace_lock={:?} inputs={:?} lookup={:?} build={:?} validation={:?} publication={:?} materialization={:?} maintenance={:?}",
676            self.total,
677            self.coordination_lock_wait,
678            self.content_lock_wait,
679            self.namespace_lock_wait,
680            self.input_capture,
681            self.cache_lookup,
682            self.caller_build,
683            self.output_validation,
684            self.publication,
685            self.materialization,
686            self.maintenance,
687        )
688    }
689}
690
691impl std::fmt::Display for ArtifactCacheOutcome {
692    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
693        let state = if self.is_reused() { "reused" } else { "built" };
694        write!(
695            formatter,
696            "{state} key={} artifacts={} {}",
697            self.record().key,
698            self.record().artifacts.len(),
699            self.record().timings,
700        )
701    }
702}
703
704impl ArtifactBuildTransaction {
705    /// Transaction-owned directory in which the caller may run its build.
706    ///
707    /// Commit accepts only the cache-created `outputs` child at this root.
708    /// Callers must remove or relocate logs and other temporary root children.
709    #[must_use]
710    pub fn staging_directory(&self) -> &Path {
711        self.staging_directory.path()
712    }
713
714    /// Checked staging destination for one declared logical output.
715    ///
716    /// # Errors
717    /// Returns [`ArtifactCacheError::UnknownOutput`] for an undeclared output name.
718    pub fn output_path(&self, name: &str) -> Result<PathBuf, ArtifactCacheError> {
719        self.output_index(name)
720            .map(|index| staged_output_path(self.staging_directory.path(), index))
721            .ok_or_else(|| ArtifactCacheError::UnknownOutput {
722                name: name.to_owned(),
723            })
724    }
725
726    /// Copy a fixed external output into its transaction-owned staging path.
727    ///
728    /// # Errors
729    /// Returns an unknown-output or atomic source-copy/publication error.
730    pub fn import_output(&self, name: &str, source: &Path) -> Result<(), ArtifactCacheError> {
731        let destination = self.output_path(name)?;
732        copy_file_atomic(source, &destination).map_err(|source_error| ArtifactCacheError::Io {
733            operation: "import artifact output into staging",
734            path: destination,
735            source: source_error,
736        })?;
737        Ok(())
738    }
739
740    /// Validate the exact staging schema and atomically publish the complete output set.
741    ///
742    /// # Errors
743    /// Returns staging-schema, output verification, input-change, lock or publication
744    /// errors. Failed staging cleanup preserves both the transaction and cleanup errors.
745    pub fn commit(mut self) -> Result<ArtifactCacheOutcome, ArtifactCacheError> {
746        let result = self.commit_inner();
747        match result {
748            Ok(outcome) => Ok(outcome),
749            Err(transaction_error) if self.staging_directory.is_armed() => {
750                let path = self.staging_directory.path().to_owned();
751                match self.staging_directory.try_cleanup() {
752                    Ok(()) => Err(transaction_error),
753                    Err(source) => Err(ArtifactCacheError::FailedTransactionCleanup {
754                        transaction_error: Box::new(transaction_error),
755                        path,
756                        source,
757                    }),
758                }
759            }
760            Err(transaction_error) => Err(transaction_error),
761        }
762    }
763
764    /// Abandon the transaction and synchronously remove its staging directory.
765    ///
766    /// # Errors
767    /// Returns the filesystem error if removing the owned staging directory fails.
768    pub fn abort(mut self) -> Result<(), ArtifactCacheError> {
769        self.staging_directory
770            .try_cleanup()
771            .map_err(|source| ArtifactCacheError::Io {
772                operation: "abort artifact cache transaction",
773                path: self.staging_directory.path().to_owned(),
774                source,
775            })?;
776        Ok(())
777    }
778
779    fn output_index(&self, name: &str) -> Option<usize> {
780        self.spec
781            .outputs
782            .iter()
783            .position(|output| output.name == name)
784    }
785
786    fn commit_inner(&mut self) -> Result<ArtifactCacheOutcome, ArtifactCacheError> {
787        self.timings.caller_build = Some(self.caller_build_started.elapsed());
788
789        let validation_started = Instant::now();
790        let output_info = inspect_complete_output_set(&self.spec, self.staging_directory.path())?;
791        self.timings.output_validation = validation_started.elapsed();
792
793        let capture_started = Instant::now();
794        revalidate_cargo_build_input_fingerprints(&self.spec)?;
795        let verified = resolve_key(&self.spec)?;
796        self.timings.input_capture = self
797            .timings
798            .input_capture
799            .saturating_add(capture_started.elapsed());
800        if verified.input_digest != self.resolved.input_digest {
801            return Err(ArtifactCacheError::InputsChangedDuringAcquisition {
802                before: self.resolved.input_digest,
803                after: verified.input_digest,
804            });
805        }
806
807        let publication_started = Instant::now();
808        let manifest =
809            manifest_contents(self.resolved.key, &self.spec, output_info.iter().copied());
810        ic_host_fs::durable::write_bytes(
811            &self.staging_directory.path().join(MANIFEST_FILE),
812            ic_host_fs::durable::WriteOptions {
813                mode: ic_host_fs::durable::PublicationMode::Replace,
814                permissions: 0o666,
815            },
816            manifest.as_bytes(),
817        )
818        .map_err(|source| ArtifactCacheError::Io {
819            operation: "write artifact cache manifest",
820            path: self.staging_directory.path().join(MANIFEST_FILE),
821            source: io::Error::other(source),
822        })?;
823        let namespace_lock_path = namespace_lock_path(&self.spec);
824        let (_namespace_lock, namespace_wait) =
825            lock_cache_file(&namespace_lock_path).map_err(artifact_cache_fs_error)?;
826        self.timings.namespace_lock_wait = self
827            .timings
828            .namespace_lock_wait
829            .saturating_add(namespace_wait);
830        remove_unretained_entry(&self.entry_directory).map_err(artifact_cache_fs_error)?;
831        fs::rename(self.staging_directory.path(), &self.entry_directory).map_err(|source| {
832            ArtifactCacheError::Io {
833                operation: "publish artifact cache entry",
834                path: self.entry_directory.clone(),
835                source,
836            }
837        })?;
838        self.staging_directory.disarm();
839        self.timings.publication = publication_started.elapsed();
840
841        let materialization_started = Instant::now();
842        materialize_outputs(&self.spec, &self.entry_directory, None)?;
843        self.timings.materialization = materialization_started.elapsed();
844        record_cache_entry_use(&self.entry_directory).map_err(artifact_cache_fs_error)?;
845        let (maintenance, maintenance_timing) = perform_maintenance_locked(
846            &self.spec,
847            &self.namespace_directory,
848            &self.entry_directory,
849        );
850        self.timings.maintenance = maintenance_timing;
851        self.timings.total = self.total_started.elapsed();
852
853        Ok(ArtifactCacheOutcome::Built(cache_record(
854            &self.spec,
855            self.resolved,
856            self.timings,
857            maintenance,
858        )?))
859    }
860}
861
862/// Verify or begin one exact external artifact-set transaction.
863///
864/// A hit validates the complete cached output set and makes every configured
865/// destination available. On Unix, a regular file owned by the effective user,
866/// with owner read/write permissions, one link, no executable or special
867/// permission bits, and matching contents is left in place. Other destinations
868/// are atomically replaced from the verified entry. Cold commits and non-Unix
869/// acquisitions always replace destinations. Callers must coordinate any other
870/// writers to these mutable paths.
871/// Final symlinks with missing or cyclic referents can be replaced. Directories
872/// and directory referents remain invalid destinations.
873///
874/// # Errors
875/// Returns invalid specification/boundary, Cargo input revalidation, input-change,
876/// lock, input-read or cache acquisition/materialization errors.
877///
878/// # Panics
879/// Panics if internal preparation retries exhaust without recording an input change.
880pub fn prepare_artifact_cache(
881    spec: &ArtifactCacheSpec,
882) -> Result<ArtifactCachePreparation, ArtifactCacheError> {
883    let total_started = Instant::now();
884    validate_spec(spec)?;
885    validate_filesystem_boundaries(spec)?;
886    let namespace_directory = initialize_cache(spec)?;
887
888    let coordination_lock_path = coordination_lock_path(spec);
889    let (coordination_lock, coordination_wait) =
890        lock_cache_file(&coordination_lock_path).map_err(artifact_cache_fs_error)?;
891    let mut timings = ArtifactCacheTimings {
892        coordination_lock_wait: coordination_wait,
893        ..ArtifactCacheTimings::default()
894    };
895    let initial_cargo_started = Instant::now();
896    revalidate_cargo_build_input_fingerprints(spec)?;
897    timings.input_capture = initial_cargo_started.elapsed();
898    let mut last_change = None;
899
900    for _ in 0..MAX_PREPARATION_RETRIES {
901        let capture_started = Instant::now();
902        let resolved = resolve_key(spec)?;
903        timings.input_capture = timings
904            .input_capture
905            .saturating_add(capture_started.elapsed());
906
907        let content_lock_path = content_lock_path(spec, resolved.key);
908        let (content_lock, content_wait) =
909            lock_cache_file(&content_lock_path).map_err(artifact_cache_fs_error)?;
910        timings.content_lock_wait = timings.content_lock_wait.saturating_add(content_wait);
911
912        let verification_started = Instant::now();
913        let verified = resolve_key(spec)?;
914        timings.input_capture = timings
915            .input_capture
916            .saturating_add(verification_started.elapsed());
917        if resolved.input_digest != verified.input_digest {
918            last_change = Some((resolved.input_digest, verified.input_digest));
919            drop(content_lock);
920            continue;
921        }
922
923        let entry_directory = entry_directory(&namespace_directory, resolved.key);
924        let namespace_lock_path = namespace_lock_path(spec);
925        let (namespace_lock, namespace_wait) =
926            lock_cache_file(&namespace_lock_path).map_err(artifact_cache_fs_error)?;
927        timings.namespace_lock_wait = timings.namespace_lock_wait.saturating_add(namespace_wait);
928        let lookup_started = Instant::now();
929        let verified_outputs = validated_cache_outputs(spec, resolved.key, &entry_directory)?;
930        timings.cache_lookup = timings
931            .cache_lookup
932            .saturating_add(lookup_started.elapsed());
933
934        if let Some(output_info) = verified_outputs {
935            let materialization_started = Instant::now();
936            materialize_outputs(spec, &entry_directory, Some(&output_info))?;
937            timings.materialization = timings
938                .materialization
939                .saturating_add(materialization_started.elapsed());
940            let after_started = Instant::now();
941            revalidate_cargo_build_input_fingerprints(spec)?;
942            let after = resolve_key(spec)?;
943            timings.input_capture = timings
944                .input_capture
945                .saturating_add(after_started.elapsed());
946            if after.input_digest != resolved.input_digest {
947                last_change = Some((resolved.input_digest, after.input_digest));
948                drop(namespace_lock);
949                drop(content_lock);
950                continue;
951            }
952            record_cache_entry_use(&entry_directory).map_err(artifact_cache_fs_error)?;
953            let (maintenance, maintenance_timing) =
954                perform_maintenance_locked(spec, &namespace_directory, &entry_directory);
955            timings.maintenance = maintenance_timing;
956            timings.total = total_started.elapsed();
957            return Ok(ArtifactCachePreparation::Reused(cache_record(
958                spec,
959                resolved,
960                timings,
961                maintenance,
962            )?));
963        }
964
965        remove_unretained_entry(&entry_directory).map_err(artifact_cache_fs_error)?;
966        let staging_directory = create_staging_directory(&namespace_directory, resolved.key)?;
967        drop(namespace_lock);
968        return Ok(ArtifactCachePreparation::Build(ArtifactBuildTransaction {
969            spec: Box::new(spec.clone()),
970            resolved,
971            staging_directory: OwnedDirectory::new(staging_directory),
972            entry_directory,
973            namespace_directory,
974            _coordination_lock: coordination_lock,
975            _content_lock: content_lock,
976            timings,
977            total_started,
978            caller_build_started: Instant::now(),
979        }));
980    }
981
982    let (before, after) = last_change.expect("preparation retries require a recorded input change");
983    Err(ArtifactCacheError::InputsChangedDuringPreparation { before, after })
984}
985
986fn revalidate_cargo_build_input_fingerprints(
987    spec: &ArtifactCacheSpec,
988) -> Result<(), ArtifactCacheError> {
989    for cargo_input in &spec.cargo_build_inputs {
990        let current = resolve_cargo_build_inputs(&cargo_input.build_spec).map_err(|source| {
991            ArtifactCacheError::CargoBuildInputRevalidation {
992                label: cargo_input.label.clone(),
993                source,
994            }
995        })?;
996        let before_validation = cargo_input.resolved.validation_digest();
997        let after_validation = current.validation_digest();
998        if after_validation != before_validation {
999            return Err(ArtifactCacheError::CargoBuildInputsChanged {
1000                label: cargo_input.label.clone(),
1001                before: before_validation,
1002                after: after_validation,
1003            });
1004        }
1005        let before = cargo_input.resolved.fingerprint();
1006        let after = current.fingerprint();
1007        if after != before {
1008            return Err(ArtifactCacheError::CargoBuildInputsChanged {
1009                label: cargo_input.label.clone(),
1010                before,
1011                after,
1012            });
1013        }
1014    }
1015    Ok(())
1016}
1017
1018/// Prune one transactional artifact namespace without acquiring an artifact.
1019///
1020/// This strict maintenance entry point returns cache errors directly. Build
1021/// acquisitions configured with [`ArtifactCacheSpec::with_prune_policy`] use
1022/// the same implementation but attach maintenance as a nonfatal record.
1023/// Abandoned staging is removed only when its content-key lock is currently
1024/// unowned and is reported separately from committed-entry retention.
1025/// Entries owned by live acquisition records are skipped until their last owner drops.
1026///
1027/// # Errors
1028/// Returns invalid namespace/root, lock, cache inspection or removal errors.
1029pub fn prune_artifact_cache(
1030    cache_root: &Path,
1031    namespace: &str,
1032    policy: ArtifactCachePrunePolicy,
1033) -> Result<ArtifactCachePruneReport, ArtifactCacheError> {
1034    validate_identifier("namespace", namespace)?;
1035    if cache_root.as_os_str().is_empty() {
1036        return invalid_spec("cache root must not be empty");
1037    }
1038    ensure_cache_directory_tag(cache_root).map_err(artifact_cache_fs_error)?;
1039    let namespace_directory = namespace_directory_for(cache_root, namespace);
1040    let lock_path = namespace_lock_path_for(cache_root, namespace);
1041    let (_lock, _) = lock_cache_file(&lock_path).map_err(artifact_cache_fs_error)?;
1042    prune_artifact_namespace_locked(cache_root, &namespace_directory, policy, None)
1043}
1044
1045fn initialize_cache(spec: &ArtifactCacheSpec) -> Result<PathBuf, ArtifactCacheError> {
1046    ensure_cache_directory_tag(&spec.cache_root).map_err(artifact_cache_fs_error)?;
1047    let namespace = namespace_directory(spec);
1048    fs::create_dir_all(entries_directory(&namespace)).map_err(|source| ArtifactCacheError::Io {
1049        operation: "create artifact cache namespace",
1050        path: namespace.clone(),
1051        source,
1052    })?;
1053    Ok(namespace)
1054}
1055
1056fn validate_spec(spec: &ArtifactCacheSpec) -> Result<(), ArtifactCacheError> {
1057    validate_identifier("namespace", &spec.namespace)?;
1058    validate_identifier("recipe identity", &spec.recipe_id)?;
1059    validate_identifier("coordination scope", &spec.coordination_scope)?;
1060    if spec.cache_root.as_os_str().is_empty() {
1061        return invalid_spec("cache root must not be empty");
1062    }
1063    if spec.outputs.is_empty() {
1064        return invalid_spec("at least one artifact output is required");
1065    }
1066
1067    let mut labels = BTreeSet::new();
1068    for (kind, paths) in [("input", &spec.inputs), ("tool", &spec.tools)] {
1069        for path in paths {
1070            validate_path_label(kind, &path.label)?;
1071            if !labels.insert((kind, path.label.as_str())) {
1072                return invalid_spec(&format!("duplicate {kind} label `{}`", path.label));
1073            }
1074        }
1075    }
1076    let mut identity_labels = BTreeSet::new();
1077    for identity in &spec.identities {
1078        validate_identifier("identity label", &identity.label)?;
1079        if !identity_labels.insert(&identity.label) {
1080            return invalid_spec(&format!("duplicate identity label `{}`", identity.label));
1081        }
1082    }
1083    let mut cargo_input_labels = BTreeSet::new();
1084    for cargo_inputs in &spec.cargo_build_inputs {
1085        validate_identifier("Cargo build input label", &cargo_inputs.label)?;
1086        if !cargo_input_labels.insert(&cargo_inputs.label) {
1087            return invalid_spec(&format!(
1088                "duplicate Cargo build input label `{}`",
1089                cargo_inputs.label
1090            ));
1091        }
1092    }
1093    if spec
1094        .environment
1095        .keys()
1096        .any(|name| name.as_os_str().is_empty())
1097    {
1098        return invalid_spec("environment names must not be empty");
1099    }
1100    let mut output_names = BTreeSet::new();
1101    let mut destinations = BTreeSet::new();
1102    for output in &spec.outputs {
1103        validate_output_name(&output.name)?;
1104        if !output_names.insert(&output.name) {
1105            return invalid_spec(&format!("duplicate output name `{}`", output.name));
1106        }
1107        if output.destination.as_os_str().is_empty() {
1108            return invalid_spec(&format!(
1109                "output `{}` destination must not be empty",
1110                output.name
1111            ));
1112        }
1113        if !destinations.insert(&output.destination) {
1114            return invalid_spec(&format!(
1115                "output `{}` shares a destination with another output",
1116                output.name
1117            ));
1118        }
1119    }
1120    Ok(())
1121}
1122
1123fn validate_filesystem_boundaries(spec: &ArtifactCacheSpec) -> Result<(), ArtifactCacheError> {
1124    let mut cargo_boundaries = CargoInputBoundaries::default();
1125    let cache_root =
1126        canonicalize_allow_missing(&spec.cache_root).map_err(|source| ArtifactCacheError::Io {
1127            operation: "resolve artifact cache root",
1128            path: spec.cache_root.clone(),
1129            source,
1130        })?;
1131    let mut declared_paths = Vec::with_capacity(spec.inputs.len() + spec.tools.len());
1132    for (kind, labeled_paths) in [("input", &spec.inputs), ("tool", &spec.tools)] {
1133        for labeled in labeled_paths {
1134            let canonical =
1135                canonicalize_path(&labeled.path, "canonicalize declared artifact cache path")?;
1136            if canonical.starts_with(&cache_root) {
1137                return invalid_spec(&format!(
1138                    "{kind} `{}` must not be located inside the artifact cache root",
1139                    labeled.label
1140                ));
1141            }
1142            let is_directory = fs::metadata(&canonical)
1143                .map_err(|source| ArtifactCacheError::Io {
1144                    operation: "inspect declared artifact cache path",
1145                    path: canonical.clone(),
1146                    source,
1147                })?
1148                .is_dir();
1149            let entry_path =
1150                resolve_file_entry(&labeled.path, "resolve declared artifact cache path entry")?;
1151            if entry_path != canonical {
1152                declared_paths.push((kind, labeled.label.as_str(), entry_path, false));
1153            }
1154            declared_paths.push((kind, labeled.label.as_str(), canonical, is_directory));
1155        }
1156    }
1157    for cargo_inputs in &spec.cargo_build_inputs {
1158        if cargo_boundaries.watches(&cargo_inputs.resolved, &cache_root)? {
1159            return invalid_spec(&format!(
1160                "artifact cache root must be outside resolved Cargo build inputs `{}` or inside one of their generated-state exclusions",
1161                cargo_inputs.label
1162            ));
1163        }
1164    }
1165
1166    let mut destinations = BTreeSet::new();
1167    for output in &spec.outputs {
1168        let destination =
1169            resolve_file_entry(&output.destination, "resolve artifact output destination")?;
1170        if destination.starts_with(&cache_root) {
1171            return invalid_spec(&format!(
1172                "output `{}` destination must be outside the artifact cache root",
1173                output.name
1174            ));
1175        }
1176        if destinations.iter().any(|other: &PathBuf| {
1177            destination.starts_with(other) || other.starts_with(&destination)
1178        }) {
1179            return invalid_spec(&format!(
1180                "output `{}` destination overlaps another output",
1181                output.name
1182            ));
1183        }
1184        destinations.insert(destination.clone());
1185        for (kind, label, declared, is_directory) in &declared_paths {
1186            if destination == *declared || (*is_directory && destination.starts_with(declared)) {
1187                return invalid_spec(&format!(
1188                    "output `{}` destination overlaps declared {kind} `{label}`",
1189                    output.name
1190                ));
1191            }
1192        }
1193        for cargo_inputs in &spec.cargo_build_inputs {
1194            if cargo_boundaries.watches(&cargo_inputs.resolved, &destination)? {
1195                return invalid_spec(&format!(
1196                    "output `{}` destination must be outside resolved Cargo build inputs `{}` or inside one of their generated-state exclusions",
1197                    output.name, cargo_inputs.label
1198                ));
1199            }
1200        }
1201        // Directory referents remain invalid: replacing their symlink may
1202        // strand another output's parent. Other final symlinks are replaced
1203        // as entries, independently of whether their referents are readable.
1204        match fs::symlink_metadata(&destination) {
1205            Ok(metadata)
1206                if metadata.is_dir()
1207                    || (metadata.file_type().is_symlink()
1208                        && fs::metadata(&destination).is_ok_and(|target| target.is_dir())) =>
1209            {
1210                return invalid_spec(&format!(
1211                    "output `{}` destination must not be an existing directory",
1212                    output.name
1213                ));
1214            }
1215            Ok(_) => {}
1216            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1217            Err(source) => {
1218                return Err(ArtifactCacheError::Io {
1219                    operation: "inspect artifact output destination",
1220                    path: destination,
1221                    source,
1222                });
1223            }
1224        }
1225    }
1226    Ok(())
1227}
1228
1229// Atomic publication replaces the directory entry, including a final symlink;
1230// resolving that symlink's referent would validate a different write location.
1231fn resolve_file_entry(path: &Path, operation: &'static str) -> Result<PathBuf, ArtifactCacheError> {
1232    let resolved = match (path.parent(), path.file_name()) {
1233        (Some(parent), Some(name)) => {
1234            canonicalize_allow_missing(parent).map(|parent| parent.join(name))
1235        }
1236        _ => canonicalize_allow_missing(path),
1237    };
1238    resolved.map_err(|source| ArtifactCacheError::Io {
1239        operation,
1240        path: path.to_owned(),
1241        source,
1242    })
1243}
1244
1245fn canonicalize_path(path: &Path, operation: &'static str) -> Result<PathBuf, ArtifactCacheError> {
1246    path.canonicalize()
1247        .map_err(|source| ArtifactCacheError::Io {
1248            operation,
1249            path: path.to_owned(),
1250            source,
1251        })
1252}
1253
1254fn validate_identifier(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
1255    if value.is_empty() {
1256        return invalid_spec(&format!("{kind} must not be empty"));
1257    }
1258    if value.len() > 256 {
1259        return invalid_spec(&format!("{kind} must not exceed 256 bytes"));
1260    }
1261    Ok(())
1262}
1263
1264fn validate_path_label(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
1265    validate_identifier(&format!("{kind} label"), value)?;
1266    if value
1267        .bytes()
1268        .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/')))
1269        || value
1270            .split('/')
1271            .any(|component| component.is_empty() || matches!(component, "." | ".."))
1272    {
1273        return invalid_spec(&format!(
1274            "{kind} label `{value}` must be a portable relative logical path"
1275        ));
1276    }
1277    Ok(())
1278}
1279
1280fn validate_output_name(name: &str) -> Result<(), ArtifactCacheError> {
1281    if name.is_empty() || name.len() > 128 {
1282        return invalid_spec("output names must contain 1 to 128 bytes");
1283    }
1284    if !name
1285        .bytes()
1286        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1287    {
1288        return invalid_spec(&format!(
1289            "output name `{name}` must use only ASCII letters, digits, dot, dash, or underscore"
1290        ));
1291    }
1292    if matches!(name, "." | "..") {
1293        return invalid_spec("output names must not be dot path components");
1294    }
1295    Ok(())
1296}
1297
1298fn invalid_spec<T>(message: &str) -> Result<T, ArtifactCacheError> {
1299    Err(ArtifactCacheError::InvalidSpec {
1300        message: message.to_owned(),
1301    })
1302}
1303
1304fn resolve_key(spec: &ArtifactCacheSpec) -> Result<ResolvedKey, ArtifactCacheError> {
1305    let paths = spec
1306        .inputs
1307        .iter()
1308        .map(|input| (PathBuf::from("input").join(&input.label), &input.path))
1309        .chain(
1310            spec.tools
1311                .iter()
1312                .map(|tool| (PathBuf::from("tool").join(&tool.label), &tool.path)),
1313        );
1314    let declared_input_digest = digest_labeled_paths(
1315        "artifact-set-inputs-v1",
1316        paths,
1317        std::slice::from_ref(&spec.cache_root),
1318    )
1319    .map_err(|source| ArtifactCacheError::Io {
1320        operation: "hash artifact cache inputs",
1321        path: spec.cache_root.clone(),
1322        source,
1323    })?;
1324    let input_digest = if spec.cargo_build_inputs.is_empty() {
1325        declared_input_digest
1326    } else {
1327        let mut inputs = InputHasher::new("artifact-set-inputs-with-cargo-v1");
1328        inputs.field("declared-input-digest", declared_input_digest.as_bytes());
1329        for cargo_input in &spec.cargo_build_inputs {
1330            let current = cargo_input
1331                .resolved
1332                .current_validation_digest()
1333                .map_err(|source| ArtifactCacheError::CargoBuildInputRevalidation {
1334                    label: cargo_input.label.clone(),
1335                    source,
1336                })?;
1337            let before = cargo_input.resolved.validation_digest();
1338            if current != before {
1339                return Err(ArtifactCacheError::CargoBuildInputsChanged {
1340                    label: cargo_input.label.clone(),
1341                    before,
1342                    after: current,
1343                });
1344            }
1345            inputs.field("cargo-input-label", cargo_input.label.as_bytes());
1346            inputs.field(
1347                "cargo-build-fingerprint",
1348                cargo_input.resolved.fingerprint().as_bytes(),
1349            );
1350            inputs.field(
1351                "cargo-input-digest",
1352                cargo_input.resolved.input_digest().as_bytes(),
1353            );
1354        }
1355        inputs.finish()
1356    };
1357
1358    let mut hasher = InputHasher::new(ARTIFACT_CACHE_FORMAT);
1359    hasher.field("namespace", spec.namespace.as_bytes());
1360    hasher.field("recipe-id", spec.recipe_id.as_bytes());
1361    hasher.field("input-digest", input_digest.as_bytes());
1362    for argument in &spec.arguments {
1363        hasher.field("argument", &os_bytes(argument));
1364    }
1365    for (name, value) in &spec.environment {
1366        hasher.field("environment-name", &os_bytes(name));
1367        match value {
1368            Some(value) => hasher.field("environment-value", &os_bytes(value)),
1369            None => hasher.field("environment-unset", b""),
1370        }
1371    }
1372    for identity in &spec.identities {
1373        hasher.field("identity-label", identity.label.as_bytes());
1374        hasher.field("identity-value", &identity.value);
1375    }
1376    for output in &spec.outputs {
1377        hasher.field("output-name", output.name.as_bytes());
1378        hasher.field(
1379            "output-validation",
1380            output.validation.cache_token().as_bytes(),
1381        );
1382    }
1383    Ok(ResolvedKey {
1384        key: hasher.finish(),
1385        input_digest,
1386    })
1387}
1388
1389fn validated_cache_outputs(
1390    spec: &ArtifactCacheSpec,
1391    key: InputDigest,
1392    entry: &Path,
1393) -> Result<Option<Vec<FileDigest>>, ArtifactCacheError> {
1394    let entry_metadata = match fs::symlink_metadata(entry) {
1395        Ok(metadata) => metadata,
1396        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1397        Err(source) => {
1398            return Err(ArtifactCacheError::Io {
1399                operation: "inspect artifact cache entry",
1400                path: entry.to_owned(),
1401                source,
1402            });
1403        }
1404    };
1405    if !entry_metadata.file_type().is_dir() || !cache_entry_root_is_valid(entry)? {
1406        return Ok(None);
1407    }
1408    let manifest_path = entry.join(MANIFEST_FILE);
1409    let manifest_metadata = match fs::symlink_metadata(&manifest_path) {
1410        Ok(metadata) => metadata,
1411        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1412        Err(source) => {
1413            return Err(ArtifactCacheError::Io {
1414                operation: "inspect artifact cache manifest",
1415                path: manifest_path,
1416                source,
1417            });
1418        }
1419    };
1420    if !manifest_metadata.file_type().is_file() {
1421        return Ok(None);
1422    }
1423    // Reject oversized manifests before inspecting cached output bytes. No
1424    // manifest can be represented if its checked length bound overflows.
1425    let Some(maximum_len) = maximum_manifest_len(spec) else {
1426        return Ok(None);
1427    };
1428    let Some(manifest) = read_file_with_limit(&manifest_path, maximum_len).map_err(|source| {
1429        ArtifactCacheError::Io {
1430            operation: "read artifact cache manifest",
1431            path: manifest_path,
1432            source,
1433        }
1434    })?
1435    else {
1436        return Ok(None);
1437    };
1438    if !manifest.starts_with(manifest_header(key).as_bytes()) {
1439        return Ok(None);
1440    }
1441    let Some(output_info) = inspect_cached_output_set(spec, entry)? else {
1442        return Ok(None);
1443    };
1444    Ok(
1445        (manifest == manifest_contents(key, spec, output_info.iter().copied()).as_bytes())
1446            .then_some(output_info),
1447    )
1448}
1449
1450fn inspect_complete_output_set(
1451    spec: &ArtifactCacheSpec,
1452    root: &Path,
1453) -> Result<Vec<FileDigest>, ArtifactCacheError> {
1454    let mut info = Vec::new();
1455    let mut invalid = Vec::new();
1456    let output_directory = root.join("outputs");
1457    if !is_plain_directory(
1458        &output_directory,
1459        "inspect artifact staging output directory",
1460    )? {
1461        invalid.push(("<outputs>".to_owned(), output_directory));
1462        return Err(ArtifactCacheError::InvalidOutputs { outputs: invalid });
1463    }
1464    let outputs = &spec.outputs;
1465    for (index, output) in outputs.iter().enumerate() {
1466        let path = staged_output_path(root, index);
1467        match inspect_artifact(&path, output.validation) {
1468            Ok(Some(artifact)) => info.push(artifact),
1469            Ok(None) => invalid.push((output.name.clone(), path)),
1470            Err(source) => {
1471                return Err(ArtifactCacheError::Io {
1472                    operation: "inspect staged artifact output",
1473                    path,
1474                    source,
1475                });
1476            }
1477        }
1478    }
1479    visit_undeclared_outputs(root, outputs.len(), |entry| {
1480        invalid.push(("<undeclared>".to_owned(), entry.path()));
1481    })?;
1482    visit_undeclared_children(
1483        root,
1484        |name| name == "outputs",
1485        "read artifact staging directory",
1486        |entry| invalid.push(("<undeclared>".to_owned(), entry.path())),
1487    )?;
1488    if invalid.is_empty() {
1489        Ok(info)
1490    } else {
1491        Err(ArtifactCacheError::InvalidOutputs { outputs: invalid })
1492    }
1493}
1494
1495fn inspect_cached_output_set(
1496    spec: &ArtifactCacheSpec,
1497    root: &Path,
1498) -> Result<Option<Vec<FileDigest>>, ArtifactCacheError> {
1499    let mut info = Vec::new();
1500    let outputs = &spec.outputs;
1501    for (index, output) in outputs.iter().enumerate() {
1502        let path = staged_output_path(root, index);
1503        match inspect_artifact(&path, output.validation) {
1504            Ok(Some(artifact)) => info.push(artifact),
1505            Ok(None) => return Ok(None),
1506            Err(source) => {
1507                return Err(ArtifactCacheError::Io {
1508                    operation: "inspect cached artifact output",
1509                    path,
1510                    source,
1511                });
1512            }
1513        }
1514    }
1515    if visit_undeclared_outputs(root, outputs.len(), |_| {})? {
1516        return Ok(None);
1517    }
1518    Ok(Some(info))
1519}
1520
1521fn visit_undeclared_outputs(
1522    root: &Path,
1523    output_count: usize,
1524    visit: impl FnMut(fs::DirEntry),
1525) -> Result<bool, ArtifactCacheError> {
1526    let output_directory = root.join("outputs");
1527    visit_undeclared_children(
1528        &output_directory,
1529        |name| {
1530            let Some(name) = name.to_str() else {
1531                return false;
1532            };
1533            name.strip_suffix(OUTPUT_FILE_SUFFIX)
1534                .and_then(|index| index.parse::<usize>().ok())
1535                .is_some_and(|index| index < output_count && name == format_output_index(index))
1536        },
1537        "read artifact output directory",
1538        visit,
1539    )
1540}
1541
1542fn visit_undeclared_children(
1543    directory: &Path,
1544    is_expected: impl Fn(&OsStr) -> bool,
1545    operation: &'static str,
1546    mut visit: impl FnMut(fs::DirEntry),
1547) -> Result<bool, ArtifactCacheError> {
1548    let entries = fs::read_dir(directory).map_err(|source| ArtifactCacheError::Io {
1549        operation,
1550        path: directory.to_owned(),
1551        source,
1552    })?;
1553    let mut undeclared = false;
1554    // Scan to completion even for boolean-only callers: a later directory-read
1555    // error must remain an error rather than becoming a cache miss.
1556    for entry in entries {
1557        let entry = entry.map_err(|source| ArtifactCacheError::Io {
1558            operation,
1559            path: directory.to_owned(),
1560            source,
1561        })?;
1562        if !is_expected(&entry.file_name()) {
1563            undeclared = true;
1564            visit(entry);
1565        }
1566    }
1567    Ok(undeclared)
1568}
1569
1570fn cache_entry_root_is_valid(root: &Path) -> Result<bool, ArtifactCacheError> {
1571    let expected = [
1572        "outputs",
1573        MANIFEST_FILE,
1574        LAST_USED_FILE,
1575        super::cache_fs::RETENTION_LOCK_FILE,
1576    ];
1577    if visit_undeclared_children(
1578        root,
1579        |name| expected.iter().any(|expected| name == *expected),
1580        "read artifact cache entry",
1581        |_| {},
1582    )? {
1583        return Ok(false);
1584    }
1585    if !is_plain_directory(
1586        &root.join("outputs"),
1587        "inspect artifact cache output directory",
1588    )? {
1589        return Ok(false);
1590    }
1591    let last_used = root.join(LAST_USED_FILE);
1592    match fs::symlink_metadata(&last_used) {
1593        Ok(metadata) => Ok(metadata.file_type().is_file()),
1594        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true),
1595        Err(source) => Err(ArtifactCacheError::Io {
1596            operation: "inspect artifact cache use marker",
1597            path: last_used,
1598            source,
1599        }),
1600    }
1601}
1602
1603fn is_plain_directory(path: &Path, operation: &'static str) -> Result<bool, ArtifactCacheError> {
1604    match fs::symlink_metadata(path) {
1605        Ok(metadata) => Ok(metadata.file_type().is_dir()),
1606        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
1607        Err(source) => Err(ArtifactCacheError::Io {
1608            operation,
1609            path: path.to_owned(),
1610            source,
1611        }),
1612    }
1613}
1614
1615fn inspect_artifact(
1616    path: &Path,
1617    validation: ArtifactOutputValidation,
1618) -> io::Result<Option<FileDigest>> {
1619    let metadata = match fs::symlink_metadata(path) {
1620        Ok(metadata) => metadata,
1621        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1622        Err(error) => return Err(error),
1623    };
1624    if !metadata.file_type().is_file() {
1625        return Ok(None);
1626    }
1627    if validation == ArtifactOutputValidation::NonEmptyFile && metadata.len() == 0 {
1628        return Ok(None);
1629    }
1630    digest_file("artifact-set-output-v1", path).map(Some)
1631}
1632
1633fn manifest_header(key: InputDigest) -> String {
1634    format!("{ARTIFACT_CACHE_FORMAT}\nkey:{key}\n")
1635}
1636
1637fn maximum_manifest_len(spec: &ArtifactCacheSpec) -> Option<usize> {
1638    // Exact writer layout with maximum-width u64 byte counts and SHA-256 hex.
1639    // Keep dynamic names/tokens in bytes, including any multibyte UTF-8.
1640    let header_len = ARTIFACT_CACHE_FORMAT.len() + "\nkey:\n".len() + 64;
1641    let row_overhead = "output:".len() + "::::\n".len() + 20 + 64;
1642    spec.outputs
1643        .iter()
1644        .enumerate()
1645        .try_fold(header_len, |length, (index, output)| {
1646            let index_digits = usize::try_from(index.checked_ilog10().unwrap_or(0) + 1).ok()?;
1647            length
1648                .checked_add(row_overhead)?
1649                .checked_add(index_digits)?
1650                .checked_add(output.name.len())?
1651                .checked_add(output.validation.cache_token().len())
1652        })
1653}
1654
1655fn manifest_contents(
1656    key: InputDigest,
1657    spec: &ArtifactCacheSpec,
1658    output_info: impl IntoIterator<Item = FileDigest>,
1659) -> String {
1660    let mut manifest = manifest_header(key);
1661    for ((index, output), info) in spec.outputs.iter().enumerate().zip(output_info) {
1662        writeln!(
1663            manifest,
1664            "output:{index}:{}:{}:{}:{}",
1665            output.name,
1666            output.validation.cache_token(),
1667            info.bytes,
1668            info.digest,
1669        )
1670        .expect("writing an artifact manifest to a String cannot fail");
1671    }
1672    manifest
1673}
1674
1675fn materialize_outputs(
1676    spec: &ArtifactCacheSpec,
1677    entry: &Path,
1678    verified_outputs: Option<&[FileDigest]>,
1679) -> Result<(), ArtifactCacheError> {
1680    for (index, output) in spec.outputs.iter().enumerate() {
1681        // Only a hit supplies the complete output set checked against its manifest.
1682        // A cold commit always publishes; a hit may leave matching destinations alone.
1683        let cached = staged_output_path(entry, index);
1684        materialize_verified_output(
1685            "artifact-set-output-v1",
1686            &cached,
1687            &output.destination,
1688            verified_outputs.map(|info| &info[index]),
1689        )
1690        .map_err(|source| ArtifactCacheError::Io {
1691            operation: "materialize artifact output",
1692            path: output.destination.clone(),
1693            source,
1694        })?;
1695    }
1696    Ok(())
1697}
1698
1699fn perform_maintenance_locked(
1700    spec: &ArtifactCacheSpec,
1701    namespace: &Path,
1702    protected_entry: &Path,
1703) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
1704    spec.prune_policy.map_or((None, None), |policy| {
1705        let identity = policy.maintenance_identity();
1706        perform_scheduled_cache_maintenance(namespace, spec.prune_interval, &identity, || {
1707            prune_artifact_namespace_locked(
1708                &spec.cache_root,
1709                namespace,
1710                policy,
1711                Some(protected_entry),
1712            )
1713            .map_err(|error| error.to_string())
1714        })
1715    })
1716}
1717
1718fn prune_artifact_namespace_locked(
1719    cache_root: &Path,
1720    namespace: &Path,
1721    policy: ArtifactCachePrunePolicy,
1722    protected_entry: Option<&Path>,
1723) -> Result<ArtifactCachePruneReport, ArtifactCacheError> {
1724    let mut report = prune_direct_child_directories(
1725        &entries_directory(namespace),
1726        policy,
1727        protected_entry,
1728        is_sha256_directory,
1729    )
1730    .map_err(artifact_cache_fs_error)?;
1731    remove_abandoned_staging(cache_root, namespace, &mut report)?;
1732    Ok(report)
1733}
1734
1735fn remove_abandoned_staging(
1736    cache_root: &Path,
1737    namespace: &Path,
1738    report: &mut ArtifactCachePruneReport,
1739) -> Result<(), ArtifactCacheError> {
1740    let staging_root = namespace.join("staging");
1741    let entries = match fs::read_dir(&staging_root) {
1742        Ok(entries) => entries,
1743        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1744        Err(source) => {
1745            return Err(ArtifactCacheError::Io {
1746                operation: "read artifact staging root during pruning",
1747                path: staging_root,
1748                source,
1749            });
1750        }
1751    };
1752    for entry in entries {
1753        let entry = entry.map_err(|source| ArtifactCacheError::Io {
1754            operation: "read artifact staging entry during pruning",
1755            path: staging_root.clone(),
1756            source,
1757        })?;
1758        let file_type = entry.file_type().map_err(|source| ArtifactCacheError::Io {
1759            operation: "inspect artifact staging entry during pruning",
1760            path: entry.path(),
1761            source,
1762        })?;
1763        if !file_type.is_dir() {
1764            continue;
1765        }
1766        let file_name = entry.file_name();
1767        let Some(key) = staging_content_key(&file_name) else {
1768            continue;
1769        };
1770        let lock_path = content_lock_path_for_key(cache_root, key);
1771        let Some(_content_lock) =
1772            try_lock_cache_file(&lock_path).map_err(artifact_cache_fs_error)?
1773        else {
1774            continue;
1775        };
1776        let path = entry.path();
1777        let bytes = directory_logical_size(&path).map_err(|source| ArtifactCacheError::Io {
1778            operation: "measure abandoned artifact staging directory",
1779            path: path.clone(),
1780            source,
1781        })?;
1782        remove_path_if_present(&path).map_err(|source| ArtifactCacheError::Io {
1783            operation: "remove abandoned artifact staging directory during pruning",
1784            path,
1785            source,
1786        })?;
1787        report.record_uncommitted_removal(bytes);
1788    }
1789    Ok(())
1790}
1791
1792fn staging_content_key(name: &OsStr) -> Option<&str> {
1793    let name = name.to_str()?;
1794    let (key, suffix) = name.split_once('-')?;
1795    (!suffix.is_empty() && key.len() == 64 && key.as_bytes().iter().all(u8::is_ascii_hexdigit))
1796        .then_some(key)
1797}
1798
1799fn cache_record(
1800    spec: &ArtifactCacheSpec,
1801    resolved: ResolvedKey,
1802    timings: ArtifactCacheTimings,
1803    maintenance: Option<ArtifactCacheMaintenance>,
1804) -> Result<ArtifactCacheRecord, ArtifactCacheError> {
1805    let entry = entry_directory(&namespace_directory(spec), resolved.key);
1806    let retention = RetainedCacheEntry::acquire(&entry).map_err(artifact_cache_fs_error)?;
1807    Ok(ArtifactCacheRecord {
1808        key: resolved.key,
1809        input_digest: resolved.input_digest,
1810        artifacts: spec
1811            .outputs
1812            .iter()
1813            .enumerate()
1814            .map(|(index, output)| ArtifactCacheArtifact {
1815                name: output.name.clone(),
1816                path: staged_output_path(&entry, index),
1817            })
1818            .collect(),
1819        timings,
1820        maintenance,
1821        _retention: retention,
1822    })
1823}
1824
1825fn create_staging_directory(
1826    namespace: &Path,
1827    key: InputDigest,
1828) -> Result<PathBuf, ArtifactCacheError> {
1829    let staging_root = namespace.join("staging");
1830    fs::create_dir_all(&staging_root).map_err(|source| ArtifactCacheError::Io {
1831        operation: "create artifact staging root",
1832        path: staging_root.clone(),
1833        source,
1834    })?;
1835    remove_same_key_staging(&staging_root, key)?;
1836    let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1837    let staging = staging_root.join(format!("{key}-{}-{sequence}", std::process::id()));
1838    fs::create_dir_all(staging.join("outputs")).map_err(|source| ArtifactCacheError::Io {
1839        operation: "create artifact transaction staging directory",
1840        path: staging.clone(),
1841        source,
1842    })?;
1843    Ok(staging)
1844}
1845
1846fn remove_same_key_staging(root: &Path, key: InputDigest) -> Result<(), ArtifactCacheError> {
1847    let prefix = format!("{key}-");
1848    let entries = fs::read_dir(root).map_err(|source| ArtifactCacheError::Io {
1849        operation: "read artifact staging root",
1850        path: root.to_owned(),
1851        source,
1852    })?;
1853    for entry in entries {
1854        let entry = entry.map_err(|source| ArtifactCacheError::Io {
1855            operation: "read artifact staging entry",
1856            path: root.to_owned(),
1857            source,
1858        })?;
1859        if entry.file_name().to_string_lossy().starts_with(&prefix) {
1860            remove_path_if_present(&entry.path()).map_err(|source| ArtifactCacheError::Io {
1861                operation: "remove abandoned artifact staging directory",
1862                path: entry.path(),
1863                source,
1864            })?;
1865        }
1866    }
1867    Ok(())
1868}
1869
1870fn staged_output_path(root: &Path, index: usize) -> PathBuf {
1871    root.join("outputs").join(format_output_index(index))
1872}
1873
1874fn format_output_index(index: usize) -> String {
1875    format!("{index:04}{OUTPUT_FILE_SUFFIX}")
1876}
1877
1878fn namespace_directory(spec: &ArtifactCacheSpec) -> PathBuf {
1879    namespace_directory_for(&spec.cache_root, &spec.namespace)
1880}
1881
1882fn namespace_directory_for(cache_root: &Path, namespace: &str) -> PathBuf {
1883    cache_root
1884        .join(".ic-testkit/artifact-sets/namespaces")
1885        .join(identifier_digest("artifact-cache-namespace-v1", namespace))
1886}
1887
1888fn entries_directory(namespace: &Path) -> PathBuf {
1889    namespace.join("entries")
1890}
1891
1892fn entry_directory(namespace: &Path, key: InputDigest) -> PathBuf {
1893    entries_directory(namespace).join(key.to_hex())
1894}
1895
1896fn coordination_lock_path(spec: &ArtifactCacheSpec) -> PathBuf {
1897    spec.cache_root
1898        .join(".ic-testkit/artifact-sets/locks/coordination")
1899        .join(format!(
1900            "{}.lock",
1901            identifier_digest("artifact-cache-coordination-v1", &spec.coordination_scope,)
1902        ))
1903}
1904
1905fn content_lock_path(spec: &ArtifactCacheSpec, key: InputDigest) -> PathBuf {
1906    content_lock_path_for_key(&spec.cache_root, &key.to_hex())
1907}
1908
1909fn content_lock_path_for_key(cache_root: &Path, key: &str) -> PathBuf {
1910    cache_root
1911        .join(".ic-testkit/artifact-sets/locks/content")
1912        .join(format!("{key}.lock"))
1913}
1914
1915fn namespace_lock_path(spec: &ArtifactCacheSpec) -> PathBuf {
1916    namespace_lock_path_for(&spec.cache_root, &spec.namespace)
1917}
1918
1919fn namespace_lock_path_for(cache_root: &Path, namespace: &str) -> PathBuf {
1920    cache_root
1921        .join(".ic-testkit/artifact-sets/locks/namespaces")
1922        .join(format!(
1923            "{}.lock",
1924            identifier_digest("artifact-cache-namespace-v1", namespace)
1925        ))
1926}
1927
1928fn identifier_digest(domain: &str, identifier: &str) -> String {
1929    digest_bytes(domain, identifier.as_bytes()).to_hex()
1930}
1931
1932fn artifact_cache_fs_error(error: CacheFsError) -> ArtifactCacheError {
1933    ArtifactCacheError::Io {
1934        operation: error.operation,
1935        path: error.path,
1936        source: error.source,
1937    }
1938}
1939
1940impl ArtifactOutputValidation {
1941    const fn cache_token(self) -> &'static str {
1942        match self {
1943            Self::RegularFile => "regular-file",
1944            Self::NonEmptyFile => "nonempty-file",
1945        }
1946    }
1947}
1948
1949impl std::fmt::Display for ArtifactCacheError {
1950    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1951        match self {
1952            Self::InvalidSpec { message } => {
1953                write!(formatter, "invalid artifact cache spec: {message}")
1954            }
1955            Self::Io {
1956                operation,
1957                path,
1958                source,
1959            } => write!(
1960                formatter,
1961                "failed to {operation} at {}: {source}",
1962                path.display()
1963            ),
1964            Self::InputsChangedDuringPreparation { before, after } => write!(
1965                formatter,
1966                "artifact inputs repeatedly changed during cache preparation: {before} -> {after}",
1967            ),
1968            Self::InputsChangedDuringAcquisition { before, after } => write!(
1969                formatter,
1970                "artifact inputs changed while the caller was building: {before} -> {after}",
1971            ),
1972            Self::CargoBuildInputsChanged {
1973                label,
1974                before,
1975                after,
1976            } => write!(
1977                formatter,
1978                "resolved Cargo build inputs `{label}` changed: {before} -> {after}",
1979            ),
1980            Self::CargoBuildInputRevalidation { label, source } => write!(
1981                formatter,
1982                "failed to revalidate resolved Cargo build inputs `{label}`: {source}",
1983            ),
1984            Self::InvalidOutputs { outputs } => {
1985                formatter.write_str("artifact transaction has missing or invalid outputs: ")?;
1986                for (index, (name, path)) in outputs.iter().enumerate() {
1987                    if index != 0 {
1988                        formatter.write_str(", ")?;
1989                    }
1990                    write!(formatter, "{name} ({})", path.display())?;
1991                }
1992                Ok(())
1993            }
1994            Self::UnknownOutput { name } => {
1995                write!(
1996                    formatter,
1997                    "artifact transaction has no output named `{name}`"
1998                )
1999            }
2000            Self::FailedTransactionCleanup {
2001                transaction_error,
2002                path,
2003                source,
2004            } => write!(
2005                formatter,
2006                "artifact transaction failed ({transaction_error}) and staging cleanup at {} also failed: {source}",
2007                path.display(),
2008            ),
2009        }
2010    }
2011}
2012
2013impl std::error::Error for ArtifactCacheError {
2014    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2015        match self {
2016            Self::Io { source, .. } | Self::FailedTransactionCleanup { source, .. } => Some(source),
2017            Self::CargoBuildInputRevalidation { source, .. } => Some(source),
2018            _ => None,
2019        }
2020    }
2021}
2022
2023#[cfg(test)]
2024mod tests;