1use crypto::{CiVerdictBody, Conclusion};
5use serde::{Deserialize, Serialize};
6
7use super::key::CacheKey;
8use crate::model::{AttemptRecord, CheckResult};
9
10pub const RESULT_CACHE_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ResultCacheEntry {
16 pub schema_version: u32,
18 pub env_digest: String,
20 pub input_digests: Vec<String>,
22 pub definition_digest: String,
24 pub check_name: String,
26 pub evidence_digest: String,
28 pub body: CiVerdictBody,
30 pub combined_output: String,
32 pub attempts: u32,
34 pub attempt_records: Vec<AttemptRecord>,
36}
37
38#[derive(Debug)]
40pub struct SpotCheckDivergence {
41 pub check_name: String,
43 pub cached_conclusion: String,
45 pub cached_evidence: String,
47 pub fresh_conclusion: String,
49 pub fresh_evidence: String,
51 pub env_digest: String,
53 pub input_digests: Vec<String>,
55 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#[derive(Debug, thiserror::Error)]
84pub enum ResultCacheError {
85 #[error("ci result cache I/O error: {0}")]
87 Io(#[from] std::io::Error),
88 #[error(transparent)]
90 SpotCheckDivergence(Box<SpotCheckDivergence>),
91}
92
93impl ResultCacheEntry {
94 #[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 #[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 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#[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}