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