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