1use sha2::{Digest, Sha256};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum IntegrityError {
29 DigestMismatch {
31 expected: String,
33 actual: String,
35 },
36 MalformedDigest(String),
38}
39
40impl std::fmt::Display for IntegrityError {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::DigestMismatch { expected, actual } => write!(
44 f,
45 "integrity check failed: expected sha256 {expected}, got {actual}. \
46 The file was NOT written. Either the source changed or the transfer \
47 was tampered with; do not retry without establishing which."
48 ),
49 Self::MalformedDigest(d) => write!(
50 f,
51 "malformed expected digest {d:?}: a sha256 is 64 hex characters"
52 ),
53 }
54 }
55}
56
57impl std::error::Error for IntegrityError {}
58
59#[must_use]
61pub fn sha256_hex(bytes: &[u8]) -> String {
62 let mut hasher = Sha256::new();
63 hasher.update(bytes);
64 hasher
65 .finalize()
66 .iter()
67 .fold(String::with_capacity(64), |mut s, b| {
68 use std::fmt::Write as _;
69 let _ = write!(s, "{b:02x}");
70 s
71 })
72}
73
74pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), IntegrityError> {
83 let actual = sha256_hex(bytes);
84 compare_hex_digests(&actual, expected_hex)
85}
86
87fn compare_hex_digests(actual_hex: &str, expected_hex: &str) -> Result<(), IntegrityError> {
93 let expected = expected_hex.trim().to_ascii_lowercase();
94 if expected.len() != 64 || !expected.bytes().all(|b| b.is_ascii_hexdigit()) {
95 return Err(IntegrityError::MalformedDigest(expected_hex.to_string()));
96 }
97
98 let diff = actual_hex
100 .bytes()
101 .zip(expected.bytes())
102 .fold(0u8, |acc, (a, b)| acc | (a ^ b));
103
104 if diff == 0 {
105 Ok(())
106 } else {
107 Err(IntegrityError::DigestMismatch {
108 expected,
109 actual: actual_hex.to_string(),
110 })
111 }
112}
113
114#[derive(Debug)]
119#[non_exhaustive]
120pub enum StreamIntegrityError {
121 Io(std::io::Error),
123 Integrity(IntegrityError),
125}
126
127impl std::fmt::Display for StreamIntegrityError {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 Self::Io(e) => write!(f, "could not read the verified file: {e}"),
131 Self::Integrity(e) => write!(f, "{e}"),
132 }
133 }
134}
135
136impl std::error::Error for StreamIntegrityError {
137 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
138 match self {
139 Self::Io(e) => Some(e),
140 Self::Integrity(e) => Some(e),
141 }
142 }
143}
144
145pub fn sha256_hex_of_reader<R: std::io::Read>(reader: &mut R) -> std::io::Result<String> {
150 let mut hasher = Sha256::new();
151 let mut buf = [0u8; 64 * 1024];
152 loop {
153 let n = std::io::Read::read(reader, &mut buf)?;
154 if n == 0 {
155 break;
156 }
157 hasher.update(&buf[..n]);
158 }
159 Ok(hasher
160 .finalize()
161 .iter()
162 .fold(String::with_capacity(64), |mut s, b| {
163 use std::fmt::Write as _;
164 let _ = write!(s, "{b:02x}");
165 s
166 }))
167}
168
169pub fn verify_reader_sha256<R: std::io::Read>(
175 reader: &mut R,
176 expected_hex: &str,
177) -> Result<(), StreamIntegrityError> {
178 let actual = sha256_hex_of_reader(reader).map_err(StreamIntegrityError::Io)?;
179 compare_hex_digests(&actual, expected_hex).map_err(StreamIntegrityError::Integrity)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
190 const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
191
192 #[test]
193 fn known_answer_vectors() {
194 assert_eq!(sha256_hex(b""), EMPTY);
195 assert_eq!(sha256_hex(b"abc"), ABC);
196 }
197
198 #[test]
199 fn accepts_matching_digest() {
200 assert_eq!(verify_sha256(b"abc", ABC), Ok(()));
201 }
202
203 #[test]
204 fn accepts_uppercase_and_padded_digest() {
205 assert_eq!(
206 verify_sha256(b"abc", &format!(" {} ", ABC.to_uppercase())),
207 Ok(())
208 );
209 }
210
211 #[test]
212 fn rejects_wrong_content() {
213 let err = verify_sha256(b"abd", ABC).expect_err("must reject");
215 match err {
216 IntegrityError::DigestMismatch { expected, actual } => {
217 assert_eq!(expected, ABC);
218 assert_ne!(actual, ABC);
219 }
220 other => panic!("wrong error: {other:?}"),
221 }
222 }
223
224 #[test]
225 fn rejects_single_flipped_bit() {
226 let mut a = vec![7u8; 4096];
228 let good = sha256_hex(&a);
229 a[2048] ^= 0x01;
230 assert!(verify_sha256(&a, &good).is_err());
231 }
232
233 #[test]
234 fn rejects_truncated_transfer() {
235 let full = vec![3u8; 8192];
236 let good = sha256_hex(&full);
237 assert!(verify_sha256(&full[..4096], &good).is_err());
238 }
239
240 #[test]
241 fn malformed_expectation_is_an_error_not_a_pass() {
242 for bad in ["", "abc", "zz", &"f".repeat(63), &"g".repeat(64)] {
243 assert!(
244 matches!(
245 verify_sha256(b"anything", bad),
246 Err(IntegrityError::MalformedDigest(_))
247 ),
248 "expected {bad:?} to be rejected as malformed"
249 );
250 }
251 }
252
253 #[test]
254 fn error_message_says_the_file_was_not_written() {
255 let msg = verify_sha256(b"abd", ABC).unwrap_err().to_string();
258 assert!(msg.contains("NOT written"), "message was: {msg}");
259 }
260
261 #[test]
262 fn streaming_hash_matches_the_in_memory_hash() {
263 let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
266 let mut slice: &[u8] = &bytes;
267 assert_eq!(
268 sha256_hex_of_reader(&mut slice).expect("a slice never fails a read"),
269 sha256_hex(&bytes)
270 );
271 }
272
273 #[test]
274 fn streaming_verify_accepts_and_rejects_like_verify() {
275 let mut good: &[u8] = b"abc";
276 assert_eq!(
277 verify_reader_sha256(&mut good, ABC).map_err(|e| e.to_string()),
278 Ok(())
279 );
280 let mut bad: &[u8] = b"abd";
281 let err = verify_reader_sha256(&mut bad, ABC).expect_err("must reject");
282 assert!(
283 matches!(
284 err,
285 StreamIntegrityError::Integrity(IntegrityError::DigestMismatch { .. })
286 ),
287 "wrong error: {err:?}"
288 );
289 let mut malformed: &[u8] = b"abc";
290 let err = verify_reader_sha256(&mut malformed, "not-a-digest").expect_err("must reject");
291 assert!(
292 matches!(
293 err,
294 StreamIntegrityError::Integrity(IntegrityError::MalformedDigest(_))
295 ),
296 "wrong error: {err:?}"
297 );
298 }
299
300 #[test]
301 fn streaming_verify_reports_a_read_failure_as_io() {
302 struct Failing;
303 impl std::io::Read for Failing {
304 fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
305 Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "boom"))
306 }
307 }
308 let err = verify_reader_sha256(&mut Failing, ABC).expect_err("must report the read");
309 assert!(
310 matches!(err, StreamIntegrityError::Io(_)),
311 "a failed read is Io, not a digest verdict: {err:?}"
312 );
313 }
314}