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