shepherd-core 6.6.1

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
/*
    Appellation: digest <module>
    Created At: 2026.08.29:00:00:00
    Contrib: @FL03
*/
//! SHA-256 and lowercase-hex formatting, in one place.
//!
//! ## Why this module exists
//!
//! An audit of the workspace found this one small family of helpers written
//! independently **fourteen times** across `shepherd-cli`, `shepherd-render`
//! and `shepherd-registry`: `sha256`, `sha256_hex`, `sha256_file`,
//! `sha256_path`, `sha256_bytes`, `hex_digest` and `format_digest`. Every copy
//! produced the same bytes by a different route -- a `fold` with `write!`, a
//! `map(|byte| format!(...)).collect()`, a hand-rolled nibble table -- and each
//! one was a separate place a future edit could change the hex casing, the
//! zero-padding, or the buffered read and silently move a digest.
//!
//! That matters more here than the usual duplication argument. These digests
//! are not diagnostics: they are pinned in receipts, render manifests, eval
//! oracles and the registry's singleton fingerprint. One byte of drift in the
//! *formatting* invalidates artifacts that were never re-rendered.
//!
//! This file is the single home `scripts/check-rust-duplicates.py` asserts for
//! that family. Adding a second definition of any of those names anywhere in
//! the workspace fails that gate.
//!
//! ## Why three functions and not seven
//!
//! The fourteen sites used seven names for **three** distinct behaviours, and
//! two of the names were actively dishonest -- `hex_digest` hashed its input in
//! `crates/cli/src/cmd/planning.rs` and `crates/cli/src/cmd/transport_bind.rs`,
//! and did **not** hash it in `crates/cli/src/orientation.rs` and
//! `crates/registry/src/registry.rs`, where it only hex-encoded an
//! already-computed digest. A shared `hex_digest` would have had to pick one
//! meaning and quietly change the other's output, so the name is retired
//! rather than moved:
//!
//! | Behaviour | Lives here as | Replaces |
//! |---|---|---|
//! | hash bytes, keep the digest | [`sha256`] | `dispatch_service.rs::sha256` |
//! | hash bytes, format as hex | [`sha256_hex`] | `sha256`, `sha256_bytes`, the hashing `hex_digest` |
//! | format existing bytes as hex | [`format_digest`] | the non-hashing `hex_digest`, `dispatch_service.rs::hex` |
//! | hash a file's contents | [`sha256_path`] | `sha256_path`, `sha256_file` |
//!
//! Hex is written per byte with `{byte:02x}` rather than `{:x}` on the digest
//! itself: `sha2` 0.11's output is a `hybrid-array` `Array`, which does not
//! implement `LowerHex` the way `generic_array` did on the 0.10 line, so the
//! whole-digest form does not compile. That constraint was rediscovered in at
//! least two of the copies this module replaces; it is recorded once here.
use alloc::string::String;
use core::fmt::Write as _;

use sha2::{Digest, Sha256};

/// The width of a SHA-256 digest in bytes.
pub const SHA256_LEN: usize = 32;

/// The SHA-256 digest of `bytes`, unformatted.
///
/// Use this when the digest is compared or stored as bytes; reach for
/// [`sha256_hex`] when it is recorded as text.
pub fn sha256(bytes: &[u8]) -> [u8; SHA256_LEN] {
    Sha256::digest(bytes).into()
}

/// The SHA-256 digest of `bytes` as a 64-character lowercase-hex string.
///
/// This is the form every shepherd artifact records: a receipt's
/// `content_sha256`, a render manifest's digest triad, the registry's
/// singleton fingerprint. Lowercase is load-bearing -- several call sites
/// validate a recorded digest with `is_ascii_hexdigit() && !is_ascii_uppercase()`
/// and would reject uppercase output.
pub fn sha256_hex(bytes: &[u8]) -> String {
    format_digest(sha256(bytes))
}

/// Format already-computed digest bytes as lowercase hex.
///
/// Takes an iterator rather than a slice so the output of
/// `Sha256::finalize()` can be formatted by value without an intermediate
/// borrow, which is how the incremental hashers in
/// `crates/registry/src/registry.rs` and `crates/cli/src/dispatch_service.rs`
/// use it.
pub fn format_digest(bytes: impl IntoIterator<Item = u8>) -> String {
    let mut output = String::with_capacity(SHA256_LEN * 2);
    for byte in bytes {
        write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail");
    }
    output
}

/// The SHA-256 digest of the file at `path`, as lowercase hex.
///
/// The file is streamed in fixed-size chunks rather than read whole, so a
/// caller cannot turn a hash into an unbounded allocation by pointing this at
/// a large file.
///
/// Returns [`std::io::Error`] rather than [`crate::Error`] on purpose. Every
/// caller wraps the failure in its own vocabulary -- `LayoutError::Io { path,
/// source }` keeps the `io::Error` as a structured source, and the CLI's
/// harness-evidence path renders it into a sentence -- and neither can
/// reconstruct the source from a flattened string. A leaf helper that erases
/// the error it was handed forces every caller to lose information.
#[cfg(feature = "std")]
pub fn sha256_path(path: &std::path::Path) -> std::io::Result<String> {
    use std::io::Read as _;

    let mut file = std::fs::File::open(path)?;
    let mut digest = Sha256::new();
    let mut buffer = [0_u8; 8192];
    loop {
        let count = file.read(&mut buffer)?;
        if count == 0 {
            break;
        }
        digest.update(&buffer[..count]);
    }
    Ok(format_digest(digest.finalize()))
}

