1use std::{
2 collections::{BTreeMap, BTreeSet},
3 ffi::OsString,
4 fs::{self, File},
5 io,
6 path::{Path, PathBuf},
7 sync::atomic::{AtomicU64, Ordering},
8 time::{Duration, Instant},
9};
10
11use super::{
12 cache_fs::{
13 ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
14 ensure_cache_directory_tag, lock_cache_file, prune_direct_child_directories,
15 record_cache_entry_use,
16 },
17 digest::{
18 InputDigest, InputHasher, digest_bytes, digest_labeled_paths, os_bytes, write_atomic,
19 },
20};
21
22const ARTIFACT_CACHE_FORMAT: &str = "ic-testkit-artifact-set-v1";
23const MANIFEST_FILE: &str = "manifest.ic-testkit";
24const MAX_PREPARATION_RETRIES: usize = 3;
25static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
26
27#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
29pub enum ArtifactOutputValidation {
30 RegularFile,
32 #[default]
34 NonEmptyFile,
35}
36
37#[derive(Clone, Eq, PartialEq)]
39pub struct ArtifactCacheSpec {
40 cache_root: PathBuf,
41 namespace: String,
42 recipe_id: String,
43 coordination_scope: String,
44 inputs: Vec<LabeledPath>,
45 tools: Vec<LabeledPath>,
46 arguments: Vec<OsString>,
47 environment: BTreeMap<OsString, Option<OsString>>,
48 identities: Vec<LabeledIdentity>,
49 outputs: Vec<OutputSpec>,
50 prune_policy: Option<ArtifactCachePrunePolicy>,
51}
52
53pub enum ArtifactCachePreparation {
55 Reused(ArtifactCacheRecord),
57 Build(ArtifactBuildTransaction),
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
63pub enum ArtifactCacheOutcome {
64 Built(ArtifactCacheRecord),
66 Reused(ArtifactCacheRecord),
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct ArtifactCacheRecord {
73 key: InputDigest,
74 input_digest: InputDigest,
75 artifacts: Vec<ArtifactCacheArtifact>,
76 timings: ArtifactCacheTimings,
77 maintenance: Option<ArtifactCacheMaintenance>,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct ArtifactCacheArtifact {
83 name: String,
84 path: PathBuf,
85}
86
87#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
89pub struct ArtifactCacheTimings {
90 coordination_lock_wait: Duration,
91 content_lock_wait: Duration,
92 namespace_lock_wait: Duration,
93 input_capture: Duration,
94 cache_lookup: Duration,
95 caller_build: Option<Duration>,
96 output_validation: Duration,
97 publication: Duration,
98 materialization: Duration,
99 maintenance: Option<Duration>,
100 total: Duration,
101}
102
103pub struct ArtifactBuildTransaction {
105 spec: Box<ArtifactCacheSpec>,
106 resolved: ResolvedKey,
107 staging_directory: PathBuf,
108 entry_directory: PathBuf,
109 namespace_directory: PathBuf,
110 _coordination_lock: File,
111 _content_lock: File,
112 timings: ArtifactCacheTimings,
113 total_started: Instant,
114 caller_build_started: Instant,
115 staging_armed: bool,
116}
117
118#[non_exhaustive]
120#[derive(Debug)]
121pub enum ArtifactCacheError {
122 InvalidSpec { message: String },
124 Io {
126 operation: &'static str,
127 path: PathBuf,
128 source: io::Error,
129 },
130 InputsChangedDuringPreparation {
132 before: InputDigest,
133 after: InputDigest,
134 },
135 InputsChangedDuringBuild {
137 before: InputDigest,
138 after: InputDigest,
139 },
140 InvalidOutputs { outputs: Vec<(String, PathBuf)> },
142 UnknownOutput { name: String },
144 FailedTransactionCleanup {
146 transaction_error: Box<Self>,
147 path: PathBuf,
148 source: io::Error,
149 },
150}
151
152#[derive(Clone, Debug, Eq, PartialEq)]
153struct LabeledPath {
154 label: String,
155 path: PathBuf,
156}
157
158#[derive(Clone, Eq, PartialEq)]
159struct LabeledIdentity {
160 label: String,
161 value: Vec<u8>,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq)]
165struct OutputSpec {
166 name: String,
167 destination: PathBuf,
168 validation: ArtifactOutputValidation,
169}
170
171#[derive(Clone, Copy)]
172struct ResolvedKey {
173 key: InputDigest,
174 input_digest: InputDigest,
175}
176
177struct ArtifactInfo {
178 bytes: u64,
179 digest: InputDigest,
180}
181
182impl ArtifactCacheSpec {
183 #[must_use]
189 pub fn new(cache_root: &Path, namespace: &str, recipe_id: &str) -> Self {
190 Self {
191 cache_root: cache_root.to_owned(),
192 namespace: namespace.to_owned(),
193 recipe_id: recipe_id.to_owned(),
194 coordination_scope: namespace.to_owned(),
195 inputs: Vec::new(),
196 tools: Vec::new(),
197 arguments: Vec::new(),
198 environment: BTreeMap::new(),
199 identities: Vec::new(),
200 outputs: Vec::new(),
201 prune_policy: None,
202 }
203 }
204
205 #[must_use]
207 pub fn with_coordination_scope(mut self, coordination_scope: &str) -> Self {
208 coordination_scope.clone_into(&mut self.coordination_scope);
209 self
210 }
211
212 #[must_use]
214 pub fn with_input(mut self, label: &str, path: &Path) -> Self {
215 self.inputs.push(LabeledPath {
216 label: label.to_owned(),
217 path: path.to_owned(),
218 });
219 self
220 }
221
222 #[must_use]
224 pub fn with_tool(mut self, label: &str, path: &Path) -> Self {
225 self.tools.push(LabeledPath {
226 label: label.to_owned(),
227 path: path.to_owned(),
228 });
229 self
230 }
231
232 #[must_use]
234 pub fn with_arguments(mut self, arguments: &[&str]) -> Self {
235 self.arguments = arguments.iter().map(OsString::from).collect();
236 self
237 }
238
239 #[must_use]
241 pub fn with_environment(mut self, environment: &[(&str, &str)]) -> Self {
242 self.environment.extend(
243 environment
244 .iter()
245 .map(|(name, value)| (OsString::from(name), Some(OsString::from(value)))),
246 );
247 self
248 }
249
250 #[must_use]
252 pub fn with_unset_environment(mut self, names: &[&str]) -> Self {
253 self.environment
254 .extend(names.iter().map(|name| (OsString::from(name), None)));
255 self
256 }
257
258 #[must_use]
260 pub fn with_identity_bytes(mut self, label: &str, value: &[u8]) -> Self {
261 self.identities.push(LabeledIdentity {
262 label: label.to_owned(),
263 value: value.to_vec(),
264 });
265 self
266 }
267
268 #[must_use]
270 pub fn with_output(self, name: &str, destination: &Path) -> Self {
271 self.with_output_validation(name, destination, ArtifactOutputValidation::NonEmptyFile)
272 }
273
274 #[must_use]
276 pub fn with_output_validation(
277 mut self,
278 name: &str,
279 destination: &Path,
280 validation: ArtifactOutputValidation,
281 ) -> Self {
282 self.outputs.push(OutputSpec {
283 name: name.to_owned(),
284 destination: destination.to_owned(),
285 validation,
286 });
287 self
288 }
289
290 #[must_use]
292 pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
293 self.prune_policy = Some(policy);
294 self
295 }
296
297 #[must_use]
299 pub fn cache_root(&self) -> &Path {
300 &self.cache_root
301 }
302
303 #[must_use]
305 pub fn namespace(&self) -> &str {
306 &self.namespace
307 }
308
309 #[must_use]
311 pub fn recipe_id(&self) -> &str {
312 &self.recipe_id
313 }
314}
315
316impl std::fmt::Debug for ArtifactCacheSpec {
317 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 formatter
319 .debug_struct("ArtifactCacheSpec")
320 .field("cache_root", &self.cache_root)
321 .field("namespace", &self.namespace)
322 .field("recipe_id", &self.recipe_id)
323 .field("coordination_scope", &self.coordination_scope)
324 .field("inputs", &self.inputs)
325 .field("tools", &self.tools)
326 .field("argument_count", &self.arguments.len())
327 .field(
328 "environment_names",
329 &self.environment.keys().collect::<Vec<_>>(),
330 )
331 .field(
332 "identity_labels",
333 &self
334 .identities
335 .iter()
336 .map(|identity| identity.label.as_str())
337 .collect::<Vec<_>>(),
338 )
339 .field("outputs", &self.outputs)
340 .field("prune_policy", &self.prune_policy)
341 .finish()
342 }
343}
344
345impl ArtifactCachePreparation {
346 #[must_use]
348 pub const fn reused_record(&self) -> Option<&ArtifactCacheRecord> {
349 match self {
350 Self::Reused(record) => Some(record),
351 Self::Build(_) => None,
352 }
353 }
354}
355
356impl ArtifactCacheOutcome {
357 #[must_use]
359 pub const fn record(&self) -> &ArtifactCacheRecord {
360 match self {
361 Self::Built(record) | Self::Reused(record) => record,
362 }
363 }
364
365 #[must_use]
367 pub const fn is_reused(&self) -> bool {
368 matches!(self, Self::Reused(_))
369 }
370}
371
372impl ArtifactCacheRecord {
373 #[must_use]
375 pub const fn key(&self) -> InputDigest {
376 self.key
377 }
378
379 #[must_use]
381 pub const fn input_digest(&self) -> InputDigest {
382 self.input_digest
383 }
384
385 #[must_use]
387 pub fn artifacts(&self) -> &[ArtifactCacheArtifact] {
388 &self.artifacts
389 }
390
391 #[must_use]
393 pub const fn timings(&self) -> ArtifactCacheTimings {
394 self.timings
395 }
396
397 #[must_use]
399 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
400 self.maintenance.as_ref()
401 }
402}
403
404impl ArtifactCacheArtifact {
405 #[must_use]
407 pub fn name(&self) -> &str {
408 &self.name
409 }
410
411 #[must_use]
413 pub fn path(&self) -> &Path {
414 &self.path
415 }
416}
417
418impl ArtifactCacheTimings {
419 #[must_use]
421 pub const fn coordination_lock_wait(self) -> Duration {
422 self.coordination_lock_wait
423 }
424
425 #[must_use]
427 pub const fn content_lock_wait(self) -> Duration {
428 self.content_lock_wait
429 }
430
431 #[must_use]
433 pub const fn namespace_lock_wait(self) -> Duration {
434 self.namespace_lock_wait
435 }
436
437 #[must_use]
439 pub const fn input_capture(self) -> Duration {
440 self.input_capture
441 }
442
443 #[must_use]
445 pub const fn cache_lookup(self) -> Duration {
446 self.cache_lookup
447 }
448
449 #[must_use]
451 pub const fn caller_build(self) -> Option<Duration> {
452 self.caller_build
453 }
454
455 #[must_use]
457 pub const fn output_validation(self) -> Duration {
458 self.output_validation
459 }
460
461 #[must_use]
463 pub const fn publication(self) -> Duration {
464 self.publication
465 }
466
467 #[must_use]
469 pub const fn materialization(self) -> Duration {
470 self.materialization
471 }
472
473 #[must_use]
475 pub const fn maintenance(self) -> Option<Duration> {
476 self.maintenance
477 }
478
479 #[must_use]
481 pub const fn total(self) -> Duration {
482 self.total
483 }
484}
485
486impl ArtifactBuildTransaction {
487 #[must_use]
489 pub fn staging_directory(&self) -> &Path {
490 &self.staging_directory
491 }
492
493 pub fn output_path(&self, name: &str) -> Result<PathBuf, ArtifactCacheError> {
495 self.output_index(name)
496 .map(|index| staged_output_path(&self.staging_directory, index))
497 .ok_or_else(|| ArtifactCacheError::UnknownOutput {
498 name: name.to_owned(),
499 })
500 }
501
502 pub fn import_output(&self, name: &str, source: &Path) -> Result<(), ArtifactCacheError> {
504 let destination = self.output_path(name)?;
505 let contents = fs::read(source).map_err(|source_error| ArtifactCacheError::Io {
506 operation: "read external artifact output",
507 path: source.to_owned(),
508 source: source_error,
509 })?;
510 write_atomic(&destination, &contents).map_err(|source_error| ArtifactCacheError::Io {
511 operation: "import artifact output into staging",
512 path: destination,
513 source: source_error,
514 })
515 }
516
517 pub fn commit(mut self) -> Result<ArtifactCacheOutcome, ArtifactCacheError> {
519 let result = self.commit_inner();
520 match result {
521 Ok(outcome) => Ok(outcome),
522 Err(transaction_error) if self.staging_armed => {
523 let path = self.staging_directory.clone();
524 match remove_dir_all_if_present(&path) {
525 Ok(()) => {
526 self.staging_armed = false;
527 Err(transaction_error)
528 }
529 Err(source) => Err(ArtifactCacheError::FailedTransactionCleanup {
530 transaction_error: Box::new(transaction_error),
531 path,
532 source,
533 }),
534 }
535 }
536 Err(transaction_error) => Err(transaction_error),
537 }
538 }
539
540 pub fn abort(mut self) -> Result<(), ArtifactCacheError> {
542 remove_dir_all_if_present(&self.staging_directory).map_err(|source| {
543 ArtifactCacheError::Io {
544 operation: "abort artifact cache transaction",
545 path: self.staging_directory.clone(),
546 source,
547 }
548 })?;
549 self.staging_armed = false;
550 Ok(())
551 }
552
553 fn output_index(&self, name: &str) -> Option<usize> {
554 sorted_outputs(&self.spec)
555 .iter()
556 .position(|output| output.name == name)
557 }
558
559 fn commit_inner(&mut self) -> Result<ArtifactCacheOutcome, ArtifactCacheError> {
560 self.timings.caller_build = Some(self.caller_build_started.elapsed());
561
562 let validation_started = Instant::now();
563 let output_info = inspect_complete_output_set(&self.spec, &self.staging_directory)?;
564 self.timings.output_validation = validation_started.elapsed();
565
566 let capture_started = Instant::now();
567 let verified = resolve_key(&self.spec)?;
568 self.timings.input_capture = self
569 .timings
570 .input_capture
571 .saturating_add(capture_started.elapsed());
572 if verified.input_digest != self.resolved.input_digest {
573 return Err(ArtifactCacheError::InputsChangedDuringBuild {
574 before: self.resolved.input_digest,
575 after: verified.input_digest,
576 });
577 }
578
579 let publication_started = Instant::now();
580 let manifest = manifest_contents(self.resolved.key, &self.spec, &output_info);
581 write_atomic(
582 &self.staging_directory.join(MANIFEST_FILE),
583 manifest.as_bytes(),
584 )
585 .map_err(|source| ArtifactCacheError::Io {
586 operation: "write artifact cache manifest",
587 path: self.staging_directory.join(MANIFEST_FILE),
588 source,
589 })?;
590 let namespace_lock_path = namespace_lock_path(&self.spec);
591 let (_namespace_lock, namespace_wait) =
592 lock_cache_file(&namespace_lock_path).map_err(artifact_cache_fs_error)?;
593 self.timings.namespace_lock_wait = self
594 .timings
595 .namespace_lock_wait
596 .saturating_add(namespace_wait);
597 remove_dir_all_if_present(&self.entry_directory).map_err(|source| {
598 ArtifactCacheError::Io {
599 operation: "remove conflicting artifact cache entry",
600 path: self.entry_directory.clone(),
601 source,
602 }
603 })?;
604 fs::rename(&self.staging_directory, &self.entry_directory).map_err(|source| {
605 ArtifactCacheError::Io {
606 operation: "publish artifact cache entry",
607 path: self.entry_directory.clone(),
608 source,
609 }
610 })?;
611 self.staging_armed = false;
612 self.timings.publication = publication_started.elapsed();
613
614 let materialization_started = Instant::now();
615 materialize_outputs(&self.spec, &self.entry_directory)?;
616 self.timings.materialization = materialization_started.elapsed();
617 record_cache_entry_use(&self.entry_directory).map_err(artifact_cache_fs_error)?;
618 let (maintenance, maintenance_timing) =
619 perform_maintenance(&self.spec, &self.namespace_directory, &self.entry_directory);
620 self.timings.maintenance = maintenance_timing;
621 self.timings.total = self.total_started.elapsed();
622
623 Ok(ArtifactCacheOutcome::Built(cache_record(
624 &self.spec,
625 self.resolved,
626 self.timings,
627 maintenance,
628 )))
629 }
630}
631
632impl Drop for ArtifactBuildTransaction {
633 fn drop(&mut self) {
634 if self.staging_armed {
635 let _ = remove_dir_all_if_present(&self.staging_directory);
636 }
637 }
638}
639
640pub fn prepare_artifact_cache(
642 spec: &ArtifactCacheSpec,
643) -> Result<ArtifactCachePreparation, ArtifactCacheError> {
644 let total_started = Instant::now();
645 validate_spec(spec)?;
646 let namespace_directory = initialize_cache(spec)?;
647
648 let coordination_lock_path = coordination_lock_path(spec);
649 let (coordination_lock, coordination_wait) =
650 lock_cache_file(&coordination_lock_path).map_err(artifact_cache_fs_error)?;
651 let mut timings = ArtifactCacheTimings {
652 coordination_lock_wait: coordination_wait,
653 ..ArtifactCacheTimings::default()
654 };
655 let mut last_change = None;
656
657 for _ in 0..MAX_PREPARATION_RETRIES {
658 let capture_started = Instant::now();
659 let resolved = resolve_key(spec)?;
660 timings.input_capture = timings
661 .input_capture
662 .saturating_add(capture_started.elapsed());
663
664 let content_lock_path = content_lock_path(spec, resolved.key);
665 let (content_lock, content_wait) =
666 lock_cache_file(&content_lock_path).map_err(artifact_cache_fs_error)?;
667 timings.content_lock_wait = timings.content_lock_wait.saturating_add(content_wait);
668
669 let verification_started = Instant::now();
670 let verified = resolve_key(spec)?;
671 timings.input_capture = timings
672 .input_capture
673 .saturating_add(verification_started.elapsed());
674 if resolved.input_digest != verified.input_digest {
675 last_change = Some((resolved.input_digest, verified.input_digest));
676 drop(content_lock);
677 continue;
678 }
679
680 let entry_directory = entry_directory(&namespace_directory, resolved.key);
681 let namespace_lock_path = namespace_lock_path(spec);
682 let (namespace_lock, namespace_wait) =
683 lock_cache_file(&namespace_lock_path).map_err(artifact_cache_fs_error)?;
684 timings.namespace_lock_wait = timings.namespace_lock_wait.saturating_add(namespace_wait);
685 let lookup_started = Instant::now();
686 let reusable = cache_entry_is_valid(spec, resolved.key, &entry_directory)?;
687 timings.cache_lookup = timings
688 .cache_lookup
689 .saturating_add(lookup_started.elapsed());
690
691 if reusable {
692 let materialization_started = Instant::now();
693 materialize_outputs(spec, &entry_directory)?;
694 timings.materialization = timings
695 .materialization
696 .saturating_add(materialization_started.elapsed());
697 let after_started = Instant::now();
698 let after = resolve_key(spec)?;
699 timings.input_capture = timings
700 .input_capture
701 .saturating_add(after_started.elapsed());
702 if after.input_digest != resolved.input_digest {
703 last_change = Some((resolved.input_digest, after.input_digest));
704 drop(namespace_lock);
705 drop(content_lock);
706 continue;
707 }
708 record_cache_entry_use(&entry_directory).map_err(artifact_cache_fs_error)?;
709 let (maintenance, maintenance_timing) =
710 perform_maintenance(spec, &namespace_directory, &entry_directory);
711 timings.maintenance = maintenance_timing;
712 timings.total = total_started.elapsed();
713 return Ok(ArtifactCachePreparation::Reused(cache_record(
714 spec,
715 resolved,
716 timings,
717 maintenance,
718 )));
719 }
720
721 remove_dir_all_if_present(&entry_directory).map_err(|source| ArtifactCacheError::Io {
722 operation: "remove invalid artifact cache entry",
723 path: entry_directory.clone(),
724 source,
725 })?;
726 let staging_directory = create_staging_directory(&namespace_directory, resolved.key)?;
727 drop(namespace_lock);
728 return Ok(ArtifactCachePreparation::Build(ArtifactBuildTransaction {
729 spec: Box::new(spec.clone()),
730 resolved,
731 staging_directory,
732 entry_directory,
733 namespace_directory,
734 _coordination_lock: coordination_lock,
735 _content_lock: content_lock,
736 timings,
737 total_started,
738 caller_build_started: Instant::now(),
739 staging_armed: true,
740 }));
741 }
742
743 let (before, after) = last_change.expect("preparation retries require a recorded input change");
744 Err(ArtifactCacheError::InputsChangedDuringPreparation { before, after })
745}
746
747pub fn prune_artifact_cache(
753 cache_root: &Path,
754 namespace: &str,
755 policy: ArtifactCachePrunePolicy,
756) -> Result<ArtifactCachePruneReport, ArtifactCacheError> {
757 validate_identifier("namespace", namespace)?;
758 if cache_root.as_os_str().is_empty() {
759 return invalid_spec("cache root must not be empty");
760 }
761 ensure_cache_directory_tag(cache_root).map_err(artifact_cache_fs_error)?;
762 let namespace_digest = identifier_digest("artifact-cache-namespace-v1", namespace);
763 let namespace_directory = cache_root
764 .join(".ic-testkit/artifact-sets/namespaces")
765 .join(&namespace_digest);
766 let lock_path = cache_root
767 .join(".ic-testkit/artifact-sets/locks/namespaces")
768 .join(format!("{namespace_digest}.lock"));
769 let (_lock, _) = lock_cache_file(&lock_path).map_err(artifact_cache_fs_error)?;
770 prune_direct_child_directories(
771 &entries_directory(&namespace_directory),
772 policy,
773 None,
774 is_content_key_directory,
775 )
776 .map_err(artifact_cache_fs_error)
777}
778
779fn initialize_cache(spec: &ArtifactCacheSpec) -> Result<PathBuf, ArtifactCacheError> {
780 ensure_cache_directory_tag(&spec.cache_root).map_err(artifact_cache_fs_error)?;
781 let namespace = namespace_directory(spec);
782 fs::create_dir_all(entries_directory(&namespace)).map_err(|source| ArtifactCacheError::Io {
783 operation: "create artifact cache namespace",
784 path: namespace.clone(),
785 source,
786 })?;
787 Ok(namespace)
788}
789
790fn validate_spec(spec: &ArtifactCacheSpec) -> Result<(), ArtifactCacheError> {
791 validate_identifier("namespace", &spec.namespace)?;
792 validate_identifier("recipe identity", &spec.recipe_id)?;
793 validate_identifier("coordination scope", &spec.coordination_scope)?;
794 if spec.cache_root.as_os_str().is_empty() {
795 return invalid_spec("cache root must not be empty");
796 }
797 if spec.outputs.is_empty() {
798 return invalid_spec("at least one artifact output is required");
799 }
800
801 let mut labels = BTreeSet::new();
802 for input in &spec.inputs {
803 validate_path_label("input", &input.label)?;
804 if !labels.insert(format!("input/{}", input.label)) {
805 return invalid_spec(&format!("duplicate input label `{}`", input.label));
806 }
807 }
808 for tool in &spec.tools {
809 validate_path_label("tool", &tool.label)?;
810 if !labels.insert(format!("tool/{}", tool.label)) {
811 return invalid_spec(&format!("duplicate tool label `{}`", tool.label));
812 }
813 }
814 let mut identity_labels = BTreeSet::new();
815 for identity in &spec.identities {
816 validate_label("identity", &identity.label)?;
817 if !identity_labels.insert(&identity.label) {
818 return invalid_spec(&format!("duplicate identity label `{}`", identity.label));
819 }
820 }
821 if spec
822 .environment
823 .keys()
824 .any(|name| name.as_os_str().is_empty())
825 {
826 return invalid_spec("environment names must not be empty");
827 }
828 let mut output_names = BTreeSet::new();
829 let mut destinations = BTreeSet::new();
830 for output in &spec.outputs {
831 validate_output_name(&output.name)?;
832 if !output_names.insert(&output.name) {
833 return invalid_spec(&format!("duplicate output name `{}`", output.name));
834 }
835 if output.destination.as_os_str().is_empty() {
836 return invalid_spec(&format!(
837 "output `{}` destination must not be empty",
838 output.name
839 ));
840 }
841 if !destinations.insert(&output.destination) {
842 return invalid_spec(&format!(
843 "output `{}` shares a destination with another output",
844 output.name
845 ));
846 }
847 }
848 Ok(())
849}
850
851fn validate_identifier(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
852 if value.is_empty() {
853 return invalid_spec(&format!("{kind} must not be empty"));
854 }
855 if value.len() > 256 {
856 return invalid_spec(&format!("{kind} must not exceed 256 bytes"));
857 }
858 Ok(())
859}
860
861fn validate_label(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
862 if value.is_empty() {
863 return invalid_spec(&format!("{kind} label must not be empty"));
864 }
865 if value.len() > 256 {
866 return invalid_spec(&format!("{kind} label must not exceed 256 bytes"));
867 }
868 Ok(())
869}
870
871fn validate_path_label(kind: &str, value: &str) -> Result<(), ArtifactCacheError> {
872 validate_label(kind, value)?;
873 if value
874 .bytes()
875 .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/')))
876 || value
877 .split('/')
878 .any(|component| component.is_empty() || matches!(component, "." | ".."))
879 {
880 return invalid_spec(&format!(
881 "{kind} label `{value}` must be a portable relative logical path"
882 ));
883 }
884 Ok(())
885}
886
887fn validate_output_name(name: &str) -> Result<(), ArtifactCacheError> {
888 if name.is_empty() || name.len() > 128 {
889 return invalid_spec("output names must contain 1 to 128 bytes");
890 }
891 if !name
892 .bytes()
893 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
894 {
895 return invalid_spec(&format!(
896 "output name `{name}` must use only ASCII letters, digits, dot, dash, or underscore"
897 ));
898 }
899 if matches!(name, "." | "..") {
900 return invalid_spec("output names must not be dot path components");
901 }
902 Ok(())
903}
904
905fn invalid_spec<T>(message: &str) -> Result<T, ArtifactCacheError> {
906 Err(ArtifactCacheError::InvalidSpec {
907 message: message.to_owned(),
908 })
909}
910
911fn resolve_key(spec: &ArtifactCacheSpec) -> Result<ResolvedKey, ArtifactCacheError> {
912 let mut paths = spec
913 .inputs
914 .iter()
915 .map(|input| {
916 (
917 PathBuf::from("input").join(&input.label),
918 input.path.clone(),
919 )
920 })
921 .chain(
922 spec.tools
923 .iter()
924 .map(|tool| (PathBuf::from("tool").join(&tool.label), tool.path.clone())),
925 )
926 .collect::<Vec<_>>();
927 paths.shrink_to_fit();
928 let input_digest = digest_labeled_paths(
929 "artifact-set-inputs-v1",
930 &paths,
931 std::slice::from_ref(&spec.cache_root),
932 )
933 .map_err(|source| ArtifactCacheError::Io {
934 operation: "hash artifact cache inputs",
935 path: spec.cache_root.clone(),
936 source,
937 })?;
938
939 let mut hasher = InputHasher::new(ARTIFACT_CACHE_FORMAT);
940 hasher.field("namespace", spec.namespace.as_bytes());
941 hasher.field("recipe-id", spec.recipe_id.as_bytes());
942 hasher.field("input-digest", input_digest.as_bytes());
943 for argument in &spec.arguments {
944 hasher.field("argument", &os_bytes(argument));
945 }
946 for (name, value) in &spec.environment {
947 hasher.field("environment-name", &os_bytes(name));
948 match value {
949 Some(value) => hasher.field("environment-value", &os_bytes(value)),
950 None => hasher.field("environment-unset", b""),
951 }
952 }
953 let mut identities = spec.identities.iter().collect::<Vec<_>>();
954 identities.sort_by(|left, right| left.label.cmp(&right.label));
955 for identity in identities {
956 hasher.field("identity-label", identity.label.as_bytes());
957 hasher.field("identity-value", &identity.value);
958 }
959 for output in sorted_outputs(spec) {
960 hasher.field("output-name", output.name.as_bytes());
961 hasher.field(
962 "output-validation",
963 output.validation.cache_token().as_bytes(),
964 );
965 }
966 Ok(ResolvedKey {
967 key: hasher.finish(),
968 input_digest,
969 })
970}
971
972fn cache_entry_is_valid(
973 spec: &ArtifactCacheSpec,
974 key: InputDigest,
975 entry: &Path,
976) -> Result<bool, ArtifactCacheError> {
977 if !entry.is_dir() {
978 return Ok(false);
979 }
980 let manifest = match fs::read_to_string(entry.join(MANIFEST_FILE)) {
981 Ok(manifest) => manifest,
982 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
983 Err(source) => {
984 return Err(ArtifactCacheError::Io {
985 operation: "read artifact cache manifest",
986 path: entry.join(MANIFEST_FILE),
987 source,
988 });
989 }
990 };
991 let Some(output_info) = inspect_cached_output_set(spec, entry)? else {
992 return Ok(false);
993 };
994 Ok(manifest == manifest_contents(key, spec, &output_info))
995}
996
997fn inspect_complete_output_set(
998 spec: &ArtifactCacheSpec,
999 root: &Path,
1000) -> Result<Vec<ArtifactInfo>, ArtifactCacheError> {
1001 let mut info = Vec::new();
1002 let mut invalid = Vec::new();
1003 let outputs = sorted_outputs(spec);
1004 for (index, output) in outputs.iter().enumerate() {
1005 let path = staged_output_path(root, index);
1006 match inspect_artifact(&path, output.validation) {
1007 Ok(Some(artifact)) => info.push(artifact),
1008 Ok(None) => invalid.push((output.name.clone(), path)),
1009 Err(source) => {
1010 return Err(ArtifactCacheError::Io {
1011 operation: "inspect staged artifact output",
1012 path,
1013 source,
1014 });
1015 }
1016 }
1017 }
1018 invalid.extend(
1019 undeclared_output_paths(root, outputs.len())?.map(|path| ("<undeclared>".to_owned(), path)),
1020 );
1021 if invalid.is_empty() {
1022 Ok(info)
1023 } else {
1024 Err(ArtifactCacheError::InvalidOutputs { outputs: invalid })
1025 }
1026}
1027
1028fn inspect_cached_output_set(
1029 spec: &ArtifactCacheSpec,
1030 root: &Path,
1031) -> Result<Option<Vec<ArtifactInfo>>, ArtifactCacheError> {
1032 let mut info = Vec::new();
1033 let outputs = sorted_outputs(spec);
1034 for (index, output) in outputs.iter().enumerate() {
1035 let path = staged_output_path(root, index);
1036 match inspect_artifact(&path, output.validation) {
1037 Ok(Some(artifact)) => info.push(artifact),
1038 Ok(None) => return Ok(None),
1039 Err(source) => {
1040 return Err(ArtifactCacheError::Io {
1041 operation: "inspect cached artifact output",
1042 path,
1043 source,
1044 });
1045 }
1046 }
1047 }
1048 if undeclared_output_paths(root, outputs.len())?
1049 .next()
1050 .is_some()
1051 {
1052 return Ok(None);
1053 }
1054 Ok(Some(info))
1055}
1056
1057fn undeclared_output_paths(
1058 root: &Path,
1059 output_count: usize,
1060) -> Result<impl Iterator<Item = PathBuf>, ArtifactCacheError> {
1061 let output_directory = root.join("outputs");
1062 let expected = (0..output_count)
1063 .map(format_output_index)
1064 .collect::<BTreeSet<_>>();
1065 let entries = fs::read_dir(&output_directory).map_err(|source| ArtifactCacheError::Io {
1066 operation: "read artifact output directory",
1067 path: output_directory.clone(),
1068 source,
1069 })?;
1070 let mut undeclared = Vec::new();
1071 for entry in entries {
1072 let entry = entry.map_err(|source| ArtifactCacheError::Io {
1073 operation: "read artifact output entry",
1074 path: output_directory.clone(),
1075 source,
1076 })?;
1077 if !expected.contains(&entry.file_name().to_string_lossy().into_owned()) {
1078 undeclared.push(entry.path());
1079 }
1080 }
1081 Ok(undeclared.into_iter())
1082}
1083
1084fn inspect_artifact(
1085 path: &Path,
1086 validation: ArtifactOutputValidation,
1087) -> io::Result<Option<ArtifactInfo>> {
1088 let metadata = match fs::symlink_metadata(path) {
1089 Ok(metadata) => metadata,
1090 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1091 Err(error) => return Err(error),
1092 };
1093 if !metadata.file_type().is_file() {
1094 return Ok(None);
1095 }
1096 let contents = fs::read(path)?;
1097 if validation == ArtifactOutputValidation::NonEmptyFile && contents.is_empty() {
1098 return Ok(None);
1099 }
1100 Ok(Some(ArtifactInfo {
1101 bytes: u64::try_from(contents.len()).expect("artifact length must fit in u64"),
1102 digest: digest_bytes("artifact-set-output-v1", &contents),
1103 }))
1104}
1105
1106fn manifest_contents(
1107 key: InputDigest,
1108 spec: &ArtifactCacheSpec,
1109 output_info: &[ArtifactInfo],
1110) -> String {
1111 let mut manifest = format!("{ARTIFACT_CACHE_FORMAT}\nkey:{key}\n");
1112 for ((index, output), info) in sorted_outputs(spec).iter().enumerate().zip(output_info) {
1113 use std::fmt::Write as _;
1114 writeln!(
1115 manifest,
1116 "output:{index}:{}:{}:{}:{}",
1117 output.name,
1118 output.validation.cache_token(),
1119 info.bytes,
1120 info.digest,
1121 )
1122 .expect("writing an artifact manifest to a String cannot fail");
1123 }
1124 manifest
1125}
1126
1127fn materialize_outputs(spec: &ArtifactCacheSpec, entry: &Path) -> Result<(), ArtifactCacheError> {
1128 for (index, output) in sorted_outputs(spec).iter().enumerate() {
1129 let cached = staged_output_path(entry, index);
1130 let contents = fs::read(&cached).map_err(|source| ArtifactCacheError::Io {
1131 operation: "read cached artifact output",
1132 path: cached,
1133 source,
1134 })?;
1135 write_atomic(&output.destination, &contents).map_err(|source| ArtifactCacheError::Io {
1136 operation: "materialize artifact output",
1137 path: output.destination.clone(),
1138 source,
1139 })?;
1140 }
1141 Ok(())
1142}
1143
1144fn perform_maintenance(
1145 spec: &ArtifactCacheSpec,
1146 namespace: &Path,
1147 protected_entry: &Path,
1148) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
1149 spec.prune_policy.map_or((None, None), |policy| {
1150 let started = Instant::now();
1151 let result = prune_direct_child_directories(
1152 &entries_directory(namespace),
1153 policy,
1154 Some(protected_entry),
1155 is_content_key_directory,
1156 );
1157 let maintenance = match result {
1158 Ok(report) => ArtifactCacheMaintenance::Pruned(report),
1159 Err(error) => ArtifactCacheMaintenance::PruneFailed {
1160 message: artifact_cache_fs_error(error).to_string(),
1161 },
1162 };
1163 (Some(maintenance), Some(started.elapsed()))
1164 })
1165}
1166
1167fn cache_record(
1168 spec: &ArtifactCacheSpec,
1169 resolved: ResolvedKey,
1170 timings: ArtifactCacheTimings,
1171 maintenance: Option<ArtifactCacheMaintenance>,
1172) -> ArtifactCacheRecord {
1173 ArtifactCacheRecord {
1174 key: resolved.key,
1175 input_digest: resolved.input_digest,
1176 artifacts: sorted_outputs(spec)
1177 .iter()
1178 .map(|output| ArtifactCacheArtifact {
1179 name: output.name.clone(),
1180 path: output.destination.clone(),
1181 })
1182 .collect(),
1183 timings,
1184 maintenance,
1185 }
1186}
1187
1188fn create_staging_directory(
1189 namespace: &Path,
1190 key: InputDigest,
1191) -> Result<PathBuf, ArtifactCacheError> {
1192 let staging_root = namespace.join("staging");
1193 fs::create_dir_all(&staging_root).map_err(|source| ArtifactCacheError::Io {
1194 operation: "create artifact staging root",
1195 path: staging_root.clone(),
1196 source,
1197 })?;
1198 remove_same_key_staging(&staging_root, key)?;
1199 let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1200 let staging = staging_root.join(format!("{key}-{}-{sequence}", std::process::id()));
1201 fs::create_dir_all(staging.join("outputs")).map_err(|source| ArtifactCacheError::Io {
1202 operation: "create artifact transaction staging directory",
1203 path: staging.clone(),
1204 source,
1205 })?;
1206 Ok(staging)
1207}
1208
1209fn remove_same_key_staging(root: &Path, key: InputDigest) -> Result<(), ArtifactCacheError> {
1210 let prefix = format!("{key}-");
1211 let entries = fs::read_dir(root).map_err(|source| ArtifactCacheError::Io {
1212 operation: "read artifact staging root",
1213 path: root.to_owned(),
1214 source,
1215 })?;
1216 for entry in entries {
1217 let entry = entry.map_err(|source| ArtifactCacheError::Io {
1218 operation: "read artifact staging entry",
1219 path: root.to_owned(),
1220 source,
1221 })?;
1222 if entry.file_name().to_string_lossy().starts_with(&prefix) {
1223 remove_dir_all_if_present(&entry.path()).map_err(|source| ArtifactCacheError::Io {
1224 operation: "remove abandoned artifact staging directory",
1225 path: entry.path(),
1226 source,
1227 })?;
1228 }
1229 }
1230 Ok(())
1231}
1232
1233fn sorted_outputs(spec: &ArtifactCacheSpec) -> Vec<&OutputSpec> {
1234 let mut outputs = spec.outputs.iter().collect::<Vec<_>>();
1235 outputs.sort_by(|left, right| left.name.cmp(&right.name));
1236 outputs
1237}
1238
1239fn staged_output_path(root: &Path, index: usize) -> PathBuf {
1240 root.join("outputs").join(format_output_index(index))
1241}
1242
1243fn format_output_index(index: usize) -> String {
1244 format!("{index:04}.artifact")
1245}
1246
1247fn namespace_directory(spec: &ArtifactCacheSpec) -> PathBuf {
1248 spec.cache_root
1249 .join(".ic-testkit/artifact-sets/namespaces")
1250 .join(identifier_digest(
1251 "artifact-cache-namespace-v1",
1252 &spec.namespace,
1253 ))
1254}
1255
1256fn entries_directory(namespace: &Path) -> PathBuf {
1257 namespace.join("entries")
1258}
1259
1260fn entry_directory(namespace: &Path, key: InputDigest) -> PathBuf {
1261 entries_directory(namespace).join(key.to_hex())
1262}
1263
1264fn coordination_lock_path(spec: &ArtifactCacheSpec) -> PathBuf {
1265 spec.cache_root
1266 .join(".ic-testkit/artifact-sets/locks/coordination")
1267 .join(format!(
1268 "{}.lock",
1269 identifier_digest("artifact-cache-coordination-v1", &spec.coordination_scope,)
1270 ))
1271}
1272
1273fn content_lock_path(spec: &ArtifactCacheSpec, key: InputDigest) -> PathBuf {
1274 spec.cache_root
1275 .join(".ic-testkit/artifact-sets/locks/content")
1276 .join(format!("{key}.lock"))
1277}
1278
1279fn namespace_lock_path(spec: &ArtifactCacheSpec) -> PathBuf {
1280 spec.cache_root
1281 .join(".ic-testkit/artifact-sets/locks/namespaces")
1282 .join(format!(
1283 "{}.lock",
1284 identifier_digest("artifact-cache-namespace-v1", &spec.namespace)
1285 ))
1286}
1287
1288fn identifier_digest(domain: &str, identifier: &str) -> String {
1289 digest_bytes(domain, identifier.as_bytes()).to_hex()
1290}
1291
1292fn is_content_key_directory(path: &Path) -> bool {
1293 path.file_name().is_some_and(|name| {
1294 let bytes = name.as_encoded_bytes();
1295 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
1296 })
1297}
1298
1299fn remove_dir_all_if_present(path: &Path) -> io::Result<()> {
1300 match fs::remove_dir_all(path) {
1301 Ok(()) => Ok(()),
1302 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1303 Err(error) => Err(error),
1304 }
1305}
1306
1307fn artifact_cache_fs_error(error: CacheFsError) -> ArtifactCacheError {
1308 ArtifactCacheError::Io {
1309 operation: error.operation,
1310 path: error.path,
1311 source: error.source,
1312 }
1313}
1314
1315impl ArtifactOutputValidation {
1316 const fn cache_token(self) -> &'static str {
1317 match self {
1318 Self::RegularFile => "regular-file",
1319 Self::NonEmptyFile => "nonempty-file",
1320 }
1321 }
1322}
1323
1324impl std::fmt::Display for ArtifactCacheError {
1325 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1326 match self {
1327 Self::InvalidSpec { message } => {
1328 write!(formatter, "invalid artifact cache spec: {message}")
1329 }
1330 Self::Io {
1331 operation,
1332 path,
1333 source,
1334 } => write!(
1335 formatter,
1336 "failed to {operation} at {}: {source}",
1337 path.display()
1338 ),
1339 Self::InputsChangedDuringPreparation { before, after } => write!(
1340 formatter,
1341 "artifact inputs repeatedly changed during cache preparation: {before} -> {after}",
1342 ),
1343 Self::InputsChangedDuringBuild { before, after } => write!(
1344 formatter,
1345 "artifact inputs changed while the caller was building: {before} -> {after}",
1346 ),
1347 Self::InvalidOutputs { outputs } => write!(
1348 formatter,
1349 "artifact transaction has missing or invalid outputs: {}",
1350 outputs
1351 .iter()
1352 .map(|(name, path)| format!("{name} ({})", path.display()))
1353 .collect::<Vec<_>>()
1354 .join(", "),
1355 ),
1356 Self::UnknownOutput { name } => {
1357 write!(
1358 formatter,
1359 "artifact transaction has no output named `{name}`"
1360 )
1361 }
1362 Self::FailedTransactionCleanup {
1363 transaction_error,
1364 path,
1365 source,
1366 } => write!(
1367 formatter,
1368 "artifact transaction failed ({transaction_error}) and staging cleanup at {} also failed: {source}",
1369 path.display(),
1370 ),
1371 }
1372 }
1373}
1374
1375impl std::error::Error for ArtifactCacheError {
1376 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1377 match self {
1378 Self::Io { source, .. } | Self::FailedTransactionCleanup { source, .. } => Some(source),
1379 _ => None,
1380 }
1381 }
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386 use super::{
1387 ArtifactCacheError, ArtifactCacheOutcome, ArtifactCachePreparation, ArtifactCacheSpec,
1388 ArtifactOutputValidation, entry_directory, namespace_directory, prepare_artifact_cache,
1389 prune_artifact_cache, resolve_key,
1390 };
1391 use crate::artifacts::{ArtifactCacheMaintenance, ArtifactCachePrunePolicy};
1392 use std::{
1393 fs,
1394 panic::{AssertUnwindSafe, catch_unwind},
1395 path::{Path, PathBuf},
1396 sync::{
1397 Arc, Barrier,
1398 atomic::{AtomicU64, AtomicUsize, Ordering},
1399 },
1400 thread,
1401 };
1402
1403 static TEMP_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1404
1405 #[test]
1406 fn one_output_is_built_materialized_repaired_and_reused() {
1407 let root = unique_temp_directory("one-output");
1408 let input = root.join("input.wasm");
1409 let destination = root.join("public/optimized.wasm");
1410 fs::write(&input, b"raw-wasm").expect("write input");
1411 let spec = ArtifactCacheSpec::new(&root.join("cache"), "optimizer", "pipeline/v1")
1412 .with_input("raw-wasm", &input)
1413 .with_arguments(&["-O3", "--strip-debug"])
1414 .with_output("optimized.wasm", &destination);
1415 assert!(!destination.starts_with(spec.cache_root()));
1416
1417 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1418 fs::write(
1419 transaction
1420 .output_path("optimized.wasm")
1421 .expect("declared output path"),
1422 b"optimized-wasm",
1423 )
1424 .expect("write staged output");
1425 let built = transaction.commit().expect("commit artifact transaction");
1426
1427 assert!(matches!(built, ArtifactCacheOutcome::Built(_)));
1428 assert_eq!(fs::read(&destination).unwrap(), b"optimized-wasm");
1429 fs::write(&destination, b"tampered-public-output").expect("tamper public output");
1430
1431 let reused = prepare_artifact_cache(&spec).expect("prepare exact reuse");
1432 let record = reused.reused_record().expect("expected exact reuse");
1433 assert_eq!(record.key(), built.record().key());
1434 assert_eq!(record.artifacts()[0].name(), "optimized.wasm");
1435 assert_eq!(fs::read(&destination).unwrap(), b"optimized-wasm");
1436 assert!(record.timings().caller_build().is_none());
1437 fs::remove_dir_all(root).expect("remove one-output test directory");
1438 }
1439
1440 #[test]
1441 fn multi_output_commit_is_complete_and_name_order_independent() {
1442 let root = unique_temp_directory("multi-output");
1443 let input = root.join("source");
1444 fs::write(&input, b"source").expect("write input");
1445 let spec = ArtifactCacheSpec::new(&root.join("cache"), "release-set", "recipe/v2")
1446 .with_input("source", &input)
1447 .with_output("role-b.wasm", &root.join("public/role-b.wasm"))
1448 .with_output("metadata.json", &root.join("public/metadata.json"))
1449 .with_output("root.wasm", &root.join("public/root.wasm"));
1450 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1451 for (name, contents) in [
1452 ("root.wasm", b"root".as_slice()),
1453 ("role-b.wasm", b"role-b".as_slice()),
1454 ("metadata.json", b"{}".as_slice()),
1455 ] {
1456 fs::write(transaction.output_path(name).unwrap(), contents)
1457 .expect("write staged output");
1458 }
1459
1460 let outcome = transaction.commit().expect("commit complete output set");
1461
1462 assert_eq!(
1463 outcome
1464 .record()
1465 .artifacts()
1466 .iter()
1467 .map(super::ArtifactCacheArtifact::name)
1468 .collect::<Vec<_>>(),
1469 ["metadata.json", "role-b.wasm", "root.wasm"],
1470 );
1471 assert!(matches!(
1472 prepare_artifact_cache(&spec).unwrap(),
1473 ArtifactCachePreparation::Reused(_)
1474 ));
1475 fs::remove_dir_all(root).expect("remove multi-output test directory");
1476 }
1477
1478 #[test]
1479 fn incomplete_output_set_fails_and_removes_staging() {
1480 let root = unique_temp_directory("incomplete-output");
1481 let input = root.join("input");
1482 fs::write(&input, b"input").expect("write input");
1483 let spec = ArtifactCacheSpec::new(&root.join("cache"), "batch", "recipe/v1")
1484 .with_input("input", &input)
1485 .with_output("first", &root.join("first"))
1486 .with_output("second", &root.join("second"));
1487 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1488 fs::write(transaction.output_path("first").unwrap(), b"first")
1489 .expect("write only first output");
1490 let staging = transaction.staging_directory().to_owned();
1491
1492 let error = transaction
1493 .commit()
1494 .expect_err("partial transaction must fail");
1495
1496 assert!(matches!(error, ArtifactCacheError::InvalidOutputs { .. }));
1497 assert!(!staging.exists());
1498 assert!(matches!(
1499 prepare_artifact_cache(&spec).unwrap(),
1500 ArtifactCachePreparation::Build(_)
1501 ));
1502 fs::remove_dir_all(root).expect("remove incomplete-output test directory");
1503 }
1504
1505 #[test]
1506 fn changed_inputs_reject_commit_and_remove_staging() {
1507 let root = unique_temp_directory("changed-inputs");
1508 let input = root.join("input");
1509 fs::write(&input, b"before").expect("write original input");
1510 let spec = ArtifactCacheSpec::new(&root.join("cache"), "transform", "recipe/v1")
1511 .with_input("input", &input)
1512 .with_output("output", &root.join("output"));
1513 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1514 fs::write(transaction.output_path("output").unwrap(), b"output")
1515 .expect("write staged output");
1516 let staging = transaction.staging_directory().to_owned();
1517 fs::write(&input, b"after").expect("change input during transaction");
1518
1519 let error = transaction
1520 .commit()
1521 .expect_err("input race must reject commit");
1522
1523 assert!(matches!(
1524 error,
1525 ArtifactCacheError::InputsChangedDuringBuild { .. }
1526 ));
1527 assert!(!staging.exists());
1528 fs::remove_dir_all(root).expect("remove changed-inputs test directory");
1529 }
1530
1531 #[test]
1532 fn dropped_and_panicked_transactions_remove_staging() {
1533 let root = unique_temp_directory("dropped-transactions");
1534 let input = root.join("input");
1535 fs::write(&input, b"input").expect("write input");
1536 let spec = ArtifactCacheSpec::new(&root.join("cache"), "drop", "recipe/v1")
1537 .with_input("input", &input)
1538 .with_output("output", &root.join("output"));
1539
1540 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1541 let dropped_staging = transaction.staging_directory().to_owned();
1542 drop(transaction);
1543 assert!(!dropped_staging.exists());
1544
1545 let panicked_staging = Arc::new(std::sync::Mutex::new(None));
1546 let captured_staging = Arc::clone(&panicked_staging);
1547 let result = catch_unwind(AssertUnwindSafe(|| {
1548 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1549 *captured_staging.lock().unwrap() = Some(transaction.staging_directory().to_owned());
1550 panic!("synthetic caller panic");
1551 }));
1552 assert!(result.is_err());
1553 assert!(!panicked_staging.lock().unwrap().as_ref().unwrap().exists());
1554 fs::remove_dir_all(root).expect("remove dropped-transactions test directory");
1555 }
1556
1557 #[test]
1558 fn every_declared_identity_dimension_changes_the_content_key() {
1559 let root = unique_temp_directory("identity-dimensions");
1560 let input = root.join("input");
1561 let tool = root.join("tool");
1562 fs::write(&input, b"input-v1").expect("write input");
1563 fs::write(&tool, b"tool-v1").expect("write tool");
1564 let base = ArtifactCacheSpec::new(&root.join("cache"), "identity", "recipe/v1")
1565 .with_input("input", &input)
1566 .with_tool("optimizer", &tool)
1567 .with_arguments(&["-O2"])
1568 .with_environment(&[("MODE", "release")])
1569 .with_identity_bytes("pipeline", b"one")
1570 .with_output("output", &root.join("output"));
1571 let original = resolve_key(&base).unwrap().key;
1572 let changed_argument = resolve_key(&base.clone().with_arguments(&["-O3"]))
1573 .unwrap()
1574 .key;
1575 let changed_environment =
1576 resolve_key(&base.clone().with_environment(&[("MODE", "size-optimized")]))
1577 .unwrap()
1578 .key;
1579 let changed_recipe = resolve_key(&ArtifactCacheSpec {
1580 recipe_id: "recipe/v2".to_owned(),
1581 ..base.clone()
1582 })
1583 .unwrap()
1584 .key;
1585 fs::write(&tool, b"tool-v2").expect("change tool bytes");
1586 let changed_tool = resolve_key(&base).unwrap().key;
1587 fs::write(&tool, b"tool-v1").expect("restore tool bytes");
1588 fs::write(&input, b"input-v2").expect("change input bytes");
1589 let changed_input = resolve_key(&base).unwrap().key;
1590
1591 for changed in [
1592 changed_argument,
1593 changed_environment,
1594 changed_recipe,
1595 changed_tool,
1596 changed_input,
1597 ] {
1598 assert_ne!(original, changed);
1599 }
1600 fs::remove_dir_all(root).expect("remove identity-dimensions test directory");
1601 }
1602
1603 #[test]
1604 fn tampered_cache_entry_is_rebuilt_instead_of_reused() {
1605 let root = unique_temp_directory("tampered-entry");
1606 let input = root.join("input");
1607 fs::write(&input, b"input").expect("write input");
1608 let spec = ArtifactCacheSpec::new(&root.join("cache"), "tamper", "recipe/v1")
1609 .with_input("input", &input)
1610 .with_output("output", &root.join("output"));
1611 let transaction = expect_build(prepare_artifact_cache(&spec).expect("prepare miss"));
1612 fs::write(transaction.output_path("output").unwrap(), b"valid")
1613 .expect("write staged output");
1614 let outcome = transaction.commit().expect("commit valid entry");
1615 let entry = entry_directory(&namespace_directory(&spec), outcome.record().key());
1616 fs::write(entry.join("outputs/0000.artifact"), b"tampered").expect("tamper cached output");
1617
1618 let rebuilt = prepare_artifact_cache(&spec).expect("prepare after corruption");
1619
1620 assert!(matches!(rebuilt, ArtifactCachePreparation::Build(_)));
1621 fs::remove_dir_all(root).expect("remove tampered-entry test directory");
1622 }
1623
1624 #[test]
1625 fn pruning_protects_active_entry_and_removes_older_key() {
1626 let root = unique_temp_directory("transaction-pruning");
1627 let input = root.join("input");
1628 fs::write(&input, b"input").expect("write input");
1629 let base = ArtifactCacheSpec::new(&root.join("cache"), "prune", "recipe/v1")
1630 .with_input("input", &input)
1631 .with_output("output", &root.join("output"));
1632 build_output(&base.clone().with_arguments(&["old"]), b"old");
1633 let active = base
1634 .clone()
1635 .with_arguments(&["active"])
1636 .with_prune_policy(ArtifactCachePrunePolicy::new().with_max_size_bytes(0));
1637
1638 let outcome = build_output(&active, b"active");
1639 let report = outcome
1640 .record()
1641 .maintenance()
1642 .and_then(ArtifactCacheMaintenance::prune_report)
1643 .expect("successful configured pruning");
1644
1645 assert_eq!(report.entries_scanned(), 2);
1646 assert_eq!(report.entries_removed(), 1);
1647 assert_eq!(report.entries_retained(), 1);
1648 assert!(matches!(
1649 prepare_artifact_cache(&base.with_arguments(&["old"])).unwrap(),
1650 ArtifactCachePreparation::Build(_)
1651 ));
1652 assert!(matches!(
1653 prepare_artifact_cache(&active).unwrap(),
1654 ArtifactCachePreparation::Reused(_)
1655 ));
1656 let strict = prune_artifact_cache(
1657 active.cache_root(),
1658 active.namespace(),
1659 ArtifactCachePrunePolicy::new().with_max_size_bytes(0),
1660 )
1661 .expect("strict namespace pruning");
1662 assert_eq!(strict.entries_removed(), 1);
1663 assert!(matches!(
1664 prepare_artifact_cache(&active).unwrap(),
1665 ArtifactCachePreparation::Build(_)
1666 ));
1667 fs::remove_dir_all(root).expect("remove transaction-pruning test directory");
1668 }
1669
1670 #[test]
1671 fn overlapping_exact_acquisitions_build_once() {
1672 let root = unique_temp_directory("overlapping-acquisitions");
1673 let input = root.join("input");
1674 fs::write(&input, b"input").expect("write input");
1675 let spec = Arc::new(
1676 ArtifactCacheSpec::new(&root.join("cache"), "concurrent", "recipe/v1")
1677 .with_input("input", &input)
1678 .with_output("output", &root.join("output")),
1679 );
1680 let start = Arc::new(Barrier::new(3));
1681 let builds = Arc::new(AtomicUsize::new(0));
1682 let workers = std::array::from_fn::<_, 2, _>(|_| {
1683 let spec = Arc::clone(&spec);
1684 let start = Arc::clone(&start);
1685 let builds = Arc::clone(&builds);
1686 thread::spawn(move || {
1687 start.wait();
1688 match prepare_artifact_cache(&spec).expect("prepare overlapping acquisition") {
1689 ArtifactCachePreparation::Reused(record) => record.key(),
1690 ArtifactCachePreparation::Build(transaction) => {
1691 builds.fetch_add(1, Ordering::SeqCst);
1692 fs::write(transaction.output_path("output").unwrap(), b"built")
1693 .expect("write concurrent staged output");
1694 transaction
1695 .commit()
1696 .expect("commit concurrent output")
1697 .record()
1698 .key()
1699 }
1700 }
1701 })
1702 });
1703 start.wait();
1704 let keys = workers.map(|worker| worker.join().expect("worker must not panic"));
1705
1706 assert_eq!(keys[0], keys[1]);
1707 assert_eq!(builds.load(Ordering::SeqCst), 1);
1708 fs::remove_dir_all(root).expect("remove overlapping-acquisitions test directory");
1709 }
1710
1711 #[test]
1712 fn different_keys_sharing_a_coordination_scope_do_not_overlap() {
1713 let root = unique_temp_directory("shared-coordination");
1714 let input = root.join("input");
1715 fs::write(&input, b"input").expect("write input");
1716 let base = ArtifactCacheSpec::new(&root.join("cache"), "coordinated", "recipe/v1")
1717 .with_coordination_scope("shared-external-tree")
1718 .with_input("input", &input)
1719 .with_output("output", &root.join("output"));
1720 let start = Arc::new(Barrier::new(3));
1721 let active = Arc::new(AtomicUsize::new(0));
1722 let maximum = Arc::new(AtomicUsize::new(0));
1723 let workers = ["first", "second"].map(|argument| {
1724 let spec = base.clone().with_arguments(&[argument]);
1725 let start = Arc::clone(&start);
1726 let active = Arc::clone(&active);
1727 let maximum = Arc::clone(&maximum);
1728 thread::spawn(move || {
1729 start.wait();
1730 let transaction = expect_build(
1731 prepare_artifact_cache(&spec).expect("prepare coordinated transaction"),
1732 );
1733 let current = active.fetch_add(1, Ordering::SeqCst) + 1;
1734 maximum.fetch_max(current, Ordering::SeqCst);
1735 thread::sleep(std::time::Duration::from_millis(20));
1736 fs::write(transaction.output_path("output").unwrap(), argument)
1737 .expect("write coordinated output");
1738 active.fetch_sub(1, Ordering::SeqCst);
1739 transaction.commit().expect("commit coordinated output");
1740 })
1741 });
1742 start.wait();
1743 for worker in workers {
1744 worker.join().expect("coordinated worker must not panic");
1745 }
1746
1747 assert_eq!(maximum.load(Ordering::SeqCst), 1);
1748 fs::remove_dir_all(root).expect("remove shared-coordination test directory");
1749 }
1750
1751 #[test]
1752 fn content_identity_is_independent_of_checkout_and_destination_paths() {
1753 let first = unique_temp_directory("checkout-first");
1754 let second = unique_temp_directory("checkout-second");
1755 for root in [&first, &second] {
1756 fs::create_dir_all(root.join("source")).expect("create source directory");
1757 fs::write(root.join("source/input"), b"same input").expect("write input");
1758 fs::write(root.join("tool"), b"same tool").expect("write tool");
1759 }
1760 let spec = |root: &Path| {
1761 ArtifactCacheSpec::new(&root.join("cache"), "portable", "recipe/v1")
1762 .with_input("source", &root.join("source"))
1763 .with_tool("optimizer", &root.join("tool"))
1764 .with_arguments(&["--exact"])
1765 .with_output("output", &root.join("different/public/output"))
1766 };
1767
1768 let first_key = resolve_key(&spec(&first)).unwrap();
1769 let second_key = resolve_key(&spec(&second)).unwrap();
1770
1771 assert_eq!(first_key.key, second_key.key);
1772 assert_eq!(first_key.input_digest, second_key.input_digest);
1773 fs::remove_dir_all(first).expect("remove first checkout");
1774 fs::remove_dir_all(second).expect("remove second checkout");
1775 }
1776
1777 #[test]
1778 fn import_helper_and_debug_output_do_not_expose_identity_values() {
1779 let root = unique_temp_directory("import-output");
1780 let input = root.join("input");
1781 let external = root.join("fixed-build-location/output");
1782 fs::create_dir_all(external.parent().unwrap()).expect("create fixed output directory");
1783 fs::write(&input, b"input").expect("write input");
1784 fs::write(&external, b"external-output").expect("write external output");
1785 let spec = ArtifactCacheSpec::new(&root.join("cache"), "import", "recipe/v1")
1786 .with_input("input", &input)
1787 .with_environment(&[("SECRET_TOKEN", "do-not-render")])
1788 .with_identity_bytes("private-ish", b"also-do-not-render")
1789 .with_output("output", &root.join("public/output"));
1790 let debug = format!("{spec:?}");
1791 assert!(debug.contains("SECRET_TOKEN"));
1792 assert!(!debug.contains("do-not-render"));
1793 assert!(!debug.contains("also-do-not-render"));
1794 let transaction = expect_build(prepare_artifact_cache(&spec).unwrap());
1795
1796 transaction
1797 .import_output("output", &external)
1798 .expect("import external output");
1799 let outcome = transaction.commit().expect("commit imported output");
1800
1801 assert_eq!(
1802 fs::read(outcome.record().artifacts()[0].path()).unwrap(),
1803 b"external-output"
1804 );
1805 fs::remove_dir_all(root).expect("remove import-output test directory");
1806 }
1807
1808 #[test]
1809 fn undeclared_staging_output_rejects_the_transaction() {
1810 let root = unique_temp_directory("undeclared-output");
1811 let input = root.join("input");
1812 fs::write(&input, b"input").expect("write input");
1813 let spec = ArtifactCacheSpec::new(&root.join("cache"), "extra", "recipe/v1")
1814 .with_input("input", &input)
1815 .with_output("output", &root.join("output"));
1816 let transaction = expect_build(prepare_artifact_cache(&spec).unwrap());
1817 fs::write(transaction.output_path("output").unwrap(), b"declared")
1818 .expect("write declared output");
1819 fs::write(
1820 transaction.staging_directory().join("outputs/extra"),
1821 b"undeclared",
1822 )
1823 .expect("write undeclared output");
1824
1825 assert!(matches!(
1826 transaction.commit(),
1827 Err(ArtifactCacheError::InvalidOutputs { .. })
1828 ));
1829 fs::remove_dir_all(root).expect("remove undeclared-output test directory");
1830 }
1831
1832 #[test]
1833 fn empty_output_requires_explicit_regular_file_validation() {
1834 let root = unique_temp_directory("empty-output");
1835 let input = root.join("input");
1836 fs::write(&input, b"input").expect("write input");
1837 let default_spec = ArtifactCacheSpec::new(&root.join("cache"), "empty", "recipe/v1")
1838 .with_input("input", &input)
1839 .with_output("output", &root.join("output"));
1840 let transaction = expect_build(prepare_artifact_cache(&default_spec).unwrap());
1841 fs::write(transaction.output_path("output").unwrap(), b"").expect("write empty output");
1842 assert!(matches!(
1843 transaction.commit(),
1844 Err(ArtifactCacheError::InvalidOutputs { .. })
1845 ));
1846
1847 let regular_spec = ArtifactCacheSpec::new(&root.join("cache"), "empty", "recipe/v1")
1848 .with_input("input", &input)
1849 .with_output_validation(
1850 "output",
1851 &root.join("output"),
1852 ArtifactOutputValidation::RegularFile,
1853 );
1854 let transaction = expect_build(prepare_artifact_cache(®ular_spec).unwrap());
1855 fs::write(transaction.output_path("output").unwrap(), b"").expect("write empty output");
1856 transaction
1857 .commit()
1858 .expect("commit explicitly valid empty file");
1859 fs::remove_dir_all(root).expect("remove empty-output test directory");
1860 }
1861
1862 fn expect_build(preparation: ArtifactCachePreparation) -> super::ArtifactBuildTransaction {
1863 match preparation {
1864 ArtifactCachePreparation::Build(transaction) => transaction,
1865 ArtifactCachePreparation::Reused(_) => panic!("expected a cache miss transaction"),
1866 }
1867 }
1868
1869 fn build_output(spec: &ArtifactCacheSpec, contents: &[u8]) -> ArtifactCacheOutcome {
1870 let transaction = expect_build(prepare_artifact_cache(spec).expect("prepare build"));
1871 fs::write(transaction.output_path("output").unwrap(), contents)
1872 .expect("write staged output");
1873 transaction.commit().expect("commit output")
1874 }
1875
1876 fn unique_temp_directory(label: &str) -> PathBuf {
1877 let sequence = TEMP_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1878 let path = std::env::temp_dir().join(format!(
1879 "ic-testkit-artifact-cache-{label}-{}-{sequence}",
1880 std::process::id()
1881 ));
1882 if path.exists() {
1883 fs::remove_dir_all(&path).expect("remove stale test directory");
1884 }
1885 fs::create_dir_all(&path).expect("create test directory");
1886 path
1887 }
1888}