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