update-bundle/src/digest.rs
//! Content digests. Every bundle member carries **both** a SHA-256 and a BLAKE3
//! digest; verification recomputes both over the exact stored bytes.
//!
//! Self-contained (`sha2` + `blake3`) — the bundle's digests are a closed domain
//! (they are compared only against other digests stored in the same bundle), so
//! this crate does not couple to the wider workspace hash primitive.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Lowercase hex encoding of a byte slice.
pub fn hex_lower(bytes: &[u8]) -> String {
const LUT: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(LUT[(b >> 4) as usize] as char);
out.push(LUT[(b & 0x0f) as usize] as char);
}
out
}
/// Decode a lowercase/uppercase hex string to bytes. Returns `None` on any
/// non-hex character or odd length.
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
let s = s.as_bytes();
if !s.len().is_multiple_of(2) {
return None;
}
fn nib(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
let mut out = Vec::with_capacity(s.len() / 2);
for pair in s.chunks(2) {
out.push((nib(pair[0])? << 4) | nib(pair[1])?);
}
Some(out)
}
/// SHA-256 of `bytes` as 64 lowercase hex characters.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex_lower(&Sha256::digest(bytes))
}
/// BLAKE3 of `bytes` as 64 lowercase hex characters.
pub fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
/// The dual digest + size recorded for every payload and evidence member.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Digests {
pub size_bytes: u64,
pub sha256: String,
pub blake3: String,
}
impl Digests {
/// Compute the dual digest of `bytes`.
pub fn of(bytes: &[u8]) -> Self {
Digests {
size_bytes: bytes.len() as u64,
sha256: sha256_hex(bytes),
blake3: blake3_hex(bytes),
}
}
/// [`Digests::of`] for every member at once, in input order (stinky 30). Each
/// member's SHA-256 and its BLAKE3 are SEPARATE units on one gatling fan-out,
/// so a single large payload costs the slower of the two hashes rather than
/// their sum, and many members hash on every core. Bit-identical to calling
/// [`Digests::of`] on each.
pub fn of_many(members: &[&[u8]]) -> Vec<Digests> {
let hex = gatling::gatling_forkjoin::gatling_for_each(members.len() * 2, 0, |u| {
let bytes = members[u / 2];
if u % 2 == 0 {
sha256_hex(bytes)
} else {
blake3_hex(bytes)
}
});
let mut it = hex.into_iter();
members
.iter()
.map(|m| Digests {
size_bytes: m.len() as u64,
sha256: it.next().unwrap_or_default(),
blake3: it.next().unwrap_or_default(),
})
.collect()
}
/// True when both digests and the size match `bytes` exactly.
pub fn matches(&self, bytes: &[u8]) -> bool {
self.size_bytes == bytes.len() as u64
&& self.sha256 == sha256_hex(bytes)
&& self.blake3 == blake3_hex(bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_vectors() {
// SHA-256("") and BLAKE3("") well-known digests.
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
blake3_hex(b""),
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
}
#[test]
fn digests_roundtrip_and_detect_change() {
let d = Digests::of(b"firmware-bytes");
assert!(d.matches(b"firmware-bytes"));
assert!(!d.matches(b"firmware-byteS"));
assert!(!d.matches(b"firmware-bytes-extra"));
}
}