Skip to main content

ic_build/artifacts/
transaction.rs

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