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 expected = expected_hex.trim().to_ascii_lowercase();
84    if expected.len() != 64 || !expected.bytes().all(|b| b.is_ascii_hexdigit()) {
85        return Err(IntegrityError::MalformedDigest(expected_hex.to_string()));
86    }
87
88    let actual = sha256_hex(bytes);
89
90    // Constant-time: fold every byte rather than returning on the first mismatch.
91    let diff = actual
92        .bytes()
93        .zip(expected.bytes())
94        .fold(0u8, |acc, (a, b)| acc | (a ^ b));
95
96    if diff == 0 {
97        Ok(())
98    } else {
99        Err(IntegrityError::DigestMismatch { expected, actual })
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    // The canonical SHA-256 of the empty input and of "abc", from FIPS 180-4.
108    // Hardcoding known-answer vectors means a broken hasher fails here rather
109    // than producing self-consistent nonsense that agrees with itself.
110    const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
111    const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
112
113    #[test]
114    fn known_answer_vectors() {
115        assert_eq!(sha256_hex(b""), EMPTY);
116        assert_eq!(sha256_hex(b"abc"), ABC);
117    }
118
119    #[test]
120    fn accepts_matching_digest() {
121        assert_eq!(verify_sha256(b"abc", ABC), Ok(()));
122    }
123
124    #[test]
125    fn accepts_uppercase_and_padded_digest() {
126        assert_eq!(
127            verify_sha256(b"abc", &format!("  {}  ", ABC.to_uppercase())),
128            Ok(())
129        );
130    }
131
132    #[test]
133    fn rejects_wrong_content() {
134        // The scenario that matters: the URL is right, the bytes are not.
135        let err = verify_sha256(b"abd", ABC).expect_err("must reject");
136        match err {
137            IntegrityError::DigestMismatch { expected, actual } => {
138                assert_eq!(expected, ABC);
139                assert_ne!(actual, ABC);
140            }
141            other => panic!("wrong error: {other:?}"),
142        }
143    }
144
145    #[test]
146    fn rejects_single_flipped_bit() {
147        // 84 MB of weights differing in one byte must not pass.
148        let mut a = vec![7u8; 4096];
149        let good = sha256_hex(&a);
150        a[2048] ^= 0x01;
151        assert!(verify_sha256(&a, &good).is_err());
152    }
153
154    #[test]
155    fn rejects_truncated_transfer() {
156        let full = vec![3u8; 8192];
157        let good = sha256_hex(&full);
158        assert!(verify_sha256(&full[..4096], &good).is_err());
159    }
160
161    #[test]
162    fn malformed_expectation_is_an_error_not_a_pass() {
163        for bad in ["", "abc", "zz", &"f".repeat(63), &"g".repeat(64)] {
164            assert!(
165                matches!(
166                    verify_sha256(b"anything", bad),
167                    Err(IntegrityError::MalformedDigest(_))
168                ),
169                "expected {bad:?} to be rejected as malformed"
170            );
171        }
172    }
173
174    #[test]
175    fn error_message_says_the_file_was_not_written() {
176        // The message is part of the contract: a user who sees this must know
177        // that nothing landed on disk and that retrying is not the answer.
178        let msg = verify_sha256(b"abd", ABC).unwrap_err().to_string();
179        assert!(msg.contains("NOT written"), "message was: {msg}");
180    }
181}