Skip to main content

ic_testkit/artifacts/
transaction.rs

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