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