headless_lms_utils/stable_digest.rs
1/// A hex BLAKE3 digest of `parts` that is the same in every process and every release.
2///
3/// Each part is length-prefixed, so no two different lists of parts can ever hash to the same
4/// value: pass the parts as they are and do not add separators of your own. Returns all 64 hex
5/// characters; truncate at the call site if a shorter identifier is wanted.
6///
7/// Unkeyed, so the digest of a guessable input is guessable. Anything that has to resist that must
8/// include a secret part of its own, the way [`crate::page_visit_hasher`] mixes in the day's key.
9///
10/// New callers use this; [`crate::error_identifier`]'s own scheme stays only because its stored
11/// identifiers may not change.
12pub fn stable_digest(parts: &[&[u8]]) -> String {
13 let mut hasher = blake3::Hasher::new();
14 for part in parts {
15 hasher.update(&(part.len() as u64).to_le_bytes());
16 hasher.update(part);
17 }
18 hasher.finalize().to_hex().to_string()
19}
20
21#[cfg(test)]
22mod tests {
23 use super::*;
24
25 /// The digest identifies whatever the caller split into parts, so two different splits sharing
26 /// their concatenation have to disagree. Without the length prefix they would not, and callers
27 /// would silently share cache entries or error groups.
28 #[test]
29 fn parts_that_only_differ_in_where_they_are_split_digest_differently() {
30 assert_ne!(stable_digest(&[b"ab", b"c"]), stable_digest(&[b"a", b"bc"]));
31 assert_ne!(stable_digest(&[b"a", b""]), stable_digest(&[b"a"]));
32 }
33
34 /// Callers store digests and compare them across releases and across replicas, so the value
35 /// has to be pinned rather than merely deterministic within one run.
36 #[test]
37 fn a_given_input_digests_to_the_same_value_on_every_host_and_release() {
38 assert_eq!(
39 stable_digest(&[b"one", b"two"]),
40 "895cdc7d0d102a0763bd1cf348febb7c06c5e74b1b3889773f138c723acb7b5c"
41 );
42 }
43}