Skip to main content

ci_engine/result_cache/
key.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Content-addressed cache key bound to env, inputs, and check identity.
3
4use 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/// Addresses a cached check result.
13///
14/// The key contains no machine-local state (paths, host identity, cache-slot
15/// directories). Changing any component yields a different [`CacheKey::id`].
16/// Identity fields bind a hit to one check; output digest alone is not enough.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct CacheKey {
19    /// Digest of the content-addressed execution environment `E`.
20    pub env_digest: String,
21    /// Sorted, tagged content-addresses of the evaluated inputs.
22    pub input_digests: Vec<String>,
23    /// Digest of the authored definition (`ci.toml` typed-blob hash).
24    pub definition_digest: String,
25    /// Repository the verdict was produced for.
26    pub repo: String,
27    /// Source state the verdict was produced for.
28    pub state: StateRef,
29    /// Evaluated basis the verdict was produced for.
30    pub basis: Basis,
31    /// Exact argv executed by the check.
32    pub command: Vec<String>,
33    /// Gating class of the check.
34    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    /// Derive the key from the portable projection of `environment`, the
48    /// execution context, and the check identity (command, class, repo, state,
49    /// basis, definition).
50    #[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    /// Domain-separated digest of the triple alone (no check name).
69    #[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, Retry};
163    use crypto::{Basis, BasisKind, StateRef};
164
165    use super::*;
166    use crate::model::ExecutionContext;
167
168    fn check() -> Check {
169        Check {
170            name: "marker".to_string(),
171            class: ConfigClass::Required,
172            command: vec!["echo".to_string(), "ok".to_string()],
173            timeout_secs: 60,
174            env: BTreeMap::new(),
175            services: Vec::new(),
176            cache_paths: Vec::new(),
177            retry: Retry::default(),
178            triggers: Vec::new(),
179            supersede: false,
180            isolation: None,
181        }
182    }
183
184    fn context() -> ExecutionContext {
185        ExecutionContext {
186            repo: "test/repo".to_string(),
187            state: StateRef {
188                content_hash: "state-content".to_string(),
189                change_id: "change".to_string(),
190                logical_change_id: None,
191            },
192            basis: Basis {
193                kind: BasisKind::Branch,
194                evaluated_tree_digest: "tree".to_string(),
195            },
196            definition_digest: "definition".to_string(),
197            toolchain: Some("rustc 1.97.0".to_string()),
198            pick_id: None,
199            attempt: 1,
200            runner: None,
201            image_digest: Some("sha256:image".to_string()),
202        }
203    }
204
205    fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
206        pairs
207            .iter()
208            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
209            .collect()
210    }
211
212    #[test]
213    fn each_triple_component_changes_the_key() {
214        let check = check();
215        let base = CacheKey::derive(&env(&[("FOO", "1")]), &context(), &check);
216        let mut changed_env = context();
217        let env_miss = CacheKey::derive(&env(&[("FOO", "2")]), &changed_env, &check);
218        assert_ne!(base.env_digest, env_miss.env_digest);
219        assert_ne!(base.id(), env_miss.id());
220
221        changed_env.basis.evaluated_tree_digest = "tree-2".to_string();
222        let input_miss = CacheKey::derive(&env(&[("FOO", "1")]), &changed_env, &check);
223        assert_ne!(base.input_digests, input_miss.input_digests);
224        assert_ne!(base.id(), input_miss.id());
225
226        let mut changed_definition = context();
227        changed_definition.definition_digest = "definition-2".to_string();
228        let definition_miss = CacheKey::derive(&env(&[("FOO", "1")]), &changed_definition, &check);
229        assert_ne!(base.definition_digest, definition_miss.definition_digest);
230        assert_ne!(base.id(), definition_miss.id());
231    }
232
233    #[test]
234    fn check_identity_changes_the_key() {
235        let env = env(&[("FOO", "1")]);
236        let base = CacheKey::derive(&env, &context(), &check());
237
238        let mut other_class = check();
239        other_class.class = ConfigClass::Informational;
240        assert_ne!(
241            base.id(),
242            CacheKey::derive(&env, &context(), &other_class).id()
243        );
244
245        let mut other_command = check();
246        other_command.command = vec!["false".to_string()];
247        assert_ne!(
248            base.id(),
249            CacheKey::derive(&env, &context(), &other_command).id()
250        );
251
252        let mut other_repo = context();
253        other_repo.repo = "other/repo".to_string();
254        assert_ne!(
255            base.id(),
256            CacheKey::derive(&env, &other_repo, &check()).id()
257        );
258    }
259
260    #[test]
261    fn machine_local_env_is_not_in_the_key() {
262        let check = check();
263        let portable = CacheKey::derive(&env(&[("FOO", "1"), ("LANG", "C")]), &context(), &check);
264        let local = CacheKey::derive(
265            &env(&[
266                ("FOO", "1"),
267                ("LANG", "C"),
268                ("PATH", "/other/bin"),
269                ("HOME", "/other/home"),
270                ("HCI_CACHE_CARGO", "/tmp/machine-a/CARGO"),
271            ]),
272            &context(),
273            &check,
274        );
275        assert_eq!(portable.env_digest, local.env_digest);
276        assert_eq!(portable.id(), local.id());
277    }
278
279    #[test]
280    fn image_and_toolchain_are_part_of_env_digest() {
281        let check = check();
282        let base = CacheKey::derive(&env(&[]), &context(), &check);
283        let mut changed = context();
284        changed.image_digest = Some("sha256:other".to_string());
285        assert_ne!(
286            base.env_digest,
287            CacheKey::derive(&env(&[]), &changed, &check).env_digest
288        );
289        changed = context();
290        changed.toolchain = Some("rustc 1.88.0".to_string());
291        assert_ne!(
292            base.env_digest,
293            CacheKey::derive(&env(&[]), &changed, &check).env_digest
294        );
295    }
296}