crates/nornir-hash/src/lib.rs
//! # nornir-hash
//!
//! The ONE shared content-hash primitive for the nordisk constellation.
//!
//! Six repos hand-rolled the exact same three lines — `let mut h = Sha256::new();
//! h.update(bytes); hex(h.finalize())` — plus a private `hex(&[u8]) -> String`
//! each spelled slightly differently (`format!("{b:02x}")`, `write!`, a lookup
//! table, `hex::encode`). This leaf owns that primitive ONCE so a content-addressed
//! store (nornir warehouse `blob_store`, holger retention + loose-file backends,
//! Skidbladnir `archive`, skade bench `aegir`, `nornir-guard`, build-thing
//! `provenance`) consumes it instead of twinning.
//!
//! ## What it owns
//! * [`sha256`] — raw 32-byte digest of a slice.
//! * [`sha256_hex`] — lowercase-hex SHA-256 of a slice (the workhorse).
//! * [`sha256_file`] — streaming lowercase-hex SHA-256 of a file on disk (64 KiB
//! chunks; never loads the whole file).
//! * [`Sha256Hasher`] — an incremental hasher for data that arrives in pieces.
//! * [`hex_encode`] / [`hex_decode`] — the lowercase-hex round-trip, dependency
//! free, so a caller that only needs the encoding does not pull anything.
//!
//! ## Dep-light (L5a)
//! The only dependency is `sha2`. Hex is hand-rolled — a third unrelated consumer
//! gets content hashing without dragging in a hex crate. No domain coupling, no
//! orchestration: a pure primitive.
//!
//! ```
//! use nornir_hash::{sha256_hex, hex_encode, hex_decode};
//! // The canonical empty-string SHA-256 vector.
//! assert_eq!(
//! sha256_hex(b""),
//! "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
//! );
//! assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
//! assert_eq!(hex_decode("deadbeef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
//! ```
use std::path::Path;
use sha2::{Digest, Sha256};
pub mod selftest;
/// Raw 32-byte SHA-256 digest of `bytes`.
///
/// The un-encoded form, for callers that compare digests directly or feed the
/// bytes onward. Use [`sha256_hex`] when you want the lowercase-hex string.
#[inline]
pub fn sha256(bytes: &[u8]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(bytes);
h.finalize().into()
}
/// Lowercase-hex SHA-256 of `bytes` — the content id a content-addressed store
/// keys on. 64 hex chars, always lowercase.
#[inline]
pub fn sha256_hex(bytes: &[u8]) -> String {
hex_encode(&sha256(bytes))
}
/// Streaming lowercase-hex SHA-256 of the file at `path`.
///
/// Reads in 64 KiB chunks and never loads the whole file into memory, so it is
/// safe on multi-gigabyte artifacts. Returns any I/O error from opening/reading.
pub fn sha256_file(path: impl AsRef<Path>) -> std::io::Result<String> {
use std::io::Read;
let path = path.as_ref();
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256Hasher::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize_hex())
}
/// An incremental SHA-256 hasher, for data that arrives in pieces (a file read
/// loop, a chunked network body, a growing buffer).
///
/// A thin wrapper over `sha2::Sha256` so consumers do not depend on `sha2`
/// directly nor re-invent the chunked read loop. Feeding it the same total bytes
/// as [`sha256_hex`] yields the same digest.
///
/// ```
/// use nornir_hash::{Sha256Hasher, sha256_hex};
/// let mut h = Sha256Hasher::new();
/// h.update(b"ab");
/// h.update(b"c");
/// assert_eq!(h.finalize_hex(), sha256_hex(b"abc"));
/// ```
#[derive(Clone, Default)]
pub struct Sha256Hasher {
inner: Sha256,
}
impl Sha256Hasher {
/// A fresh hasher with no bytes fed in yet.
#[inline]
pub fn new() -> Self {
Self { inner: Sha256::new() }
}
/// Feed the next chunk of input. Call as many times as needed.
#[inline]
pub fn update(&mut self, bytes: &[u8]) {
self.inner.update(bytes);
}
/// Consume the hasher and return the raw 32-byte digest.
#[inline]
pub fn finalize(self) -> [u8; 32] {
self.inner.finalize().into()
}
/// Consume the hasher and return the lowercase-hex digest (64 chars).
#[inline]
pub fn finalize_hex(self) -> String {
hex_encode(&self.finalize())
}
}
/// Lowercase-hex encoding of `bytes`. Two chars per byte, always lowercase, no
/// separators. The inverse of [`hex_decode`].
pub fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
/// Error from [`hex_decode`]: the input was not valid lowercase-or-uppercase hex.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HexError {
/// The input had an odd number of characters (hex is two chars per byte).
OddLength(usize),
/// A non-hex character at the given byte offset.
NonHexChar(usize),
}
impl std::fmt::Display for HexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HexError::OddLength(n) => write!(f, "hex string has odd length {n}"),
HexError::NonHexChar(i) => write!(f, "non-hex character at offset {i}"),
}
}
}
impl std::error::Error for HexError {}
/// Decode a hex string (either case) back to bytes. The inverse of
/// [`hex_encode`]. Accepts uppercase too, so it round-trips any well-formed hex.
pub fn hex_decode(s: &str) -> Result<Vec<u8>, HexError> {
let s = s.as_bytes();
if !s.len().is_multiple_of(2) {
return Err(HexError::OddLength(s.len()));
}
let nibble = |c: u8, i: usize| -> Result<u8, HexError> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(HexError::NonHexChar(i)),
}
};
let mut out = Vec::with_capacity(s.len() / 2);
for (i, pair) in s.as_chunks::<2>().0.iter().enumerate() {
let hi = nibble(pair[0], i * 2)?;
let lo = nibble(pair[1], i * 2 + 1)?;
out.push((hi << 4) | lo);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
// Canonical NIST/FIPS-180 SHA-256 test vectors — RED if the wiring rots.
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
#[test]
fn known_sha256_vectors() {
assert_eq!(sha256_hex(b""), EMPTY);
assert_eq!(sha256_hex(b"abc"), ABC);
// The digest length is always 32 bytes / 64 hex chars.
assert_eq!(sha256(b"anything").len(), 32);
assert_eq!(sha256_hex(b"anything").len(), 64);
// Lowercase only.
assert!(sha256_hex(b"abc").chars().all(|c| !c.is_ascii_uppercase()));
}
#[test]
fn streaming_matches_oneshot() {
let mut h = Sha256Hasher::new();
h.update(b"a");
h.update(b"b");
h.update(b"c");
assert_eq!(h.finalize_hex(), ABC);
// An empty stream is the empty-input vector.
assert_eq!(Sha256Hasher::new().finalize_hex(), EMPTY);
}
#[test]
fn hex_round_trip() {
assert_eq!(hex_encode(&[0x00, 0x0f, 0xff, 0xde, 0xad]), "000fffdead");
assert_eq!(hex_encode(&[]), "");
let bytes: Vec<u8> = (0u16..=255).map(|b| b as u8).collect();
assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
// Uppercase decodes too.
assert_eq!(hex_decode("DEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn hex_decode_rejects_bad_input() {
assert_eq!(hex_decode("abc"), Err(HexError::OddLength(3)));
assert_eq!(hex_decode("zz"), Err(HexError::NonHexChar(0)));
assert_eq!(hex_decode("aag"), Err(HexError::OddLength(3)));
assert_eq!(hex_decode("agag"), Err(HexError::NonHexChar(1)));
}
#[test]
fn sha256_file_matches_in_memory() {
let dir = std::env::temp_dir();
let path = dir.join(format!("nornir-hash-selftest-{}.bin", std::process::id()));
let payload = b"the quick brown fox jumps over the lazy dog";
std::fs::write(&path, payload).unwrap();
let from_file = sha256_file(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(from_file, sha256_hex(payload));
}
}