Skip to main content

ic_testkit/artifacts/
transaction.rs

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/// Built-in validation applied to one transactional artifact output.
28#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
29pub enum ArtifactOutputValidation {
30    /// Require a regular file; an empty file is valid.
31    RegularFile,
32    /// Require a nonempty regular file.
33    #[default]
34    NonEmptyFile,
35}
36
37/// Complete caller-owned description of one transactional artifact set.
38#[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
53/// Result of preparing a transactional artifact-set acquisition.
54pub enum ArtifactCachePreparation {
55    /// A complete verified entry was materialized without running the caller's build.
56    Reused(ArtifactCacheRecord),
57    /// The caller must populate and commit a transaction-owned staging directory.
58    Build(ArtifactBuildTransaction),
59}
60
61/// Whether an artifact transaction published or reused its exact output set.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub enum ArtifactCacheOutcome {
64    /// The caller populated staging and the complete result was published.
65    Built(ArtifactCacheRecord),
66    /// A complete existing entry was verified and materialized.
67    Reused(ArtifactCacheRecord),
68}
69
70/// Details shared by built and reused transactional artifact outcomes.
71#[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/// One logical artifact materialized at its caller-selected destination.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct ArtifactCacheArtifact {
83    name: String,
84    path: PathBuf,
85}
86
87/// Phase timings for one transactional artifact-cache acquisition.
88#[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
103/// Owned miss transaction holding the recipe and content-key process locks.
104pub 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/// Structured failure from transactional artifact caching.
119#[non_exhaustive]
120#[derive(Debug)]
121pub enum ArtifactCacheError {
122    /// The caller supplied an incomplete, ambiguous, or unsafe specification.
123    InvalidSpec { message: String },
124    /// A filesystem operation failed.
125    Io {
126        operation: &'static str,
127        path: PathBuf,
128        source: io::Error,
129    },
130    /// Inputs repeatedly changed while preparing a stable cache acquisition.
131    InputsChangedDuringPreparation {
132        before: InputDigest,
133        after: InputDigest,
134    },
135    /// Inputs changed while the caller owned a build transaction.
136    InputsChangedDuringBuild {
137        before: InputDigest,
138        after: InputDigest,
139    },
140    /// One or more declared staged outputs were missing or failed validation.
141    InvalidOutputs { outputs: Vec<(String, PathBuf)> },
142    /// A logical output name was not declared by the transaction specification.
143    UnknownOutput { name: String },
144    /// Transaction failure cleanup also failed.
145    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    /// Describe one external artifact recipe using non-secret stable identifiers.
184    ///
185    /// The namespace selects an independent content store. `recipe_id` must be
186    /// bumped whenever undeclared pipeline semantics change. The default
187    /// coordination scope is the namespace.
188    #[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    /// Serialize recipes that share caller-owned mutable external build state.
206    #[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    /// Add one exact input file or directory under a stable logical label.
213    #[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    /// Add one exact executable or other tool file under a stable logical label.
223    #[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    /// Set ordered command arguments that contribute to the content key.
233    #[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    /// Set environment values that contribute to the content key.
240    #[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    /// Record environment variables whose unset state contributes to the content key.
251    #[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    /// Add opaque non-secret identity bytes under a stable logical label.
259    #[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    /// Declare one nonempty regular-file output and its public destination.
269    #[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    /// Declare one output, public destination, and built-in validation policy.
275    #[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    /// Apply best-effort retention while protecting the acquired entry.
291    #[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    /// Caller-selected root containing cache data and coordination locks.
298    #[must_use]
299    pub fn cache_root(&self) -> &Path {
300        &self.cache_root
301    }
302
303    /// Stable caller-owned content-store namespace.
304    #[must_use]
305    pub fn namespace(&self) -> &str {
306        &self.namespace
307    }
308
309    /// Stable caller-owned external recipe identity.
310    #[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    /// Return the already materialized record, or `None` for a cache miss transaction.
347    #[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    /// Read the common acquisition record.
358    #[must_use]
359    pub const fn record(&self) -> &ArtifactCacheRecord {
360        match self {
361            Self::Built(record) | Self::Reused(record) => record,
362        }
363    }
364
365    /// Report whether a complete matching artifact set was reused.
366    #[must_use]
367    pub const fn is_reused(&self) -> bool {
368        matches!(self, Self::Reused(_))
369    }
370}
371
372impl ArtifactCacheRecord {
373    /// Exact content key selecting the immutable cache entry.
374    #[must_use]
375    pub const fn key(&self) -> InputDigest {
376        self.key
377    }
378
379    /// Exact digest of declared input and tool paths.
380    #[must_use]
381    pub const fn input_digest(&self) -> InputDigest {
382        self.input_digest
383    }
384
385    /// Logical artifacts and their materialized caller destinations.
386    #[must_use]
387    pub fn artifacts(&self) -> &[ArtifactCacheArtifact] {
388        &self.artifacts
389    }
390
391    /// Phase timings for this acquisition.
392    #[must_use]
393    pub const fn timings(&self) -> ArtifactCacheTimings {
394        self.timings
395    }
396
397    /// Best-effort retention attempted during this acquisition, when configured.
398    #[must_use]
399    pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
400        self.maintenance.as_ref()
401    }
402}
403
404impl ArtifactCacheArtifact {
405    /// Stable logical output name.
406    #[must_use]
407    pub fn name(&self) -> &str {
408        &self.name
409    }
410
411    /// Caller-selected materialized destination.
412    #[must_use]
413    pub fn path(&self) -> &Path {
414        &self.path
415    }
416}
417
418impl ArtifactCacheTimings {
419    /// Time waiting for the caller-selected coordination scope.
420    #[must_use]
421    pub const fn coordination_lock_wait(self) -> Duration {
422        self.coordination_lock_wait
423    }
424
425    /// Time waiting for exact content-key ownership.
426    #[must_use]
427    pub const fn content_lock_wait(self) -> Duration {
428        self.content_lock_wait
429    }
430
431    /// Time waiting for short namespace mutations.
432    #[must_use]
433    pub const fn namespace_lock_wait(self) -> Duration {
434        self.namespace_lock_wait
435    }
436
437    /// Time spent hashing and verifying declared inputs.
438    #[must_use]
439    pub const fn input_capture(self) -> Duration {
440        self.input_capture
441    }
442
443    /// Time spent validating or rejecting a committed cache entry.
444    #[must_use]
445    pub const fn cache_lookup(self) -> Duration {
446        self.cache_lookup
447    }
448
449    /// Time the caller held a miss transaction before committing it.
450    #[must_use]
451    pub const fn caller_build(self) -> Option<Duration> {
452        self.caller_build
453    }
454
455    /// Time spent validating staged output files.
456    #[must_use]
457    pub const fn output_validation(self) -> Duration {
458        self.output_validation
459    }
460
461    /// Time spent publishing an immutable content entry.
462    #[must_use]
463    pub const fn publication(self) -> Duration {
464        self.publication
465    }
466
467    /// Time spent materializing caller-facing output destinations.
468    #[must_use]
469    pub const fn materialization(self) -> Duration {
470        self.materialization
471    }
472
473    /// Time spent on configured best-effort retention.
474    #[must_use]
475    pub const fn maintenance(self) -> Option<Duration> {
476        self.maintenance
477    }
478
479    /// Complete acquisition duration.
480    #[must_use]
481    pub const fn total(self) -> Duration {
482        self.total
483    }
484}
485
486impl ArtifactBuildTransaction {
487    /// Transaction-owned directory in which the caller may run its build.
488    #[must_use]
489    pub fn staging_directory(&self) -> &Path {
490        &self.staging_directory
491    }
492
493    /// Checked staging destination for one declared logical output.
494    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    /// Copy a fixed external output into its transaction-owned staging path.
503    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    /// Validate and atomically publish the complete staged output set.
518    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    /// Abandon the transaction and synchronously remove its staging directory.
541    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
640/// Verify or begin one exact external artifact-set transaction.
641pub 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
747/// Prune one transactional artifact namespace without acquiring an artifact.
748///
749/// This strict maintenance entry point returns cache errors directly. Build
750/// acquisitions configured with [`ArtifactCacheSpec::with_prune_policy`] use
751/// the same implementation but attach maintenance as a nonfatal record.
752pub 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(&regular_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}