use crypto::{CiVerdictBody, Conclusion};
use serde::{Deserialize, Serialize};
use super::key::CacheKey;
use crate::model::{AttemptRecord, CheckResult};
pub const RESULT_CACHE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResultCacheEntry {
pub schema_version: u32,
pub env_digest: String,
pub input_digests: Vec<String>,
pub definition_digest: String,
pub check_name: String,
pub evidence_digest: String,
pub body: CiVerdictBody,
pub combined_output: String,
pub attempts: u32,
pub attempt_records: Vec<AttemptRecord>,
}
#[derive(Debug)]
pub struct SpotCheckDivergence {
pub check_name: String,
pub cached_conclusion: String,
pub cached_evidence: String,
pub fresh_conclusion: String,
pub fresh_evidence: String,
pub env_digest: String,
pub input_digests: Vec<String>,
pub definition_digest: String,
}
impl std::fmt::Display for SpotCheckDivergence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"ci result cache spot-check failed for check `{}`: \
cached (conclusion={}, evidence={}) \
disagrees with fresh (conclusion={}, evidence={}); \
refusing to trust the cache entry \
[env={} inputs={:?} definition={}]",
self.check_name,
self.cached_conclusion,
self.cached_evidence,
self.fresh_conclusion,
self.fresh_evidence,
self.env_digest,
self.input_digests,
self.definition_digest
)
}
}
impl std::error::Error for SpotCheckDivergence {}
#[derive(Debug, thiserror::Error)]
pub enum ResultCacheError {
#[error("ci result cache I/O error: {0}")]
Io(#[from] std::io::Error),
#[error(transparent)]
SpotCheckDivergence(Box<SpotCheckDivergence>),
}
impl ResultCacheEntry {
#[must_use]
pub fn from_result(key: &CacheKey, check_name: &str, result: &CheckResult) -> Self {
Self {
schema_version: RESULT_CACHE_SCHEMA_VERSION,
env_digest: key.env_digest.clone(),
input_digests: key.input_digests.clone(),
definition_digest: key.definition_digest.clone(),
check_name: check_name.to_string(),
evidence_digest: evidence_digest(&result.combined_output),
body: result.body.clone(),
combined_output: result.combined_output.clone(),
attempts: result.attempts,
attempt_records: result.attempt_records.clone(),
}
}
#[must_use]
pub fn into_check_result(self) -> CheckResult {
CheckResult {
body: self.body,
combined_output: self.combined_output,
attempts: self.attempts,
attempt_records: self.attempt_records,
}
}
pub(super) fn is_valid_for(&self, key: &CacheKey, check_name: &str) -> bool {
self.schema_version == RESULT_CACHE_SCHEMA_VERSION
&& self.env_digest == key.env_digest
&& self.input_digests == key.input_digests
&& self.definition_digest == key.definition_digest
&& self.check_name == check_name
&& self.evidence_digest == evidence_digest(&self.combined_output)
&& self.binds_check_identity(key, check_name)
}
pub fn verify_fresh(&self, fresh: &CheckResult) -> Result<(), ResultCacheError> {
let fresh_evidence = evidence_digest(&fresh.combined_output);
if self.body.outcome == fresh.body.outcome
&& self.evidence_digest == fresh_evidence
&& same_check_identity(&self.body, &fresh.body)
{
return Ok(());
}
Err(ResultCacheError::SpotCheckDivergence(Box::new(
SpotCheckDivergence {
check_name: self.check_name.clone(),
cached_conclusion: conclusion_label(self.body.outcome.conclusion).to_string(),
cached_evidence: self.evidence_digest.clone(),
fresh_conclusion: conclusion_label(fresh.conclusion()).to_string(),
fresh_evidence,
env_digest: self.env_digest.clone(),
input_digests: self.input_digests.clone(),
definition_digest: self.definition_digest.clone(),
},
)))
}
pub(super) fn cache_key(&self) -> CacheKey {
CacheKey {
env_digest: self.env_digest.clone(),
input_digests: self.input_digests.clone(),
definition_digest: self.definition_digest.clone(),
repo: self.body.repo.clone(),
state: self.body.state.clone(),
basis: self.body.basis.clone(),
command: self.body.check.command.clone(),
class: self.body.check.class,
}
}
fn binds_check_identity(&self, key: &CacheKey, check_name: &str) -> bool {
self.body.repo == key.repo
&& self.body.state == key.state
&& self.body.basis == key.basis
&& self.body.check.definition_digest == key.definition_digest
&& self.body.check.command == key.command
&& self.body.check.class == key.class
&& self.body.check.name == check_name
}
}
fn same_check_identity(cached: &CiVerdictBody, fresh: &CiVerdictBody) -> bool {
cached.repo == fresh.repo
&& cached.state == fresh.state
&& cached.basis == fresh.basis
&& cached.check.definition_digest == fresh.check.definition_digest
&& cached.check.command == fresh.check.command
&& cached.check.class == fresh.check.class
&& cached.check.name == fresh.check.name
}
#[must_use]
pub fn evidence_digest(combined_output: &str) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(b"heddle-ci-evidence-v1\0");
hasher.update(&(combined_output.len() as u64).to_le_bytes());
hasher.update(combined_output.as_bytes());
hasher.finalize().to_hex().to_string()
}
fn conclusion_label(conclusion: Conclusion) -> &'static str {
match conclusion {
Conclusion::Success => "success",
Conclusion::Failure => "failure",
Conclusion::Cancelled => "cancelled",
Conclusion::Skipped => "skipped",
Conclusion::TimedOut => "timed_out",
Conclusion::InfraError => "infra_error",
}
}