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