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 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 actual = sha256_hex(bytes);
let diff = actual
.bytes()
.zip(expected.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b));
if diff == 0 {
Ok(())
} else {
Err(IntegrityError::DigestMismatch { expected, actual })
}
}
#[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}");
}
}