Skip to main content

ci_engine/result_cache/
entry.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Portable, serializable cache entry and fail-closed comparison.
3
4use crypto::{CiVerdictBody, Conclusion};
5use serde::{Deserialize, Serialize};
6
7use super::key::CacheKey;
8use crate::model::{AttemptRecord, CheckResult};
9
10/// Schema version of [`ResultCacheEntry`]. Bump when the bytes change.
11pub const RESULT_CACHE_SCHEMA_VERSION: u32 = 1;
12
13/// A portable cached check result, bound to env, inputs, and check identity.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ResultCacheEntry {
16    /// Entry schema version.
17    pub schema_version: u32,
18    /// Digest of the content-addressed environment `E`.
19    pub env_digest: String,
20    /// Content-addresses of the evaluated inputs.
21    pub input_digests: Vec<String>,
22    /// Digest of the authored definition.
23    pub definition_digest: String,
24    /// Check name within that definition.
25    pub check_name: String,
26    /// BLAKE3 of the captured output (logs are not the cache key).
27    pub evidence_digest: String,
28    /// Reusable verdict body (a cache hit *is* a verdict).
29    pub body: CiVerdictBody,
30    /// ANSI-stripped combined output reused on a hit.
31    pub combined_output: String,
32    /// Attempt count from the original run.
33    pub attempts: u32,
34    /// Operational attempt records; not part of the signed body.
35    pub attempt_records: Vec<AttemptRecord>,
36}
37
38/// Details of a fail-closed spot-check disagreement.
39#[derive(Debug)]
40pub struct SpotCheckDivergence {
41    /// Check that disagreed.
42    pub check_name: String,
43    /// Conclusion stored in the cache entry.
44    pub cached_conclusion: String,
45    /// Evidence digest stored in the cache entry.
46    pub cached_evidence: String,
47    /// Conclusion produced by the fresh run.
48    pub fresh_conclusion: String,
49    /// Evidence digest of the fresh run.
50    pub fresh_evidence: String,
51    /// Environment digest from the lookup key.
52    pub env_digest: String,
53    /// Input digests from the lookup key.
54    pub input_digests: Vec<String>,
55    /// Definition digest from the lookup key.
56    pub definition_digest: String,
57}
58
59impl std::fmt::Display for SpotCheckDivergence {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(
62            f,
63            "ci result cache spot-check failed for check `{}`: \
64             cached (conclusion={}, evidence={}) \
65             disagrees with fresh (conclusion={}, evidence={}); \
66             refusing to trust the cache entry \
67             [env={} inputs={:?} definition={}]",
68            self.check_name,
69            self.cached_conclusion,
70            self.cached_evidence,
71            self.fresh_conclusion,
72            self.fresh_evidence,
73            self.env_digest,
74            self.input_digests,
75            self.definition_digest
76        )
77    }
78}
79
80impl std::error::Error for SpotCheckDivergence {}
81
82/// Errors from cache I/O or a fail-closed spot-check disagreement.
83#[derive(Debug, thiserror::Error)]
84pub enum ResultCacheError {
85    /// Filesystem or encoding failure while reading or writing an entry.
86    #[error("ci result cache I/O error: {0}")]
87    Io(#[from] std::io::Error),
88    /// A sampled cache hit disagreed with a fresh run. Never trusted.
89    #[error(transparent)]
90    SpotCheckDivergence(Box<SpotCheckDivergence>),
91}
92
93impl ResultCacheEntry {
94    /// Build a portable entry from a completed check result.
95    #[must_use]
96    pub fn from_result(key: &CacheKey, check_name: &str, result: &CheckResult) -> Self {
97        Self {
98            schema_version: RESULT_CACHE_SCHEMA_VERSION,
99            env_digest: key.env_digest.clone(),
100            input_digests: key.input_digests.clone(),
101            definition_digest: key.definition_digest.clone(),
102            check_name: check_name.to_string(),
103            evidence_digest: evidence_digest(&result.combined_output),
104            body: result.body.clone(),
105            combined_output: result.combined_output.clone(),
106            attempts: result.attempts,
107            attempt_records: result.attempt_records.clone(),
108        }
109    }
110
111    /// Reconstruct the executor result reused on a cache hit.
112    #[must_use]
113    pub fn into_check_result(self) -> CheckResult {
114        CheckResult {
115            body: self.body,
116            combined_output: self.combined_output,
117            attempts: self.attempts,
118            attempt_records: self.attempt_records,
119        }
120    }
121
122    pub(super) fn is_valid_for(&self, key: &CacheKey, check_name: &str) -> bool {
123        self.schema_version == RESULT_CACHE_SCHEMA_VERSION
124            && self.env_digest == key.env_digest
125            && self.input_digests == key.input_digests
126            && self.definition_digest == key.definition_digest
127            && self.check_name == check_name
128            && self.evidence_digest == evidence_digest(&self.combined_output)
129            && self.binds_check_identity(key, check_name)
130    }
131
132    /// Fail-closed comparison of a cached entry against a fresh run.
133    pub fn verify_fresh(&self, fresh: &CheckResult) -> Result<(), ResultCacheError> {
134        let fresh_evidence = evidence_digest(&fresh.combined_output);
135        if self.body.outcome == fresh.body.outcome
136            && self.evidence_digest == fresh_evidence
137            && same_check_identity(&self.body, &fresh.body)
138        {
139            return Ok(());
140        }
141        Err(ResultCacheError::SpotCheckDivergence(Box::new(
142            SpotCheckDivergence {
143                check_name: self.check_name.clone(),
144                cached_conclusion: conclusion_label(self.body.outcome.conclusion).to_string(),
145                cached_evidence: self.evidence_digest.clone(),
146                fresh_conclusion: conclusion_label(fresh.conclusion()).to_string(),
147                fresh_evidence,
148                env_digest: self.env_digest.clone(),
149                input_digests: self.input_digests.clone(),
150                definition_digest: self.definition_digest.clone(),
151            },
152        )))
153    }
154
155    pub(super) fn cache_key(&self) -> CacheKey {
156        CacheKey {
157            env_digest: self.env_digest.clone(),
158            input_digests: self.input_digests.clone(),
159            definition_digest: self.definition_digest.clone(),
160            repo: self.body.repo.clone(),
161            state: self.body.state.clone(),
162            basis: self.body.basis.clone(),
163            command: self.body.check.command.clone(),
164            class: self.body.check.class,
165        }
166    }
167
168    fn binds_check_identity(&self, key: &CacheKey, check_name: &str) -> bool {
169        self.body.repo == key.repo
170            && self.body.state == key.state
171            && self.body.basis == key.basis
172            && self.body.check.definition_digest == key.definition_digest
173            && self.body.check.command == key.command
174            && self.body.check.class == key.class
175            && self.body.check.name == check_name
176    }
177}
178
179fn same_check_identity(cached: &CiVerdictBody, fresh: &CiVerdictBody) -> bool {
180    cached.repo == fresh.repo
181        && cached.state == fresh.state
182        && cached.basis == fresh.basis
183        && cached.check.definition_digest == fresh.check.definition_digest
184        && cached.check.command == fresh.check.command
185        && cached.check.class == fresh.check.class
186        && cached.check.name == fresh.check.name
187}
188
189/// Domain-separated BLAKE3 of captured check output.
190#[must_use]
191pub fn evidence_digest(combined_output: &str) -> String {
192    let mut hasher = blake3::Hasher::new();
193    hasher.update(b"heddle-ci-evidence-v1\0");
194    hasher.update(&(combined_output.len() as u64).to_le_bytes());
195    hasher.update(combined_output.as_bytes());
196    hasher.finalize().to_hex().to_string()
197}
198
199fn conclusion_label(conclusion: Conclusion) -> &'static str {
200    match conclusion {
201        Conclusion::Success => "success",
202        Conclusion::Failure => "failure",
203        Conclusion::Cancelled => "cancelled",
204        Conclusion::Skipped => "skipped",
205        Conclusion::TimedOut => "timed_out",
206        Conclusion::InfraError => "infra_error",
207    }
208}