use sha2::{Digest, Sha256};
const SCRATCH_CAPACITY: usize = 8 * 1024;
pub struct StreamingHasher {
hasher: Sha256,
scratch: Vec<u8>,
}
impl StreamingHasher {
pub fn new(domain: &[u8]) -> Self {
let mut hasher = Self {
hasher: Sha256::new(),
scratch: Vec::with_capacity(SCRATCH_CAPACITY),
};
hasher.hasher.update(domain);
hasher
}
fn push_bytes(&mut self, bytes: &[u8]) {
if bytes.len() >= SCRATCH_CAPACITY {
self.flush();
self.hasher.update(bytes);
return;
}
if self.scratch.len() + bytes.len() > SCRATCH_CAPACITY {
self.flush();
}
self.scratch.extend_from_slice(bytes);
}
fn flush(&mut self) {
if !self.scratch.is_empty() {
self.hasher.update(&self.scratch);
self.scratch.clear();
}
}
pub fn absorb_u64(&mut self, value: u64) {
self.push_bytes(&value.to_le_bytes());
}
pub fn absorb_len(&mut self, len: usize) {
let value = u64::try_from(len)
.expect("content-hash field length exceeds u64 (impossible on supported targets)");
self.absorb_u64(value);
}
pub fn absorb_str(&mut self, value: &str) {
let bytes = value.as_bytes();
self.absorb_len(bytes.len());
self.push_bytes(bytes);
}
pub fn absorb_str_collection<'a, I>(&mut self, items: I)
where
I: IntoIterator<Item = &'a str>,
I::IntoIter: ExactSizeIterator,
{
let iter = items.into_iter();
self.absorb_len(iter.len());
for item in iter {
self.absorb_str(item);
}
}
pub fn absorb_cell(&mut self, cell: Option<f64>) {
let mut framed = [0u8; 9];
if let Some(value) = cell {
framed[0] = 1;
framed[1..].copy_from_slice(&value.to_le_bytes());
}
self.push_bytes(&framed);
}
pub fn absorb_raw(&mut self, bytes: &[u8]) {
self.push_bytes(bytes);
}
pub fn finalize_hex(mut self) -> String {
self.flush();
let digest = self.hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write;
write!(&mut out, "{byte:02x}").expect("writing to string cannot fail");
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finalize_is_64_lowercase_hex() {
let hex = StreamingHasher::new(b"domain\0").finalize_hex();
assert_eq!(hex.len(), 64);
assert!(hex
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
}
#[test]
fn streaming_matches_a_single_update_over_the_same_bytes() {
let mut expected = Vec::new();
expected.extend_from_slice(b"domain\0");
expected.extend_from_slice(&3u64.to_le_bytes()); expected.extend_from_slice(b"abc");
expected.extend_from_slice(&42u64.to_le_bytes());
expected.push(1);
expected.extend_from_slice(&1.5f64.to_le_bytes());
expected.push(0);
expected.extend_from_slice(&0.0f64.to_le_bytes());
let expected_hex = {
let digest = Sha256::digest(&expected);
let mut out = String::new();
for byte in digest {
use std::fmt::Write;
write!(&mut out, "{byte:02x}").unwrap();
}
out
};
let mut hasher = StreamingHasher::new(b"domain\0");
hasher.absorb_str("abc");
hasher.absorb_u64(42);
hasher.absorb_cell(Some(1.5));
hasher.absorb_cell(None);
assert_eq!(hasher.finalize_hex(), expected_hex);
}
#[test]
fn large_absorb_bypasses_scratch_without_changing_digest() {
let big = vec![0xABu8; SCRATCH_CAPACITY * 2 + 7];
let mut one = StreamingHasher::new(b"d");
one.absorb_raw(&big);
let mut two = StreamingHasher::new(b"d");
two.absorb_raw(&big[..5]);
two.absorb_raw(&big[5..]);
assert_eq!(one.finalize_hex(), two.finalize_hex());
}
#[test]
fn masked_cell_differs_from_zero_cell() {
let mut masked = StreamingHasher::new(b"d");
masked.absorb_cell(None);
let mut zero = StreamingHasher::new(b"d");
zero.absorb_cell(Some(0.0));
assert_ne!(masked.finalize_hex(), zero.finalize_hex());
}
#[test]
fn string_framing_prevents_concatenation_collision() {
let mut ab_c = StreamingHasher::new(b"d");
ab_c.absorb_str("ab");
ab_c.absorb_str("c");
let mut a_bc = StreamingHasher::new(b"d");
a_bc.absorb_str("a");
a_bc.absorb_str("bc");
assert_ne!(ab_c.finalize_hex(), a_bc.finalize_hex());
}
#[test]
fn collection_count_prefix_prevents_boundary_collision() {
let mut split = StreamingHasher::new(b"d");
split.absorb_str_collection(["a"]);
split.absorb_str_collection(["b"]);
let mut merged = StreamingHasher::new(b"d");
merged.absorb_str_collection(["a", "b"]);
merged.absorb_str_collection(std::iter::empty::<&str>());
assert_ne!(split.finalize_hex(), merged.finalize_hex());
}
#[test]
fn collection_order_changes_digest() {
let mut forward = StreamingHasher::new(b"d");
forward.absorb_str_collection(["a", "b"]);
let mut reversed = StreamingHasher::new(b"d");
reversed.absorb_str_collection(["b", "a"]);
assert_ne!(forward.finalize_hex(), reversed.finalize_hex());
}
}