Skip to main content

pdf_ocr/
integrity.rs

1// Copyright (c) 2026 Innovation Trigger B.V.
2//
3// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
4// the PDFluent Commercial Licence. See the LICENSE file in this repository --
5// that file travels with the copy you received, which a URL does not.
6
7//! Integrity checking for files fetched over the network.
8//!
9//! # Why this module exists
10//!
11//! The PaddleOCR backend used to fetch up to ~84 MB of ONNX model weights from a
12//! third-party public repository and write them straight to the cache, with no
13//! integrity check of any kind. Whoever served those bytes decided what our
14//! inference engine executed. For an SDK sold on privacy and on not phoning home,
15//! that is the wrong default — and a warning in the README is not a fix, it is a
16//! note explaining why the product is wrong.
17//!
18//! So fetching is now opt-in and always verified, and this module is the
19//! verification. It is compiled unconditionally, without the `paddle` feature,
20//! for one specific reason: tests behind a non-default feature flag do not run in
21//! CI, and a security check whose tests never execute is indistinguishable from no
22//! check at all. That exact mistake has cost this project three regressions.
23
24use sha2::{Digest, Sha256};
25
26/// Why a downloaded file was rejected.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum IntegrityError {
29    /// The digest of the received bytes does not match what was expected.
30    DigestMismatch {
31        /// What the caller said the file should hash to.
32        expected: String,
33        /// What it actually hashed to.
34        actual: String,
35    },
36    /// The expected digest is not a 64-character hex SHA-256.
37    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/// Hex-encoded SHA-256 of `bytes`.
60#[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
74/// Check `bytes` against an expected hex SHA-256.
75///
76/// A malformed expected digest is an error rather than a skipped check: silently
77/// accepting anything because the *expectation* was unreadable is precisely the
78/// failure this module exists to prevent.
79///
80/// Comparison is case-insensitive on the hex, and constant-time over the digest so
81/// that verification does not leak the expected value byte by byte.
82pub 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
87/// Compare a computed hex digest against the expectation.
88///
89/// The single comparison behind [`verify_sha256`] and
90/// [`verify_reader_sha256`]: one normalisation, one constant-time fold, so the
91/// streaming path cannot drift from the in-memory one.
92fn 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    // Constant-time: fold every byte rather than returning on the first mismatch.
99    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/// Why a streamed verification failed: the read itself, or the digest.
115///
116/// Non-exhaustive: future verifications may fail in new ways, and callers
117/// match with a wildcard arm.
118#[derive(Debug)]
119#[non_exhaustive]
120pub enum StreamIntegrityError {
121    /// Reading the stream failed before the digest was complete.
122    Io(std::io::Error),
123    /// The stream read fine but did not match the pinned digest.
124    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
145/// Hex-encoded SHA-256 of a stream, read 64 KiB at a time.
146///
147/// For files too large to hold in memory -- a 300 MB model weight is hashed in
148/// place rather than read into a `Vec` first, so the bytes are never held twice.
149pub 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
169/// Check a stream against an expected hex SHA-256 without holding it in memory.
170///
171/// The streaming twin of [`verify_sha256`], over the same comparison: the
172/// shared ONNX Runtime layer verifies multi-hundred-megabyte model files with
173/// this before loading them.
174pub 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    // The canonical SHA-256 of the empty input and of "abc", from FIPS 180-4.
187    // Hardcoding known-answer vectors means a broken hasher fails here rather
188    // than producing self-consistent nonsense that agrees with itself.
189    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        // The scenario that matters: the URL is right, the bytes are not.
214        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        // 84 MB of weights differing in one byte must not pass.
227        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        // The message is part of the contract: a user who sees this must know
256        // that nothing landed on disk and that retrying is not the answer.
257        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        // The chunked reader must agree with the one-shot hash, across chunk
264        // boundaries: 200_000 bytes span three 64 KiB reads.
265        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}