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