#[cfg(test)]
mod tests {
    use super::{format_digest, sha256, sha256_hex};
    use alloc::string::String;

    /// FIPS 180-4 / NIST published vectors. These are the anchor: every other
    /// assertion in this module compares against a value this crate computed,
    /// so without a known answer the whole file could agree with itself while
    /// being wrong.
    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
    const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";

    #[test]
    fn known_answer_vectors_hold() {
        assert_eq!(sha256_hex(b""), EMPTY_SHA256);
        assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
    }

    #[test]
    fn hex_form_is_lowercase_and_zero_padded() {
        // 0x0f must render as "0f", not "f": a digest that drops leading zeros
        // is shorter than 64 characters and fails every `len() == 64` check in
        // the workspace.
        assert_eq!(format_digest([0x00, 0x0f, 0xa0, 0xff]), "000fa0ff");
        assert_eq!(sha256_hex(b"abc").len(), 64);
        assert!(
            sha256_hex(b"abc")
                .bytes()
                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
        );
    }

    #[test]
    fn sha256_and_sha256_hex_are_the_same_digest() {
        assert_eq!(format_digest(sha256(b"abc")), sha256_hex(b"abc"));
        assert_eq!(sha256(b"abc").len(), super::SHA256_LEN);
    }

    /// The three hex encoders this module replaced, reproduced verbatim from
    /// the call sites they were deleted from. If [`format_digest`] ever stops
    /// agreeing with all three, a pinned manifest somewhere in the repo has
    /// silently moved.
    #[test]
    fn formatting_matches_every_encoder_it_replaced() {
        // crates/cli/src/cmd/planning.rs and crates/render/src/manifest.rs
        fn fold_with_write(bytes: &[u8]) -> String {
            use core::fmt::Write;

            bytes.iter().fold(String::new(), |mut output, byte| {
                write!(&mut output, "{byte:02x}").expect("writing a String cannot fail");
                output
            })
        }

        // crates/cli/src/orientation.rs, transport_bind.rs, eval_manifest.rs
        fn map_and_collect(bytes: &[u8]) -> String {
            use alloc::format;

            bytes.iter().map(|byte| format!("{byte:02x}")).collect()
        }

        // crates/cli/src/cmd/harness_evidence.rs
        fn nibble_table(bytes: &[u8]) -> String {
            const HEX: &[u8; 16] = b"0123456789abcdef";

            let mut output = String::with_capacity(bytes.len() * 2);
            for byte in bytes {
                output.push(HEX[usize::from(byte >> 4)] as char);
                output.push(HEX[usize::from(byte & 0x0f)] as char);
            }
            output
        }

        for input in [b"".as_slice(), b"abc", b"\x00\xff\x0f\xa0", b"shepherd"] {
            let digest = sha256(input);
            let expected = format_digest(digest);
            assert_eq!(expected, fold_with_write(&digest));
            assert_eq!(expected, map_and_collect(&digest));
            assert_eq!(expected, nibble_table(&digest));
            assert_eq!(expected, sha256_hex(input));
        }
    }

    #[cfg(feature = "std")]
    #[test]
    fn sha256_path_streams_the_same_digest_as_sha256_hex() {
        use super::sha256_path;
        use std::sync::atomic::{AtomicU64, Ordering};
        use std::time::{SystemTime, UNIX_EPOCH};

        // One unique scratch path per case: `cargo test` runs these threads in
        // parallel, and a shared fixture name makes them fight over one file.
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|elapsed| elapsed.as_nanos())
            .unwrap_or(0);

        // The third case crosses the 8192-byte read buffer, which is the only
        // place the streaming loop can differ from a whole-file read.
        let long = b"shepherd"
            .iter()
            .copied()
            .cycle()
            .take(20_000)
            .collect::<Vec<u8>>();
        for contents in [Vec::new(), b"abc".to_vec(), long] {
            let path = std::env::temp_dir().join(format!(
                "shepherd-core-digest-{stamp}-{}.bin",
                COUNTER.fetch_add(1, Ordering::Relaxed)
            ));
            std::fs::write(&path, &contents).expect("write scratch fixture");
            let streamed = sha256_path(&path).expect("hash scratch fixture");
            let _ = std::fs::remove_file(&path);
            assert_eq!(streamed, sha256_hex(&contents));
        }
    }

    #[cfg(feature = "std")]
    #[test]
    fn sha256_path_surfaces_the_io_error_it_was_handed() {
        use super::sha256_path;

        // The caller needs `ErrorKind`, not a string: `LayoutError::Io` keeps
        // the source, and a missing file must stay distinguishable from a
        // permission failure.
        let missing = std::env::temp_dir().join("shepherd-core-digest-does-not-exist.bin");
        let error = sha256_path(&missing).expect_err("a missing file cannot be hashed");
        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
    }
}