use sha2::{Digest, Sha256};
const FIELD_DELIMITER: u8 = 0x1f;
const TUPLE_DELIMITER: u8 = 0x1e;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Source<'a> {
pub urn: &'a str,
pub content_version: u64,
}
pub fn fingerprint(sources: &[Source<'_>]) -> String {
let mut ordered: Vec<Source<'_>> = sources.to_vec();
ordered.sort_by(|a, b| {
a.urn
.as_bytes()
.cmp(b.urn.as_bytes())
.then(a.content_version.cmp(&b.content_version))
});
ordered.dedup();
let mut hasher = Sha256::new();
for src in &ordered {
hasher.update(src.urn.as_bytes());
hasher.update([FIELD_DELIMITER]);
hasher.update(src.content_version.to_be_bytes());
hasher.update([TUPLE_DELIMITER]);
}
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(s: &[(&'static str, u64)]) -> String {
let v: Vec<Source<'_>> = s
.iter()
.map(|(u, v)| Source {
urn: u,
content_version: *v,
})
.collect();
fingerprint(&v)
}
#[test]
fn empty_input_hashes_to_sha256_of_empty() {
assert_eq!(
fingerprint(&[]),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn output_is_64_lowercase_hex_chars() {
let out = fp(&[("urn:doc:a", 1)]);
assert_eq!(out.len(), 64);
assert!(out.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
}
#[test]
fn deterministic_across_calls() {
let a = fp(&[("urn:doc:a", 1), ("urn:doc:b", 2)]);
let b = fp(&[("urn:doc:a", 1), ("urn:doc:b", 2)]);
assert_eq!(a, b);
}
#[test]
fn order_independent() {
let a = fp(&[("urn:doc:a", 1), ("urn:doc:b", 2)]);
let b = fp(&[("urn:doc:b", 2), ("urn:doc:a", 1)]);
assert_eq!(a, b);
}
#[test]
fn duplicates_collapse() {
let one = fp(&[("urn:doc:a", 1)]);
let dup = fp(&[("urn:doc:a", 1), ("urn:doc:a", 1)]);
assert_eq!(one, dup);
}
#[test]
fn version_change_flips_fingerprint() {
let v1 = fp(&[("urn:doc:a", 1)]);
let v2 = fp(&[("urn:doc:a", 2)]);
assert_ne!(v1, v2);
}
#[test]
fn different_urns_produce_different_fingerprints() {
let a = fp(&[("urn:doc:a", 1)]);
let b = fp(&[("urn:doc:b", 1)]);
assert_ne!(a, b);
}
#[test]
fn same_urn_different_versions_both_count() {
let single = fp(&[("urn:doc:a", 1)]);
let pair = fp(&[("urn:doc:a", 1), ("urn:doc:a", 2)]);
assert_ne!(single, pair);
}
#[test]
fn version_is_big_endian_8_bytes() {
let lo = fp(&[("urn:doc:a", 1)]);
let hi = fp(&[("urn:doc:a", 1u64 << 56)]);
assert_ne!(lo, hi);
}
#[test]
fn urn_boundary_is_injective() {
let a = fp(&[("ab", 1)]);
let b = fp(&[("a", 1)]);
assert_ne!(a, b);
}
#[test]
fn matches_hand_computed_single_entry() {
let mut hasher = Sha256::new();
hasher.update(b"urn:doc:a");
hasher.update([0x1f]);
hasher.update(1u64.to_be_bytes());
hasher.update([0x1e]);
let digest = hasher.finalize();
let mut expected = String::new();
for byte in digest {
use std::fmt::Write;
let _ = write!(&mut expected, "{byte:02x}");
}
assert_eq!(fp(&[("urn:doc:a", 1)]), expected);
}
#[test]
fn sort_is_byte_order_not_lex() {
let mixed = fp(&[("a", 1), ("Z", 1)]);
let reversed = fp(&[("Z", 1), ("a", 1)]);
assert_eq!(mixed, reversed);
let hand = fp(&[("Z", 1), ("a", 1)]);
assert_eq!(mixed, hand);
}
#[test]
fn version_secondary_sort_for_same_urn() {
let ascending = fp(&[("urn:doc:a", 1), ("urn:doc:a", 2)]);
let descending = fp(&[("urn:doc:a", 2), ("urn:doc:a", 1)]);
assert_eq!(ascending, descending);
}
#[test]
fn empty_urn_is_distinct_from_no_entries() {
let none = fingerprint(&[]);
let empty = fp(&[("", 0)]);
assert_ne!(none, empty);
}
}