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