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
449struct DebugValues<I>(I);
450
451impl<I> std::fmt::Debug for DebugValues<I>
452where
453 I: Iterator + Clone,
454 I::Item: std::fmt::Debug,
455{
456 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 formatter.debug_list().entries(self.0.clone()).finish()
458 }
459}
460
461impl std::fmt::Debug for ArtifactCacheSpec {
462 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 formatter
464 .debug_struct("ArtifactCacheSpec")
465 .field("cache_root", &self.cache_root)
466 .field("namespace", &self.namespace)
467 .field("recipe_id", &self.recipe_id)
468 .field("coordination_scope", &self.coordination_scope)
469 .field("inputs", &self.inputs)
470 .field("tools", &self.tools)
471 .field("argument_count", &self.arguments.len())
472 .field("environment_names", &DebugValues(self.environment.keys()))
473 .field(
474 "identity_labels",
475 &DebugValues(
476 self.identities
477 .iter()
478 .map(|identity| identity.label.as_str()),
479 ),
480 )
481 .field(
482 "cargo_build_input_labels",
483 &DebugValues(
484 self.cargo_build_inputs
485 .iter()
486 .map(|input| input.label.as_str()),
487 ),
488 )
489 .field("outputs", &self.outputs)
490 .field("prune_policy", &self.prune_policy)
491 .field("prune_interval", &self.prune_interval)
492 .finish()
493 }
494}
495
496impl ArtifactCachePreparation {
497 #[must_use]
499 pub const fn reused_record(&self) -> Option<&ArtifactCacheRecord> {
500 match self {
501 Self::Reused(record) => Some(record),
502 Self::Build(_) => None,
503 }
504 }
505}
506
507impl ArtifactCacheOutcome {
508 #[must_use]
510 pub const fn record(&self) -> &ArtifactCacheRecord {
511 match self {
512 Self::Built(record) | Self::Reused(record) => record,
513 }
514 }
515
516 #[must_use]
518 pub const fn is_reused(&self) -> bool {
519 matches!(self, Self::Reused(_))
520 }
521}
522
523impl ArtifactCacheRecord {
524 #[must_use]
526 pub const fn key(&self) -> InputDigest {
527 self.key
528 }
529
530 #[must_use]
532 pub const fn input_digest(&self) -> InputDigest {
533 self.input_digest
534 }
535
536 #[must_use]
542 pub fn artifacts(&self) -> &[ArtifactCacheArtifact] {
543 &self.artifacts
544 }
545
546 #[must_use]
548 pub const fn timings(&self) -> ArtifactCacheTimings {
549 self.timings
550 }
551
552 #[must_use]
554 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
555 self.maintenance.as_ref()
556 }
557}
558
559impl ArtifactCacheArtifact {
560 #[must_use]
562 pub fn name(&self) -> &str {
563 &self.name
564 }
565
566 #[must_use]
568 pub fn path(&self) -> &Path {
569 &self.path
570 }
571}
572
573impl ArtifactCacheTimings {
574 #[must_use]
576 pub const fn coordination_lock_wait(self) -> Duration {
577 self.coordination_lock_wait
578 }
579
580 #[must_use]
582 pub const fn content_lock_wait(self) -> Duration {
583 self.content_lock_wait
584 }
585
586 #[must_use]
588 pub const fn namespace_lock_wait(self) -> Duration {
589 self.namespace_lock_wait
590 }
591
592 #[must_use]
594 pub const fn input_capture(self) -> Duration {
595 self.input_capture
596 }
597
598 #[must_use]
600 pub const fn cache_lookup(self) -> Duration {
601 self.cache_lookup
602 }
603
604 #[must_use]
606 pub const fn caller_build(self) -> Option<Duration> {
607 self.caller_build
608 }
609
610 #[must_use]
612 pub const fn output_validation(self) -> Duration {
613 self.output_validation
614 }
615
616 #[must_use]
618 pub const fn publication(self) -> Duration {
619 self.publication
620 }
621
622 #[must_use]
624 pub const fn materialization(self) -> Duration {
625 self.materialization
626 }
627
628 #[must_use]
630 pub const fn maintenance(self) -> Option<Duration> {
631 self.maintenance
632 }
633
634 #[must_use]
636 pub const fn total(self) -> Duration {
637 self.total
638 }
639
640 pub(super) fn saturating_add(self, other: Self) -> Self {
641 Self {
642 coordination_lock_wait: self
643 .coordination_lock_wait
644 .saturating_add(other.coordination_lock_wait),
645 content_lock_wait: self
646 .content_lock_wait
647 .saturating_add(other.content_lock_wait),
648 namespace_lock_wait: self
649 .namespace_lock_wait
650 .saturating_add(other.namespace_lock_wait),
651 input_capture: self.input_capture.saturating_add(other.input_capture),
652 cache_lookup: self.cache_lookup.saturating_add(other.cache_lookup),
653 caller_build: [self.caller_build, other.caller_build]
654 .into_iter()
655 .flatten()
656 .reduce(Duration::saturating_add),
657 output_validation: self
658 .output_validation
659 .saturating_add(other.output_validation),
660 publication: self.publication.saturating_add(other.publication),
661 materialization: self.materialization.saturating_add(other.materialization),
662 maintenance: [self.maintenance, other.maintenance]
663 .into_iter()
664 .flatten()
665 .reduce(Duration::saturating_add),
666 total: self.total.saturating_add(other.total),
667 }
668 }
669}
670
671impl std::fmt::Display for ArtifactCacheTimings {
672 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673 write!(
674 formatter,
675 "total={:?} coordination_lock={:?} content_lock={:?} namespace_lock={:?} inputs={:?} lookup={:?} build={:?} validation={:?} publication={:?} materialization={:?} maintenance={:?}",
676 self.total,
677 self.coordination_lock_wait,
678 self.content_lock_wait,
679 self.namespace_lock_wait,
680 self.input_capture,
681 self.cache_lookup,
682 self.caller_build,
683 self.output_validation,
684 self.publication,
685 self.materialization,
686 self.maintenance,
687 )
688 }
689}
690
691impl std::fmt::Display for ArtifactCacheOutcome {
692 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
693 let state = if self.is_reused() { "reused" } else { "built" };
694 write!(
695 formatter,
696 "{state} key={} artifacts={} {}",
697 self.record().key,
698 self.record().artifacts.len(),
699 self.record().timings,
700 )
701 }
702}
703
704impl ArtifactBuildTransaction {
705 #[must_use]
710 pub fn staging_directory(&self) -> &Path {
711 self.staging_directory.path()
712 }
713
714 pub fn output_path(&self, name: &str) -> Result<PathBuf, ArtifactCacheError> {
719 self.output_index(name)
720 .map(|index| staged_output_path(self.staging_directory.path(), index))
721 .ok_or_else(|| ArtifactCacheError::UnknownOutput {
722 name: name.to_owned(),
723 })
724 }
725
726 pub fn import_output(&self, name: &str, source: &Path) -> Result<(), ArtifactCacheError> {
731 let destination = self.output_path(name)?;
732 copy_file_atomic(source, &destination).map_err(|source_error| ArtifactCacheError::Io {
733 operation: "import artifact output into staging",
734 path: destination,
735 source: source_error,
736 })?;
737 Ok(())
738 }
739
740 pub fn commit(mut self) -> Result<ArtifactCacheOutcome, ArtifactCacheError> {
746 let result = self.commit_inner();
747 match result {
748 Ok(outcome) => Ok(outcome),
749 Err(transaction_error) if self.staging_directory.is_armed() => {
750 let path = self.staging_directory.path().to_owned();
751 match self.staging_directory.try_cleanup() {
752 Ok(()) => Err(transaction_error),
753 Err(source) => Err(ArtifactCacheError::FailedTransactionCleanup {
754 transaction_error: Box::new(transaction_error),
755 path,
756 source,
757 }),
758 }
759 }
760 Err(transaction_error) => Err(transaction_error),
761 }
762 }
763
764 pub fn abort(mut self) -> Result<(), ArtifactCacheError> {
769 self.staging_directory
770 .try_cleanup()
771 .map_err(|source| ArtifactCacheError::Io {
772 operation: "abort artifact cache transaction",
773 path: self.staging_directory.path().to_owned(),
774 source,
775 })?;
776 Ok(())
777 }
778
779 fn output_index(&self, name: &str) -> Option<usize> {
780 self.spec
781 .outputs
782 .iter()
783 .position(|output| output.name == name)
784 }
785
786 fn commit_inner(&mut self) -> Result<ArtifactCacheOutcome, ArtifactCacheError> {
787 self.timings.caller_build = Some(self.caller_build_started.elapsed());
788
789 let validation_started = Instant::now();
790 let output_info = inspect_complete_output_set(&self.spec, self.staging_directory.path())?;
791 self.timings.output_validation = validation_started.elapsed();
792
793 let capture_started = Instant::now();
794 revalidate_cargo_build_input_fingerprints(&self.spec)?;
795 let verified = resolve_key(&self.spec)?;
796 self.timings.input_capture = self
797 .timings
798 .input_capture
799 .saturating_add(capture_started.elapsed());
800 if verified.input_digest != self.resolved.input_digest {
801 return Err(ArtifactCacheError::InputsChangedDuringAcquisition {
802 before: self.resolved.input_digest,
803 after: verified.input_digest,
804 });
805 }
806
807 let publication_started = Instant::now();
808 let manifest =
809 manifest_contents(self.resolved.key, &self.spec, output_info.iter().copied());
810 ic_host_fs::durable::write_bytes(
811 &self.staging_directory.path().join(MANIFEST_FILE),
812 ic_host_fs::durable::WriteOptions {
813 mode: ic_host_fs::durable::PublicationMode::Replace,
814 permissions: 0o666,
815 },
816 manifest.as_bytes(),
817 )
818 .map_err(|source| ArtifactCacheError::Io {
819 operation: "write artifact cache manifest",
820 path: self.staging_directory.path().join(MANIFEST_FILE),
821 source: io::Error::other(source),
822 })?;
823 let namespace_lock_path = namespace_lock_path(&self.spec);
824 let (_namespace_lock, namespace_wait) =
825 lock_cache_file(&namespace_lock_path).map_err(artifact_cache_fs_error)?;
826 self.timings.namespace_lock_wait = self
827 .timings
828 .namespace_lock_wait
829 .saturating_add(namespace_wait);
830 remove_unretained_entry(&self.entry_directory).map_err(artifact_cache_fs_error)?;
831 fs::rename(self.staging_directory.path(), &self.entry_directory).map_err(|source| {
832 ArtifactCacheError::Io {
833 operation: "publish artifact cache entry",
834 path: self.entry_directory.clone(),
835 source,
836 }
837 })?;
838 self.staging_directory.disarm();
839 self.timings.publication = publication_started.elapsed();
840
841 let materialization_started = Instant::now();
842 materialize_outputs(&self.spec, &self.entry_directory, None)?;
843 self.timings.materialization = materialization_started.elapsed();
844 record_cache_entry_use(&self.entry_directory).map_err(artifact_cache_fs_error)?;
845 let (maintenance, maintenance_timing) = perform_maintenance_locked(
846 &self.spec,
847 &self.namespace_directory,
848 &self.entry_directory,
849 );
850 self.timings.maintenance = maintenance_timing;
851 self.timings.total = self.total_started.elapsed();
852
853 Ok(ArtifactCacheOutcome::Built(cache_record(
854 &self.spec,
855 self.resolved,
856 self.timings,
857 maintenance,
858 )?))
859 }
860}
861
862pub fn prepare_artifact_cache(
881 spec: &ArtifactCacheSpec,
882) -> Result<ArtifactCachePreparation, ArtifactCacheError> {
883 let total_started = Instant::now();
884 validate_spec(spec)?;
885 validate_filesystem_boundaries(spec)?;
886 let namespace_directory = initialize_cache(spec)?;
887
888 let coordination_lock_path = coordination_lock_path(spec);
889 let (coordination_lock, coordination_wait) =
890 lock_cache_file(&coordination_lock_path).map_err(artifact_cache_fs_error)?;
891 let mut timings = ArtifactCacheTimings {
892 coordination_lock_wait: coordination_wait,
893 ..ArtifactCacheTimings::default()
894 };
895 let initial_cargo_started = Instant::now();
896 revalidate_cargo_build_input_fingerprints(spec)?;
897 timings.input_capture = initial_cargo_started.elapsed();
898 let mut last_change = None;
899
900 for _ in 0..MAX_PREPARATION_RETRIES {
901 let capture_started = Instant::now();
902 let resolved = resolve_key(spec)?;
903 timings.input_capture = timings
904 .input_capture
905 .saturating_add(capture_started.elapsed());
906
907 let content_lock_path = content_lock_path(spec, resolved.key);
908 let (content_lock, content_wait) =
909 lock_cache_file(&content_lock_path).map_err(artifact_cache_fs_error)?;
910 timings.content_lock_wait = timings.content_lock_wait.saturating_add(content_wait);
911
912 let verification_started = Instant::now();
913 let verified = resolve_key(spec)?;
914 timings.input_capture = timings
915 .input_capture
916 .saturating_add(verification_started.elapsed());
917 if resolved.input_digest != verified.input_digest {
918 last_change = Some((resolved.input_digest, verified.input_digest));
919 drop(content_lock);
920 continue;
921 }
922
923 let entry_directory = entry_directory(&namespace_directory, resolved.key);
924 let namespace_lock_path = namespace_lock_path(spec);
925 let (namespace_lock, namespace_wait) =
926 lock_cache_file(&namespace_lock_path).map_err(artifact_cache_fs_error)?;
927 timings.namespace_lock_wait = timings.namespace_lock_wait.saturating_add(namespace_wait);
928 let lookup_started = Instant::now();
929 let verified_outputs = validated_cache_outputs(spec, resolved.key, &entry_directory)?;
930 timings.cache_lookup = timings
931 .cache_lookup
932 .saturating_add(lookup_started.elapsed());
933
934 if let Some(output_info) = verified_outputs {
935 let materialization_started = Instant::now();
936 materialize_outputs(spec, &entry_directory, Some(&output_info))?;
937 timings.materialization = timings
938 .materialization
939 .saturating_add(materialization_started.elapsed());
940 let after_started = Instant::now();
941 revalidate_cargo_build_input_fingerprints(spec)?;
942 let after = resolve_key(spec)?;
943 timings.input_capture = timings
944 .input_capture
945 .saturating_add(after_started.elapsed());
946 if after.input_digest != resolved.input_digest {
947 last_change = Some((resolved.input_digest, after.input_digest));
948 drop(namespace_lock);
949 drop(content_lock);
950 continue;
951 }
952 record_cache_entry_use(&entry_directory).map_err(artifact_cache_fs_error)?;
953 let (maintenance, maintenance_timing) =
954 perform_maintenance_locked(spec, &namespace_directory, &entry_directory);
955 timings.maintenance = maintenance_timing;
956 timings.total = total_started.elapsed();
957 return Ok(ArtifactCachePreparation::Reused(cache_record(
958 spec,
959 resolved,
960 timings,
961 maintenance,
962 )?));
963 }
964
965 remove_unretained_entry(&entry_directory).map_err(artifact_cache_fs_error)?;
966 let staging_directory = create_staging_directory(&namespace_directory, resolved.key)?;
967 drop(namespace_lock);
968 return Ok(ArtifactCachePreparation::Build(ArtifactBuildTransaction {
969 spec: Box::new(spec.clone()),
970 resolved,
971 staging_directory: OwnedDirectory::new(staging_directory),
972 entry_directory,
973 namespace_directory,
974 _coordination_lock: coordination_lock,
975 _content_lock: content_lock,
976 timings,
977 total_started,
978 caller_build_started: Instant::now(),
979 }));
980 }
981
982 let (before, after) = last_change.expect("preparation retries require a recorded input change");
983 Err(ArtifactCacheError::InputsChangedDuringPreparation { before, after })
984}
985
986fn revalidate_cargo_build_input_fingerprints(
987 spec: &ArtifactCacheSpec,
988) -> Result<(), ArtifactCacheError> {
989 for cargo_input in &spec.cargo_build_inputs {
990 let current = resolve_cargo_build_inputs(&cargo_input.build_spec).map_err(|source| {
991 ArtifactCacheError::CargoBuildInputRevalidation {
992 label: cargo_input.label.clone(),
993 source,
994 }
995 })?;
996 let before_validation = cargo_input.resolved.validation_digest();
997 let after_validation = current.validation_digest();
998 if after_validation != before_validation {
999 return Err(ArtifactCacheError::CargoBuildInputsChanged {
1000 label: cargo_input.label.clone(),
1001 before: before_validation,
1002 after: after_validation,
1003 });
1004 }
1005 let before = cargo_input.resolved.fingerprint();
1006 let after = current.fingerprint();
1007 if after != before {
1008 return Err(ArtifactCacheError::CargoBuildInputsChanged {
1009 label: cargo_input.label.clone(),
1010 before,
1011 after,
1012 });
1013 }
1014 }
1015 Ok(())
1016}
1017
1018pub fn prune_artifact_cache(
1030 cache_root: &Path,
1031 namespace: &str,
1032 policy: ArtifactCachePrunePolicy,
1033) -> Result<ArtifactCachePruneReport, ArtifactCacheError> {
1034 validate_identifier("namespace", namespace)?;
1035 if cache_root.as_os_str().is_empty() {
1036 return invalid_spec("cache root must not be empty");
1037 }
1038 ensure_cache_directory_tag(cache_root).map_err(artifact_cache_fs_error)?;
1039 let namespace_directory = namespace_directory_for(cache_root, namespace);
1040 let lock_path = namespace_lock_path_for(cache_root, namespace);
1041 let (_lock, _) = lock_cache_file(&lock_path).map_err(artifact_cache_fs_error)?;
1042 prune_artifact_namespace_locked(cache_root, &namespace_directory, policy, None)
1043}
1044
1045fn initialize_cache(spec: &ArtifactCacheSpec) -> Result<PathBuf, ArtifactCacheError> {
1046 ensure_cache_directory_tag(&spec.cache_root).map_err(artifact_cache_fs_error)?;
1047 let namespace = namespace_directory(spec);
1048 fs::create_dir_all(entries_directory(&namespace)).map_err(|source| ArtifactCacheError::Io {
1049 operation: "create artifact cache namespace",
1050 path: namespace.clone(),
1051 source,
1052 })?;
1053 Ok(namespace)
1054}
1055
1056fn validate_spec(spec: &ArtifactCacheSpec) -> Result<(), ArtifactCacheError> {
1057 validate_identifier("namespace", &spec.namespace)?;
1058 validate_identifier("recipe identity", &spec.recipe_id)?;
1059 validate_identifier("coordination scope", &spec.coordination_scope)?;
1060 if spec.cache_root.as_os_str().is_empty() {
1061 return invalid_spec("cache root must not be empty");
1062 }
1063 if spec.outputs.is_empty() {
1064 return invalid_spec("at least one artifact output is required");
1065 }
1066
1067 let mut labels = BTreeSet::new();
1068 for (kind, paths) in [("input", &spec.inputs), ("tool", &spec.tools)] {
1069 for path in paths {
1070 validate_path_label(kind, &path.label)?;
1071 if !labels.insert((kind, path.label.as_str())) {
1072 return invalid_spec(&format!("duplicate {kind} label `{}`", path.label));
1073 }
1074 }
1075 }
1076 let mut identity_labels = BTreeSet::new();
1077 for identity in &spec.identities {
1078 validate_identifier("identity label", &identity.label)?;
1079 if !identity_labels.insert(&identity.label) {
1080 return invalid_spec(&format!("duplicate identity label `{}`", identity.label));
1081 }
1082 }
1083 let mut cargo_input_labels = BTreeSet::new();
1084 for cargo_inputs in &spec.cargo_build_inputs {
1085 validate_identifier("Cargo build input label", &cargo_inputs.label)?;
1086 if !cargo_input_labels.insert(&cargo_inputs.label) {
1087 return invalid_spec(&format!(
1088 "duplicate Cargo build input label `{}`",
1089 cargo_inputs.label
1090 ));
1091 }
1092 }
1093 if spec
1094 .environment
1095 .keys()
1096 .any(|name| name.as_os_str().is_empty())
1097 {
1098 return invalid_spec("environment names must not be empty");
1099 }
1100 let mut output_names = BTreeSet::new();
1101 let mut destinations = BTreeSet::new();
1102 for output in &spec.outputs {
1103 validate_output_name(&output.name)?;
1104 if !output_names.insert(&output.name) {
1105 return invalid_spec(&format!("duplicate output name `{}`", output.name));
1106 }
1107 if output.destination.as_os_str().is_empty() {
1108 return invalid_spec(&format!(
1109 "output `{}` destination must not be empty",
1110 output.name
1111 ));
1112 }
1113 if !destinations.insert(&output.destination) {
1114 return invalid_spec(&format!(
1115 "output `{}` shares a destination with another output",
1116 output.name
1117 ));
1118 }
1119 }
1120 Ok(())
1121}
1122
1123fn validate_filesystem_boundaries(spec: &ArtifactCacheSpec) -> Result<(), ArtifactCacheError> {
1124 let mut cargo_boundaries = CargoInputBoundaries::default();
1125 let cache_root =
1126 canonicalize_allow_missing(&spec.cache_root).map_err(|source| ArtifactCacheError::Io {
1127 operation: "resolve artifact cache root",
1128 path: spec.cache_root.clone(),
1129 source,
1130 })?;
1131 let mut declared_paths = Vec::with_capacity(spec.inputs.len() + spec.tools.len());
1132 for (kind, labeled_paths) in [("input", &spec.inputs), ("tool", &spec.tools)] {
1133 for labeled in labeled_paths {
1134 let canonical =
1135 canonicalize_path(&labeled.path, "canonicalize declared artifact cache path")?;
1136 if canonical.starts_with(&cache_root) {
1137 return invalid_spec(&format!(
1138 "{kind} `{}` must not be located inside the artifact cache root",
1139 labeled.label
1140 ));
1141 }
1142 let is_directory = fs::metadata(&canonical)
1143 .map_err(|source| ArtifactCacheError::Io {
1144 operation: "inspect declared artifact cache path",
1145 path: canonical.clone(),
1146 source,
1147 })?
1148 .is_dir();
1149 let entry_path =
1150 resolve_file_entry(&labeled.path, "resolve declared artifact cache path entry")?;
1151 if entry_path != canonical {
1152 declared_paths.push((kind, labeled.label.as_str(), entry_path, false));
1153 }
1154 declared_paths.push((kind, labeled.label.as_str(), canonical, is_directory));
1155 }
1156 }
1157 for cargo_inputs in &spec.cargo_build_inputs {
1158 if cargo_boundaries.watches(&cargo_inputs.resolved, &cache_root)? {
1159 return invalid_spec(&format!(
1160 "artifact cache root must be outside resolved Cargo build inputs `{}` or inside one of their generated-state exclusions",
1161 cargo_inputs.label
1162 ));
1163 }
1164 }
1165
1166 let mut destinations = BTreeSet::new();
1167 for output in &spec.outputs {
1168 let destination =
1169 resolve_file_entry(&output.destination, "resolve artifact output destination")?;
1170 if destination.starts_with(&cache_root) {
1171 return invalid_spec(&format!(
1172 "output `{}` destination must be outside the artifact cache root",
1173 output.name
1174 ));
1175 }
1176 if destinations.iter().any(|other: &PathBuf| {
1177 destination.starts_with(other) || other.starts_with(&destination)
1178 }) {
1179 return invalid_spec(&format!(
1180 "output `{}` destination overlaps another output",
1181 output.name
1182 ));
1183 }
1184 destinations.insert(destination.clone());
1185 for (kind, label, declared, is_directory) in &declared_paths {
1186 if destination == *declared || (*is_directory && destination.starts_with(declared)) {
1187 return invalid_spec(&format!(
1188 "output `{}` destination overlaps declared {kind} `{label}`",
1189 output.name
1190 ));
1191 }
1192 }
1193 for cargo_inputs in &spec.cargo_build_inputs {
1194 if cargo_boundaries.watches(&cargo_inputs.resolved, &destination)? {
1195 return invalid_spec(&format!(
1196 "output `{}` destination must be outside resolved Cargo build inputs `{}` or inside one of their generated-state exclusions",
1197 output.name, cargo_inputs.label
1198 ));
1199 }
1200 }
1201 match fs::symlink_metadata(&destination) {
1205 Ok(metadata)
1206 if metadata.is_dir()
1207 || (metadata.file_type().is_symlink()
1208 && fs::metadata(&destination).is_ok_and(|target| target.is_dir())) =>
1209 {
1210 return invalid_spec(&format!(
1211 "output `{}` destination must not be an existing directory",
1212 output.name
1213 ));
1214 }
1215 Ok(_) => {}
1216 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1217 Err(source) => {
1218 return Err(ArtifactCacheError::Io {
1219 operation: "inspect artifact output destination",
1220 path: destination,
1221 source,
1222 });
1223 }
1224 }
1225 }
1226 Ok(())
1227}
1228
1229fn resolve_file_entry(path: &Path, operation: &'static str) -> Result<PathBuf, ArtifactCacheError> {
1232 let resolved = match (path.parent(), path.file_name()) {
1233 (Some(parent), Some(name)) => {
1234 canonicalize_allow_missing(parent).map(|parent| parent.join(name))
1235 }
1236 _ => canonicalize_allow_missing(path),
1237 };
1238 resolved.map_err(|source| ArtifactCacheError::Io {
1239 operation,
1240 path: path.to_owned(),
1241 source,
1242 })
1243}
1244
1245fn canonicalize_path(path: &Path, operation: &'static str) -> Result<PathBuf, ArtifactCacheError> {
1246 path.canonicalize()
1247 .map_err(|source| ArtifactCacheError::Io {
1248 operation,
1249 path: path.to_owned(),
1250 source,
1251 })
1252}
1253
1254fn validate_identifier(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
1255 if value.is_empty() {
1256 return invalid_spec(&format!("{kind} must not be empty"));
1257 }
1258 if value.len() > 256 {
1259 return invalid_spec(&format!("{kind} must not exceed 256 bytes"));
1260 }
1261 Ok(())
1262}
1263
1264fn validate_path_label(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
1265 validate_identifier(&format!("{kind} label"), value)?;
1266 if value
1267 .bytes()
1268 .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/')))
1269 || value
1270 .split('/')
1271 .any(|component| component.is_empty() || matches!(component, "." | ".."))
1272 {
1273 return invalid_spec(&format!(
1274 "{kind} label `{value}` must be a portable relative logical path"
1275 ));
1276 }
1277 Ok(())
1278}
1279
1280fn validate_output_name(name: &str) -> Result<(), ArtifactCacheError> {
1281 if name.is_empty() || name.len() > 128 {
1282 return invalid_spec("output names must contain 1 to 128 bytes");
1283 }
1284 if !name
1285 .bytes()
1286 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1287 {
1288 return invalid_spec(&format!(
1289 "output name `{name}` must use only ASCII letters, digits, dot, dash, or underscore"
1290 ));
1291 }
1292 if matches!(name, "." | "..") {
1293 return invalid_spec("output names must not be dot path components");
1294 }
1295 Ok(())
1296}
1297
1298fn invalid_spec<T>(message: &str) -> Result<T, ArtifactCacheError> {
1299 Err(ArtifactCacheError::InvalidSpec {
1300 message: message.to_owned(),
1301 })
1302}
1303
1304fn resolve_key(spec: &ArtifactCacheSpec) -> Result<ResolvedKey, ArtifactCacheError> {
1305 let paths = spec
1306 .inputs
1307 .iter()
1308 .map(|input| (PathBuf::from("input").join(&input.label), &input.path))
1309 .chain(
1310 spec.tools
1311 .iter()
1312 .map(|tool| (PathBuf::from("tool").join(&tool.label), &tool.path)),
1313 );
1314 let declared_input_digest = digest_labeled_paths(
1315 "artifact-set-inputs-v1",
1316 paths,
1317 std::slice::from_ref(&spec.cache_root),
1318 )
1319 .map_err(|source| ArtifactCacheError::Io {
1320 operation: "hash artifact cache inputs",
1321 path: spec.cache_root.clone(),
1322 source,
1323 })?;
1324 let input_digest = if spec.cargo_build_inputs.is_empty() {
1325 declared_input_digest
1326 } else {
1327 let mut inputs = InputHasher::new("artifact-set-inputs-with-cargo-v1");
1328 inputs.field("declared-input-digest", declared_input_digest.as_bytes());
1329 for cargo_input in &spec.cargo_build_inputs {
1330 let current = cargo_input
1331 .resolved
1332 .current_validation_digest()
1333 .map_err(|source| ArtifactCacheError::CargoBuildInputRevalidation {
1334 label: cargo_input.label.clone(),
1335 source,
1336 })?;
1337 let before = cargo_input.resolved.validation_digest();
1338 if current != before {
1339 return Err(ArtifactCacheError::CargoBuildInputsChanged {
1340 label: cargo_input.label.clone(),
1341 before,
1342 after: current,
1343 });
1344 }
1345 inputs.field("cargo-input-label", cargo_input.label.as_bytes());
1346 inputs.field(
1347 "cargo-build-fingerprint",
1348 cargo_input.resolved.fingerprint().as_bytes(),
1349 );
1350 inputs.field(
1351 "cargo-input-digest",
1352 cargo_input.resolved.input_digest().as_bytes(),
1353 );
1354 }
1355 inputs.finish()
1356 };
1357
1358 let mut hasher = InputHasher::new(ARTIFACT_CACHE_FORMAT);
1359 hasher.field("namespace", spec.namespace.as_bytes());
1360 hasher.field("recipe-id", spec.recipe_id.as_bytes());
1361 hasher.field("input-digest", input_digest.as_bytes());
1362 for argument in &spec.arguments {
1363 hasher.field("argument", &os_bytes(argument));
1364 }
1365 for (name, value) in &spec.environment {
1366 hasher.field("environment-name", &os_bytes(name));
1367 match value {
1368 Some(value) => hasher.field("environment-value", &os_bytes(value)),
1369 None => hasher.field("environment-unset", b""),
1370 }
1371 }
1372 for identity in &spec.identities {
1373 hasher.field("identity-label", identity.label.as_bytes());
1374 hasher.field("identity-value", &identity.value);
1375 }
1376 for output in &spec.outputs {
1377 hasher.field("output-name", output.name.as_bytes());
1378 hasher.field(
1379 "output-validation",
1380 output.validation.cache_token().as_bytes(),
1381 );
1382 }
1383 Ok(ResolvedKey {
1384 key: hasher.finish(),
1385 input_digest,
1386 })
1387}
1388
1389fn validated_cache_outputs(
1390 spec: &ArtifactCacheSpec,
1391 key: InputDigest,
1392 entry: &Path,
1393) -> Result<Option<Vec<FileDigest>>, ArtifactCacheError> {
1394 let entry_metadata = match fs::symlink_metadata(entry) {
1395 Ok(metadata) => metadata,
1396 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1397 Err(source) => {
1398 return Err(ArtifactCacheError::Io {
1399 operation: "inspect artifact cache entry",
1400 path: entry.to_owned(),
1401 source,
1402 });
1403 }
1404 };
1405 if !entry_metadata.file_type().is_dir() || !cache_entry_root_is_valid(entry)? {
1406 return Ok(None);
1407 }
1408 let manifest_path = entry.join(MANIFEST_FILE);
1409 let manifest_metadata = match fs::symlink_metadata(&manifest_path) {
1410 Ok(metadata) => metadata,
1411 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1412 Err(source) => {
1413 return Err(ArtifactCacheError::Io {
1414 operation: "inspect artifact cache manifest",
1415 path: manifest_path,
1416 source,
1417 });
1418 }
1419 };
1420 if !manifest_metadata.file_type().is_file() {
1421 return Ok(None);
1422 }
1423 let Some(maximum_len) = maximum_manifest_len(spec) else {
1426 return Ok(None);
1427 };
1428 let Some(manifest) = read_file_with_limit(&manifest_path, maximum_len).map_err(|source| {
1429 ArtifactCacheError::Io {
1430 operation: "read artifact cache manifest",
1431 path: manifest_path,
1432 source,
1433 }
1434 })?
1435 else {
1436 return Ok(None);
1437 };
1438 if !manifest.starts_with(manifest_header(key).as_bytes()) {
1439 return Ok(None);
1440 }
1441 let Some(output_info) = inspect_cached_output_set(spec, entry)? else {
1442 return Ok(None);
1443 };
1444 Ok(
1445 (manifest == manifest_contents(key, spec, output_info.iter().copied()).as_bytes())
1446 .then_some(output_info),
1447 )
1448}
1449
1450fn inspect_complete_output_set(
1451 spec: &ArtifactCacheSpec,
1452 root: &Path,
1453) -> Result<Vec<FileDigest>, ArtifactCacheError> {
1454 let mut info = Vec::new();
1455 let mut invalid = Vec::new();
1456 let output_directory = root.join("outputs");
1457 if !is_plain_directory(
1458 &output_directory,
1459 "inspect artifact staging output directory",
1460 )? {
1461 invalid.push(("<outputs>".to_owned(), output_directory));
1462 return Err(ArtifactCacheError::InvalidOutputs { outputs: invalid });
1463 }
1464 let outputs = &spec.outputs;
1465 for (index, output) in outputs.iter().enumerate() {
1466 let path = staged_output_path(root, index);
1467 match inspect_artifact(&path, output.validation) {
1468 Ok(Some(artifact)) => info.push(artifact),
1469 Ok(None) => invalid.push((output.name.clone(), path)),
1470 Err(source) => {
1471 return Err(ArtifactCacheError::Io {
1472 operation: "inspect staged artifact output",
1473 path,
1474 source,
1475 });
1476 }
1477 }
1478 }
1479 visit_undeclared_outputs(root, outputs.len(), |entry| {
1480 invalid.push(("<undeclared>".to_owned(), entry.path()));
1481 })?;
1482 visit_undeclared_children(
1483 root,
1484 |name| name == "outputs",
1485 "read artifact staging directory",
1486 |entry| invalid.push(("<undeclared>".to_owned(), entry.path())),
1487 )?;
1488 if invalid.is_empty() {
1489 Ok(info)
1490 } else {
1491 Err(ArtifactCacheError::InvalidOutputs { outputs: invalid })
1492 }
1493}
1494
1495fn inspect_cached_output_set(
1496 spec: &ArtifactCacheSpec,
1497 root: &Path,
1498) -> Result<Option<Vec<FileDigest>>, ArtifactCacheError> {
1499 let mut info = Vec::new();
1500 let outputs = &spec.outputs;
1501 for (index, output) in outputs.iter().enumerate() {
1502 let path = staged_output_path(root, index);
1503 match inspect_artifact(&path, output.validation) {
1504 Ok(Some(artifact)) => info.push(artifact),
1505 Ok(None) => return Ok(None),
1506 Err(source) => {
1507 return Err(ArtifactCacheError::Io {
1508 operation: "inspect cached artifact output",
1509 path,
1510 source,
1511 });
1512 }
1513 }
1514 }
1515 if visit_undeclared_outputs(root, outputs.len(), |_| {})? {
1516 return Ok(None);
1517 }
1518 Ok(Some(info))
1519}
1520
1521fn visit_undeclared_outputs(
1522 root: &Path,
1523 output_count: usize,
1524 visit: impl FnMut(fs::DirEntry),
1525) -> Result<bool, ArtifactCacheError> {
1526 let output_directory = root.join("outputs");
1527 visit_undeclared_children(
1528 &output_directory,
1529 |name| {
1530 let Some(name) = name.to_str() else {
1531 return false;
1532 };
1533 name.strip_suffix(OUTPUT_FILE_SUFFIX)
1534 .and_then(|index| index.parse::<usize>().ok())
1535 .is_some_and(|index| index < output_count && name == format_output_index(index))
1536 },
1537 "read artifact output directory",
1538 visit,
1539 )
1540}
1541
1542fn visit_undeclared_children(
1543 directory: &Path,
1544 is_expected: impl Fn(&OsStr) -> bool,
1545 operation: &'static str,
1546 mut visit: impl FnMut(fs::DirEntry),
1547) -> Result<bool, ArtifactCacheError> {
1548 let entries = fs::read_dir(directory).map_err(|source| ArtifactCacheError::Io {
1549 operation,
1550 path: directory.to_owned(),
1551 source,
1552 })?;
1553 let mut undeclared = false;
1554 for entry in entries {
1557 let entry = entry.map_err(|source| ArtifactCacheError::Io {
1558 operation,
1559 path: directory.to_owned(),
1560 source,
1561 })?;
1562 if !is_expected(&entry.file_name()) {
1563 undeclared = true;
1564 visit(entry);
1565 }
1566 }
1567 Ok(undeclared)
1568}
1569
1570fn cache_entry_root_is_valid(root: &Path) -> Result<bool, ArtifactCacheError> {
1571 let expected = [
1572 "outputs",
1573 MANIFEST_FILE,
1574 LAST_USED_FILE,
1575 super::cache_fs::RETENTION_LOCK_FILE,
1576 ];
1577 if visit_undeclared_children(
1578 root,
1579 |name| expected.iter().any(|expected| name == *expected),
1580 "read artifact cache entry",
1581 |_| {},
1582 )? {
1583 return Ok(false);
1584 }
1585 if !is_plain_directory(
1586 &root.join("outputs"),
1587 "inspect artifact cache output directory",
1588 )? {
1589 return Ok(false);
1590 }
1591 let last_used = root.join(LAST_USED_FILE);
1592 match fs::symlink_metadata(&last_used) {
1593 Ok(metadata) => Ok(metadata.file_type().is_file()),
1594 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true),
1595 Err(source) => Err(ArtifactCacheError::Io {
1596 operation: "inspect artifact cache use marker",
1597 path: last_used,
1598 source,
1599 }),
1600 }
1601}
1602
1603fn is_plain_directory(path: &Path, operation: &'static str) -> Result<bool, ArtifactCacheError> {
1604 match fs::symlink_metadata(path) {
1605 Ok(metadata) => Ok(metadata.file_type().is_dir()),
1606 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
1607 Err(source) => Err(ArtifactCacheError::Io {
1608 operation,
1609 path: path.to_owned(),
1610 source,
1611 }),
1612 }
1613}
1614
1615fn inspect_artifact(
1616 path: &Path,
1617 validation: ArtifactOutputValidation,
1618) -> io::Result<Option<FileDigest>> {
1619 let metadata = match fs::symlink_metadata(path) {
1620 Ok(metadata) => metadata,
1621 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1622 Err(error) => return Err(error),
1623 };
1624 if !metadata.file_type().is_file() {
1625 return Ok(None);
1626 }
1627 if validation == ArtifactOutputValidation::NonEmptyFile && metadata.len() == 0 {
1628 return Ok(None);
1629 }
1630 digest_file("artifact-set-output-v1", path).map(Some)
1631}
1632
1633fn manifest_header(key: InputDigest) -> String {
1634 format!("{ARTIFACT_CACHE_FORMAT}\nkey:{key}\n")
1635}
1636
1637fn maximum_manifest_len(spec: &ArtifactCacheSpec) -> Option<usize> {
1638 let header_len = ARTIFACT_CACHE_FORMAT.len() + "\nkey:\n".len() + 64;
1641 let row_overhead = "output:".len() + "::::\n".len() + 20 + 64;
1642 spec.outputs
1643 .iter()
1644 .enumerate()
1645 .try_fold(header_len, |length, (index, output)| {
1646 let index_digits = usize::try_from(index.checked_ilog10().unwrap_or(0) + 1).ok()?;
1647 length
1648 .checked_add(row_overhead)?
1649 .checked_add(index_digits)?
1650 .checked_add(output.name.len())?
1651 .checked_add(output.validation.cache_token().len())
1652 })
1653}
1654
1655fn manifest_contents(
1656 key: InputDigest,
1657 spec: &ArtifactCacheSpec,
1658 output_info: impl IntoIterator<Item = FileDigest>,
1659) -> String {
1660 let mut manifest = manifest_header(key);
1661 for ((index, output), info) in spec.outputs.iter().enumerate().zip(output_info) {
1662 writeln!(
1663 manifest,
1664 "output:{index}:{}:{}:{}:{}",
1665 output.name,
1666 output.validation.cache_token(),
1667 info.bytes,
1668 info.digest,
1669 )
1670 .expect("writing an artifact manifest to a String cannot fail");
1671 }
1672 manifest
1673}
1674
1675fn materialize_outputs(
1676 spec: &ArtifactCacheSpec,
1677 entry: &Path,
1678 verified_outputs: Option<&[FileDigest]>,
1679) -> Result<(), ArtifactCacheError> {
1680 for (index, output) in spec.outputs.iter().enumerate() {
1681 let cached = staged_output_path(entry, index);
1684 materialize_verified_output(
1685 "artifact-set-output-v1",
1686 &cached,
1687 &output.destination,
1688 verified_outputs.map(|info| &info[index]),
1689 )
1690 .map_err(|source| ArtifactCacheError::Io {
1691 operation: "materialize artifact output",
1692 path: output.destination.clone(),
1693 source,
1694 })?;
1695 }
1696 Ok(())
1697}
1698
1699fn perform_maintenance_locked(
1700 spec: &ArtifactCacheSpec,
1701 namespace: &Path,
1702 protected_entry: &Path,
1703) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
1704 spec.prune_policy.map_or((None, None), |policy| {
1705 let identity = policy.maintenance_identity();
1706 perform_scheduled_cache_maintenance(namespace, spec.prune_interval, &identity, || {
1707 prune_artifact_namespace_locked(
1708 &spec.cache_root,
1709 namespace,
1710 policy,
1711 Some(protected_entry),
1712 )
1713 .map_err(|error| error.to_string())
1714 })
1715 })
1716}
1717
1718fn prune_artifact_namespace_locked(
1719 cache_root: &Path,
1720 namespace: &Path,
1721 policy: ArtifactCachePrunePolicy,
1722 protected_entry: Option<&Path>,
1723) -> Result<ArtifactCachePruneReport, ArtifactCacheError> {
1724 let mut report = prune_direct_child_directories(
1725 &entries_directory(namespace),
1726 policy,
1727 protected_entry,
1728 is_sha256_directory,
1729 )
1730 .map_err(artifact_cache_fs_error)?;
1731 remove_abandoned_staging(cache_root, namespace, &mut report)?;
1732 Ok(report)
1733}
1734
1735fn remove_abandoned_staging(
1736 cache_root: &Path,
1737 namespace: &Path,
1738 report: &mut ArtifactCachePruneReport,
1739) -> Result<(), ArtifactCacheError> {
1740 let staging_root = namespace.join("staging");
1741 let entries = match fs::read_dir(&staging_root) {
1742 Ok(entries) => entries,
1743 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1744 Err(source) => {
1745 return Err(ArtifactCacheError::Io {
1746 operation: "read artifact staging root during pruning",
1747 path: staging_root,
1748 source,
1749 });
1750 }
1751 };
1752 for entry in entries {
1753 let entry = entry.map_err(|source| ArtifactCacheError::Io {
1754 operation: "read artifact staging entry during pruning",
1755 path: staging_root.clone(),
1756 source,
1757 })?;
1758 let file_type = entry.file_type().map_err(|source| ArtifactCacheError::Io {
1759 operation: "inspect artifact staging entry during pruning",
1760 path: entry.path(),
1761 source,
1762 })?;
1763 if !file_type.is_dir() {
1764 continue;
1765 }
1766 let file_name = entry.file_name();
1767 let Some(key) = staging_content_key(&file_name) else {
1768 continue;
1769 };
1770 let lock_path = content_lock_path_for_key(cache_root, key);
1771 let Some(_content_lock) =
1772 try_lock_cache_file(&lock_path).map_err(artifact_cache_fs_error)?
1773 else {
1774 continue;
1775 };
1776 let path = entry.path();
1777 let bytes = directory_logical_size(&path).map_err(|source| ArtifactCacheError::Io {
1778 operation: "measure abandoned artifact staging directory",
1779 path: path.clone(),
1780 source,
1781 })?;
1782 remove_path_if_present(&path).map_err(|source| ArtifactCacheError::Io {
1783 operation: "remove abandoned artifact staging directory during pruning",
1784 path,
1785 source,
1786 })?;
1787 report.record_uncommitted_removal(bytes);
1788 }
1789 Ok(())
1790}
1791
1792fn staging_content_key(name: &OsStr) -> Option<&str> {
1793 let name = name.to_str()?;
1794 let (key, suffix) = name.split_once('-')?;
1795 (!suffix.is_empty() && key.len() == 64 && key.as_bytes().iter().all(u8::is_ascii_hexdigit))
1796 .then_some(key)
1797}
1798
1799fn cache_record(
1800 spec: &ArtifactCacheSpec,
1801 resolved: ResolvedKey,
1802 timings: ArtifactCacheTimings,
1803 maintenance: Option<ArtifactCacheMaintenance>,
1804) -> Result<ArtifactCacheRecord, ArtifactCacheError> {
1805 let entry = entry_directory(&namespace_directory(spec), resolved.key);
1806 let retention = RetainedCacheEntry::acquire(&entry).map_err(artifact_cache_fs_error)?;
1807 Ok(ArtifactCacheRecord {
1808 key: resolved.key,
1809 input_digest: resolved.input_digest,
1810 artifacts: spec
1811 .outputs
1812 .iter()
1813 .enumerate()
1814 .map(|(index, output)| ArtifactCacheArtifact {
1815 name: output.name.clone(),
1816 path: staged_output_path(&entry, index),
1817 })
1818 .collect(),
1819 timings,
1820 maintenance,
1821 _retention: retention,
1822 })
1823}
1824
1825fn create_staging_directory(
1826 namespace: &Path,
1827 key: InputDigest,
1828) -> Result<PathBuf, ArtifactCacheError> {
1829 let staging_root = namespace.join("staging");
1830 fs::create_dir_all(&staging_root).map_err(|source| ArtifactCacheError::Io {
1831 operation: "create artifact staging root",
1832 path: staging_root.clone(),
1833 source,
1834 })?;
1835 remove_same_key_staging(&staging_root, key)?;
1836 let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1837 let staging = staging_root.join(format!("{key}-{}-{sequence}", std::process::id()));
1838 fs::create_dir_all(staging.join("outputs")).map_err(|source| ArtifactCacheError::Io {
1839 operation: "create artifact transaction staging directory",
1840 path: staging.clone(),
1841 source,
1842 })?;
1843 Ok(staging)
1844}
1845
1846fn remove_same_key_staging(root: &Path, key: InputDigest) -> Result<(), ArtifactCacheError> {
1847 let prefix = format!("{key}-");
1848 let entries = fs::read_dir(root).map_err(|source| ArtifactCacheError::Io {
1849 operation: "read artifact staging root",
1850 path: root.to_owned(),
1851 source,
1852 })?;
1853 for entry in entries {
1854 let entry = entry.map_err(|source| ArtifactCacheError::Io {
1855 operation: "read artifact staging entry",
1856 path: root.to_owned(),
1857 source,
1858 })?;
1859 if entry.file_name().to_string_lossy().starts_with(&prefix) {
1860 remove_path_if_present(&entry.path()).map_err(|source| ArtifactCacheError::Io {
1861 operation: "remove abandoned artifact staging directory",
1862 path: entry.path(),
1863 source,
1864 })?;
1865 }
1866 }
1867 Ok(())
1868}
1869
1870fn staged_output_path(root: &Path, index: usize) -> PathBuf {
1871 root.join("outputs").join(format_output_index(index))
1872}
1873
1874fn format_output_index(index: usize) -> String {
1875 format!("{index:04}{OUTPUT_FILE_SUFFIX}")
1876}
1877
1878fn namespace_directory(spec: &ArtifactCacheSpec) -> PathBuf {
1879 namespace_directory_for(&spec.cache_root, &spec.namespace)
1880}
1881
1882fn namespace_directory_for(cache_root: &Path, namespace: &str) -> PathBuf {
1883 cache_root
1884 .join(".ic-testkit/artifact-sets/namespaces")
1885 .join(identifier_digest("artifact-cache-namespace-v1", namespace))
1886}
1887
1888fn entries_directory(namespace: &Path) -> PathBuf {
1889 namespace.join("entries")
1890}
1891
1892fn entry_directory(namespace: &Path, key: InputDigest) -> PathBuf {
1893 entries_directory(namespace).join(key.to_hex())
1894}
1895
1896fn coordination_lock_path(spec: &ArtifactCacheSpec) -> PathBuf {
1897 spec.cache_root
1898 .join(".ic-testkit/artifact-sets/locks/coordination")
1899 .join(format!(
1900 "{}.lock",
1901 identifier_digest("artifact-cache-coordination-v1", &spec.coordination_scope,)
1902 ))
1903}
1904
1905fn content_lock_path(spec: &ArtifactCacheSpec, key: InputDigest) -> PathBuf {
1906 content_lock_path_for_key(&spec.cache_root, &key.to_hex())
1907}
1908
1909fn content_lock_path_for_key(cache_root: &Path, key: &str) -> PathBuf {
1910 cache_root
1911 .join(".ic-testkit/artifact-sets/locks/content")
1912 .join(format!("{key}.lock"))
1913}
1914
1915fn namespace_lock_path(spec: &ArtifactCacheSpec) -> PathBuf {
1916 namespace_lock_path_for(&spec.cache_root, &spec.namespace)
1917}
1918
1919fn namespace_lock_path_for(cache_root: &Path, namespace: &str) -> PathBuf {
1920 cache_root
1921 .join(".ic-testkit/artifact-sets/locks/namespaces")
1922 .join(format!(
1923 "{}.lock",
1924 identifier_digest("artifact-cache-namespace-v1", namespace)
1925 ))
1926}
1927
1928fn identifier_digest(domain: &str, identifier: &str) -> String {
1929 digest_bytes(domain, identifier.as_bytes()).to_hex()
1930}
1931
1932fn artifact_cache_fs_error(error: CacheFsError) -> ArtifactCacheError {
1933 ArtifactCacheError::Io {
1934 operation: error.operation,
1935 path: error.path,
1936 source: error.source,
1937 }
1938}
1939
1940impl ArtifactOutputValidation {
1941 const fn cache_token(self) -> &'static str {
1942 match self {
1943 Self::RegularFile => "regular-file",
1944 Self::NonEmptyFile => "nonempty-file",
1945 }
1946 }
1947}
1948
1949impl std::fmt::Display for ArtifactCacheError {
1950 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1951 match self {
1952 Self::InvalidSpec { message } => {
1953 write!(formatter, "invalid artifact cache spec: {message}")
1954 }
1955 Self::Io {
1956 operation,
1957 path,
1958 source,
1959 } => write!(
1960 formatter,
1961 "failed to {operation} at {}: {source}",
1962 path.display()
1963 ),
1964 Self::InputsChangedDuringPreparation { before, after } => write!(
1965 formatter,
1966 "artifact inputs repeatedly changed during cache preparation: {before} -> {after}",
1967 ),
1968 Self::InputsChangedDuringAcquisition { before, after } => write!(
1969 formatter,
1970 "artifact inputs changed while the caller was building: {before} -> {after}",
1971 ),
1972 Self::CargoBuildInputsChanged {
1973 label,
1974 before,
1975 after,
1976 } => write!(
1977 formatter,
1978 "resolved Cargo build inputs `{label}` changed: {before} -> {after}",
1979 ),
1980 Self::CargoBuildInputRevalidation { label, source } => write!(
1981 formatter,
1982 "failed to revalidate resolved Cargo build inputs `{label}`: {source}",
1983 ),
1984 Self::InvalidOutputs { outputs } => {
1985 formatter.write_str("artifact transaction has missing or invalid outputs: ")?;
1986 for (index, (name, path)) in outputs.iter().enumerate() {
1987 if index != 0 {
1988 formatter.write_str(", ")?;
1989 }
1990 write!(formatter, "{name} ({})", path.display())?;
1991 }
1992 Ok(())
1993 }
1994 Self::UnknownOutput { name } => {
1995 write!(
1996 formatter,
1997 "artifact transaction has no output named `{name}`"
1998 )
1999 }
2000 Self::FailedTransactionCleanup {
2001 transaction_error,
2002 path,
2003 source,
2004 } => write!(
2005 formatter,
2006 "artifact transaction failed ({transaction_error}) and staging cleanup at {} also failed: {source}",
2007 path.display(),
2008 ),
2009 }
2010 }
2011}
2012
2013impl std::error::Error for ArtifactCacheError {
2014 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2015 match self {
2016 Self::Io { source, .. } | Self::FailedTransactionCleanup { source, .. } => Some(source),
2017 Self::CargoBuildInputRevalidation { source, .. } => Some(source),
2018 _ => None,
2019 }
2020 }
2021}
2022
2023#[cfg(test)]
2024mod tests;