Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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));
    }
}