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::{CI_VERDICT_BODY_SCHEMA_VERSION, 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 = 2;
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.body.schema_version == CI_VERDICT_BODY_SCHEMA_VERSION
125            && self.env_digest == key.env_digest
126            && self.input_digests == key.input_digests
127            && self.definition_digest == key.definition_digest
128            && self.check_name == check_name
129            && self.evidence_digest == evidence_digest(&self.combined_output)
130            && self.binds_check_identity(key, check_name)
131    }
132
133    /// Fail-closed comparison of a cached entry against a fresh run.
134    pub fn verify_fresh(&self, fresh: &CheckResult) -> Result<(), ResultCacheError> {
135        let fresh_evidence = evidence_digest(&fresh.combined_output);
136        if self.body.outcome == fresh.body.outcome
137            && self.evidence_digest == fresh_evidence
138            && same_check_identity(&self.body, &fresh.body)
139        {
140            return Ok(());
141        }
142        Err(ResultCacheError::SpotCheckDivergence(Box::new(
143            SpotCheckDivergence {
144                check_name: self.check_name.clone(),
145                cached_conclusion: conclusion_label(self.body.outcome.conclusion).to_string(),
146                cached_evidence: self.evidence_digest.clone(),
147                fresh_conclusion: conclusion_label(fresh.conclusion()).to_string(),
148                fresh_evidence,
149                env_digest: self.env_digest.clone(),
150                input_digests: self.input_digests.clone(),
151                definition_digest: self.definition_digest.clone(),
152            },
153        )))
154    }
155
156    pub(super) fn cache_key(&self) -> CacheKey {
157        CacheKey {
158            env_digest: self.env_digest.clone(),
159            input_digests: self.input_digests.clone(),
160            definition_digest: self.definition_digest.clone(),
161            repo: self.body.repo.clone(),
162            state: self.body.state.clone(),
163            basis: self.body.basis.clone(),
164            command: self.body.check.command.clone(),
165            class: self.body.check.class,
166        }
167    }
168
169    fn binds_check_identity(&self, key: &CacheKey, check_name: &str) -> bool {
170        self.body.repo == key.repo
171            && self.body.state == key.state
172            && self.body.basis == key.basis
173            && self.body.check.definition_digest == key.definition_digest
174            && self.body.check.command == key.command
175            && self.body.check.class == key.class
176            && self.body.check.name == check_name
177    }
178}
179
180fn same_check_identity(cached: &CiVerdictBody, fresh: &CiVerdictBody) -> bool {
181    cached.repo == fresh.repo
182        && cached.state == fresh.state
183        && cached.basis == fresh.basis
184        && cached.check.definition_digest == fresh.check.definition_digest
185        && cached.check.command == fresh.check.command
186        && cached.check.class == fresh.check.class
187        && cached.check.name == fresh.check.name
188}
189
190/// Domain-separated BLAKE3 of captured check output.
191#[must_use]
192pub fn evidence_digest(combined_output: &str) -> String {
193    let mut hasher = blake3::Hasher::new();
194    hasher.update(b"heddle-ci-evidence-v1\0");
195    hasher.update(&(combined_output.len() as u64).to_le_bytes());
196    hasher.update(combined_output.as_bytes());
197    hasher.finalize().to_hex().to_string()
198}
199
200fn conclusion_label(conclusion: Conclusion) -> &'static str {
201    match conclusion {
202        Conclusion::Success => "success",
203        Conclusion::Failure => "failure",
204        Conclusion::Cancelled => "cancelled",
205        Conclusion::Skipped => "skipped",
206        Conclusion::TimedOut => "timed_out",
207        Conclusion::InfraError => "infra_error",
208    }
209}