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