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