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