1use std::collections::BTreeMap;
5
6use ci_config::Check;
7use crypto::{Basis, CheckClass, StateRef};
8use serde::Serialize;
9
10use crate::{cache::CACHE_ENV_PREFIX, model::ExecutionContext};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct CacheKey {
19 pub env_digest: String,
21 pub input_digests: Vec<String>,
23 pub definition_digest: String,
25 pub repo: String,
27 pub state: StateRef,
29 pub basis: Basis,
31 pub command: Vec<String>,
33 pub class: CheckClass,
35}
36
37#[derive(Serialize)]
38struct EnvMaterial<'a> {
39 os: &'a str,
40 arch: &'a str,
41 image_digest: Option<&'a str>,
42 toolchain: Option<&'a str>,
43 env: BTreeMap<&'a str, &'a str>,
44}
45
46impl CacheKey {
47 #[must_use]
51 pub fn derive(
52 environment: &BTreeMap<String, String>,
53 context: &ExecutionContext,
54 check: &Check,
55 ) -> Self {
56 Self {
57 env_digest: env_digest(environment, context),
58 input_digests: input_digests(context),
59 definition_digest: context.definition_digest.clone(),
60 repo: context.repo.clone(),
61 state: context.state.clone(),
62 basis: context.basis.clone(),
63 command: check.command.clone(),
64 class: map_class(check.class),
65 }
66 }
67
68 #[must_use]
70 pub fn id(&self) -> String {
71 digest_json(b"key", self)
72 }
73}
74
75pub(super) fn entry_id(key: &CacheKey, check_name: &str) -> String {
76 digest_json(b"entry", &(key, check_name))
77}
78
79pub(super) fn entry_id_bytes(key: &CacheKey, check_name: &str) -> [u8; 32] {
80 hash_json(b"entry", &(key, check_name))
81}
82
83fn map_class(class: ci_config::CheckClass) -> CheckClass {
84 match class {
85 ci_config::CheckClass::Required => CheckClass::Required,
86 ci_config::CheckClass::Advisory => CheckClass::Advisory,
87 ci_config::CheckClass::Informational => CheckClass::Informational,
88 }
89}
90
91fn env_digest(environment: &BTreeMap<String, String>, context: &ExecutionContext) -> String {
92 let env = portable_env(environment);
93 digest_json(
94 b"env",
95 &EnvMaterial {
96 os: std::env::consts::OS,
97 arch: std::env::consts::ARCH,
98 image_digest: context.image_digest.as_deref(),
99 toolchain: context.toolchain.as_deref(),
100 env,
101 },
102 )
103}
104
105fn input_digests(context: &ExecutionContext) -> Vec<String> {
106 let mut inputs = vec![
107 format!("state:{}", context.state.content_hash),
108 format!("tree:{}", context.basis.evaluated_tree_digest),
109 ];
110 inputs.sort();
111 inputs
112}
113
114fn portable_env(environment: &BTreeMap<String, String>) -> BTreeMap<&str, &str> {
115 environment
116 .iter()
117 .filter(|(name, _)| !is_machine_local_key(name))
118 .map(|(name, value)| (name.as_str(), value.as_str()))
119 .collect()
120}
121
122fn is_machine_local_key(name: &str) -> bool {
123 name.starts_with(CACHE_ENV_PREFIX)
124 || matches!(
125 name,
126 "PATH"
127 | "HOME"
128 | "USER"
129 | "SHELL"
130 | "TERM"
131 | "CARGO_HOME"
132 | "RUSTUP_HOME"
133 | "TMPDIR"
134 | "TEMP"
135 | "TMP"
136 | "PWD"
137 | "HOSTNAME"
138 | "HOST"
139 | "LOGNAME"
140 )
141}
142
143fn digest_json(label: &[u8], value: &impl Serialize) -> String {
144 blake3::Hash::from_bytes(hash_json(label, value))
145 .to_hex()
146 .to_string()
147}
148
149fn hash_json(label: &[u8], value: &impl Serialize) -> [u8; 32] {
150 let payload = serde_json::to_vec(value).expect("cache key material is always serializable");
151 let mut hasher = blake3::Hasher::new();
152 hasher.update(b"heddle-ci-result-cache-v1\0");
153 hasher.update(&(label.len() as u64).to_le_bytes());
154 hasher.update(label);
155 hasher.update(&(payload.len() as u64).to_le_bytes());
156 hasher.update(&payload);
157 *hasher.finalize().as_bytes()
158}
159
160#[cfg(test)]
161mod tests {
162 use ci_config::CheckClass as ConfigClass;
163 use crypto::{Basis, BasisKind, StateRef};
164
165 use super::*;
166 use crate::model::ExecutionContext;
167
168 fn check() -> Check {
169 let mut check = Check::new("marker", vec!["echo".to_string(), "ok".to_string()]);
170 check.timeout_secs = 60;
171 check.supersede = false;
172 check
173 }
174
175 fn context() -> ExecutionContext {
176 ExecutionContext {
177 repo: "test/repo".to_string(),
178 state: StateRef {
179 content_hash: "state-content".to_string(),
180 change_id: "change".to_string(),
181 logical_change_id: None,
182 },
183 basis: Basis {
184 kind: BasisKind::Branch,
185 evaluated_tree_digest: "tree".to_string(),
186 },
187 definition_digest: "definition".to_string(),
188 toolchain: Some("rustc 1.97.0".to_string()),
189 pick_id: None,
190 attempt: 1,
191 runner: None,
192 image_digest: Some("sha256:image".to_string()),
193 }
194 }
195
196 fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
197 pairs
198 .iter()
199 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
200 .collect()
201 }
202
203 #[test]
204 fn each_triple_component_changes_the_key() {
205 let check = check();
206 let base = CacheKey::derive(&env(&[("FOO", "1")]), &context(), &check);
207 let mut changed_env = context();
208 let env_miss = CacheKey::derive(&env(&[("FOO", "2")]), &changed_env, &check);
209 assert_ne!(base.env_digest, env_miss.env_digest);
210 assert_ne!(base.id(), env_miss.id());
211
212 changed_env.basis.evaluated_tree_digest = "tree-2".to_string();
213 let input_miss = CacheKey::derive(&env(&[("FOO", "1")]), &changed_env, &check);
214 assert_ne!(base.input_digests, input_miss.input_digests);
215 assert_ne!(base.id(), input_miss.id());
216
217 let mut changed_definition = context();
218 changed_definition.definition_digest = "definition-2".to_string();
219 let definition_miss = CacheKey::derive(&env(&[("FOO", "1")]), &changed_definition, &check);
220 assert_ne!(base.definition_digest, definition_miss.definition_digest);
221 assert_ne!(base.id(), definition_miss.id());
222 }
223
224 #[test]
225 fn check_identity_changes_the_key() {
226 let env = env(&[("FOO", "1")]);
227 let base = CacheKey::derive(&env, &context(), &check());
228
229 let mut other_class = check();
230 other_class.class = ConfigClass::Informational;
231 assert_ne!(
232 base.id(),
233 CacheKey::derive(&env, &context(), &other_class).id()
234 );
235
236 let mut other_command = check();
237 other_command.command = vec!["false".to_string()];
238 assert_ne!(
239 base.id(),
240 CacheKey::derive(&env, &context(), &other_command).id()
241 );
242
243 let mut other_repo = context();
244 other_repo.repo = "other/repo".to_string();
245 assert_ne!(
246 base.id(),
247 CacheKey::derive(&env, &other_repo, &check()).id()
248 );
249 }
250
251 #[test]
252 fn machine_local_env_is_not_in_the_key() {
253 let check = check();
254 let portable = CacheKey::derive(&env(&[("FOO", "1"), ("LANG", "C")]), &context(), &check);
255 let local = CacheKey::derive(
256 &env(&[
257 ("FOO", "1"),
258 ("LANG", "C"),
259 ("PATH", "/other/bin"),
260 ("HOME", "/other/home"),
261 ("HCI_CACHE_CARGO", "/tmp/machine-a/CARGO"),
262 ]),
263 &context(),
264 &check,
265 );
266 assert_eq!(portable.env_digest, local.env_digest);
267 assert_eq!(portable.id(), local.id());
268 }
269
270 #[test]
271 fn image_and_toolchain_are_part_of_env_digest() {
272 let check = check();
273 let base = CacheKey::derive(&env(&[]), &context(), &check);
274 let mut changed = context();
275 changed.image_digest = Some("sha256:other".to_string());
276 assert_ne!(
277 base.env_digest,
278 CacheKey::derive(&env(&[]), &changed, &check).env_digest
279 );
280 changed = context();
281 changed.toolchain = Some("rustc 1.88.0".to_string());
282 assert_ne!(
283 base.env_digest,
284 CacheKey::derive(&env(&[]), &changed, &check).env_digest
285 );
286 }
287}