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