Skip to main content

ic_build/artifacts/
transaction.rs

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