use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntegrityError {
DigestMismatch {
expected: String,
actual: String,
},
MalformedDigest(String),
}
impl std::fmt::Display for IntegrityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DigestMismatch { expected, actual } => write!(
f,
"integrity check failed: expected sha256 {expected}, got {actual}. \
The file was NOT written. Either the source changed or the transfer \
was tampered with; do not retry without establishing which."
),
Self::MalformedDigest(d) => write!(
f,
"malformed expected digest {d:?}: a sha256 is 64 hex characters"
),
}
}
}
impl std::error::Error for IntegrityError {}
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
s
})
}
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), IntegrityError> {
let actual = sha256_hex(bytes);
compare_hex_digests(&actual, expected_hex)
}
fn compare_hex_digests(actual_hex: &str, expected_hex: &str) -> Result<(), IntegrityError> {
let expected = expected_hex.trim().to_ascii_lowercase();
if expected.len() != 64 || !expected.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(IntegrityError::MalformedDigest(expected_hex.to_string()));
}
let diff = actual_hex
.bytes()
.zip(expected.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b));
if diff == 0 {
Ok(())
} else {
Err(IntegrityError::DigestMismatch {
expected,
actual: actual_hex.to_string(),
})
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum StreamIntegrityError {
Io(std::io::Error),
Integrity(IntegrityError),
}
impl std::fmt::Display for StreamIntegrityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "could not read the verified file: {e}"),
Self::Integrity(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for StreamIntegrityError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Integrity(e) => Some(e),
}
}
}
pub fn sha256_hex_of_reader<R: std::io::Read>(reader: &mut R) -> std::io::Result<String> {
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = std::io::Read::read(reader, &mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher
.finalize()
.iter()
.fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
s
}))
}
pub fn verify_reader_sha256<R: std::io::Read>(
reader: &mut R,
expected_hex: &str,
) -> Result<(), StreamIntegrityError> {
let actual = sha256_hex_of_reader(reader).map_err(StreamIntegrityError::Io)?;
compare_hex_digests(&actual, expected_hex).map_err(StreamIntegrityError::Integrity)
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
#[test]
fn known_answer_vectors() {
assert_eq!(sha256_hex(b""), EMPTY);
assert_eq!(sha256_hex(b"abc"), ABC);
}
#[test]
fn accepts_matching_digest() {
assert_eq!(verify_sha256(b"abc", ABC), Ok(()));
}
#[test]
fn accepts_uppercase_and_padded_digest() {
assert_eq!(
verify_sha256(b"abc", &format!(" {} ", ABC.to_uppercase())),
Ok(())
);
}
#[test]
fn rejects_wrong_content() {
let err = verify_sha256(b"abd", ABC).expect_err("must reject");
match err {
IntegrityError::DigestMismatch { expected, actual } => {
assert_eq!(expected, ABC);
assert_ne!(actual, ABC);
}
other => panic!("wrong error: {other:?}"),
}
}
#[test]
fn rejects_single_flipped_bit() {
let mut a = vec![7u8; 4096];
let good = sha256_hex(&a);
a[2048] ^= 0x01;
assert!(verify_sha256(&a, &good).is_err());
}
#[test]
fn rejects_truncated_transfer() {
let full = vec![3u8; 8192];
let good = sha256_hex(&full);
assert!(verify_sha256(&full[..4096], &good).is_err());
}
#[test]
fn malformed_expectation_is_an_error_not_a_pass() {
for bad in ["", "abc", "zz", &"f".repeat(63), &"g".repeat(64)] {
assert!(
matches!(
verify_sha256(b"anything", bad),
Err(IntegrityError::MalformedDigest(_))
),
"expected {bad:?} to be rejected as malformed"
);
}
}
#[test]
fn error_message_says_the_file_was_not_written() {
let msg = verify_sha256(b"abd", ABC).unwrap_err().to_string();
assert!(msg.contains("NOT written"), "message was: {msg}");
}
#[test]
fn streaming_hash_matches_the_in_memory_hash() {
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let mut slice: &[u8] = &bytes;
assert_eq!(
sha256_hex_of_reader(&mut slice).expect("a slice never fails a read"),
sha256_hex(&bytes)
);
}
#[test]
fn streaming_verify_accepts_and_rejects_like_verify() {
let mut good: &[u8] = b"abc";
assert_eq!(
verify_reader_sha256(&mut good, ABC).map_err(|e| e.to_string()),
Ok(())
);
let mut bad: &[u8] = b"abd";
let err = verify_reader_sha256(&mut bad, ABC).expect_err("must reject");
assert!(
matches!(
err,
StreamIntegrityError::Integrity(IntegrityError::DigestMismatch { .. })
),
"wrong error: {err:?}"
);
let mut malformed: &[u8] = b"abc";
let err = verify_reader_sha256(&mut malformed, "not-a-digest").expect_err("must reject");
assert!(
matches!(
err,
StreamIntegrityError::Integrity(IntegrityError::MalformedDigest(_))
),
"wrong error: {err:?}"
);
}
#[test]
fn streaming_verify_reports_a_read_failure_as_io() {
struct Failing;
impl std::io::Read for Failing {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "boom"))
}
}
let err = verify_reader_sha256(&mut Failing, ABC).expect_err("must report the read");
assert!(
matches!(err, StreamIntegrityError::Io(_)),
"a failed read is Io, not a digest verdict: {err:?}"
);
}
}