Skip to main content

cabin_core/
hash.rs

1//! Shared hash-encoding helpers used across the workspace.
2
3use std::fmt::Write as _;
4use std::io::{Read, Write};
5
6use sha2::{Digest, Sha256};
7
8/// Lower-case hex encoding of a digest (or any byte slice).
9pub fn hex_digest(digest: &[u8]) -> String {
10    let mut hex = String::with_capacity(2 * digest.len());
11    for byte in digest {
12        let _ = write!(hex, "{byte:02x}");
13    }
14    hex
15}
16
17/// Stream `reader` through SHA-256 in 64 KiB chunks and return the
18/// lower-case hex digest.  This is the shared primitive behind every
19/// Cabin file / archive integrity check; callers own opening the
20/// file and mapping any [`std::io::Error`] into their crate's own
21/// error type (and re-attaching path context).
22///
23/// # Errors
24/// Returns the [`std::io::Error`] propagated from reading `reader`.
25pub fn hash_reader<R: Read>(mut reader: R) -> std::io::Result<String> {
26    let mut hasher = Sha256::new();
27    let mut buf = vec![0u8; 64 * 1024];
28    loop {
29        let n = reader.read(&mut buf)?;
30        if n == 0 {
31            break;
32        }
33        hasher.update(&buf[..n]);
34    }
35    Ok(hex_digest(&hasher.finalize()))
36}
37
38/// Stream `reader` into `writer` in 64 KiB chunks, hashing the bytes
39/// with SHA-256 as they pass through, and return the lower-case hex
40/// digest.  This is the shared primitive behind Cabin's
41/// stream-to-temp-and-verify archive staging (download, local copy,
42/// vendor): the bytes are written exactly once while the digest is
43/// computed in the same pass, so a torn copy surfaces as a checksum
44/// mismatch rather than a silently bad archive.
45///
46/// Like [`hash_reader`], callers own opening and creating the handles
47/// and mapping any [`std::io::Error`] into their crate's own error
48/// type with path context.
49///
50/// # Errors
51/// Returns the first [`std::io::Error`] encountered while reading
52/// `reader` or writing `writer`.
53pub fn hash_copy<R: Read, W: Write>(mut reader: R, mut writer: W) -> std::io::Result<String> {
54    let mut hasher = Sha256::new();
55    let mut buf = vec![0u8; 64 * 1024];
56    loop {
57        let n = reader.read(&mut buf)?;
58        if n == 0 {
59            break;
60        }
61        hasher.update(&buf[..n]);
62        writer.write_all(&buf[..n])?;
63    }
64    Ok(hex_digest(&hasher.finalize()))
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use std::io::Cursor;
71
72    /// SHA-256 of the empty input, a fixed reference value.
73    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
74
75    #[test]
76    fn hex_digest_encodes_lower_case_hex() {
77        assert_eq!(hex_digest(&[]), "");
78        assert_eq!(hex_digest(&[0x00]), "00");
79        assert_eq!(hex_digest(&[0x00, 0x0f, 0xa5, 0xff]), "000fa5ff");
80    }
81
82    #[test]
83    fn hash_reader_matches_known_sha256_vectors() {
84        assert_eq!(hash_reader(Cursor::new(b"")).unwrap(), EMPTY_SHA256);
85        assert_eq!(
86            hash_reader(Cursor::new(b"abc")).unwrap(),
87            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
88        );
89    }
90
91    #[test]
92    fn hash_reader_streams_input_larger_than_one_chunk() {
93        // Larger than the 64 KiB streaming buffer, and not a multiple
94        // of it, so the loop runs several times with a short tail.
95        let data = vec![0xabu8; 3 * 64 * 1024 + 7];
96        let expected = hex_digest(&Sha256::digest(&data));
97        assert_eq!(hash_reader(Cursor::new(&data)).unwrap(), expected);
98    }
99
100    #[test]
101    fn hash_reader_propagates_read_errors() {
102        struct FailingReader;
103        impl Read for FailingReader {
104            fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
105                Err(std::io::Error::other("read failed"))
106            }
107        }
108        let err = hash_reader(FailingReader).unwrap_err();
109        assert_eq!(err.to_string(), "read failed");
110    }
111
112    #[test]
113    fn hash_copy_writes_bytes_verbatim_and_returns_matching_digest() {
114        let data = vec![0x5au8; 64 * 1024 + 3];
115        let mut copied = Vec::new();
116        let digest = hash_copy(Cursor::new(&data), &mut copied).unwrap();
117        assert_eq!(copied, data);
118        assert_eq!(digest, hash_reader(Cursor::new(&data)).unwrap());
119    }
120
121    #[test]
122    fn hash_copy_of_empty_input_writes_nothing() {
123        let mut copied = Vec::new();
124        let digest = hash_copy(Cursor::new(b""), &mut copied).unwrap();
125        assert!(copied.is_empty());
126        assert_eq!(digest, EMPTY_SHA256);
127    }
128
129    #[test]
130    fn hash_copy_propagates_write_errors() {
131        struct FailingWriter;
132        impl Write for FailingWriter {
133            fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
134                Err(std::io::Error::other("write failed"))
135            }
136            fn flush(&mut self) -> std::io::Result<()> {
137                Ok(())
138            }
139        }
140        let err = hash_copy(Cursor::new(b"payload"), FailingWriter).unwrap_err();
141        assert_eq!(err.to_string(), "write failed");
142    }
143}