docling_core/encryption.rs
1//! Why an encrypted document could not be opened — the one typed signal every
2//! backend raises for a password-protected input (#636).
3//!
4//! A PDF, an Office document, an iWork package and a WordPerfect file each
5//! detect encryption in their own way and word the failure for their own
6//! users, but a caller has one question: *is this a password problem, and
7//! would asking for one help?* Before this type, the answer meant matching
8//! on message text ("document is encrypted", "the PDF is encrypted …",
9//! "password-protected"), which no release promised to keep. The value lives
10//! here, in the crate every backend already depends on, so `docling-pdf`'s
11//! error and the converter's error can both carry it; the converter's
12//! `ConversionError::encryption()` finds it anywhere on the `source()` chain.
13//!
14//! The [`Display`](std::fmt::Display) text is the Office backends' wording
15//! (`document is encrypted (a password is required to open it)`, …), which a
16//! backend prefixes with its format; backends with an established wording of
17//! their own (the PDF reader, iWork, WordPerfect) keep it and attach the
18//! typed value as the cause, so no error message changed with its arrival.
19
20use std::fmt;
21
22/// Why an encrypted document could not be opened.
23///
24/// Only [`NeedPassword`](Self::NeedPassword) and
25/// [`WrongPassword`](Self::WrongPassword) are solved by a password
26/// ([`needs_password`](Self::needs_password)); the other two say the file
27/// cannot be read however it is asked. Non-exhaustive: a reader may learn to
28/// tell another case apart.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum EncryptionError {
32 /// No password was given and none the reader tries by itself (a
33 /// format's documented default) opens the document.
34 NeedPassword,
35 /// A password was given and neither it nor a default one opens it.
36 WrongPassword,
37 /// The document is encrypted in a way this reader does not decrypt
38 /// (ODF package encryption, XOR obfuscation, an iWork package, a
39 /// WordPerfect file, an unknown cipher), so no password would help. The
40 /// payload names the scheme.
41 NotDecryptable(String),
42 /// The document is encrypted, but its encryption header is damaged or
43 /// outside the specification's bounds. The payload names the field.
44 Malformed(String),
45}
46
47impl EncryptionError {
48 /// Whether a (different) password would open the document — the two
49 /// cases worth prompting a user for.
50 pub fn needs_password(&self) -> bool {
51 matches!(self, Self::NeedPassword | Self::WrongPassword)
52 }
53
54 /// A stable machine-readable name for the case — what an HTTP error
55 /// body or a log line carries: `password_required`, `wrong_password`,
56 /// `not_decryptable`, `malformed_encryption`.
57 pub fn code(&self) -> &'static str {
58 match self {
59 Self::NeedPassword => "password_required",
60 Self::WrongPassword => "wrong_password",
61 Self::NotDecryptable(_) => "not_decryptable",
62 Self::Malformed(_) => "malformed_encryption",
63 }
64 }
65}
66
67impl fmt::Display for EncryptionError {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::NeedPassword => {
71 f.write_str("document is encrypted (a password is required to open it)")
72 }
73 Self::WrongPassword => f.write_str("document is encrypted and the password is wrong"),
74 Self::NotDecryptable(what) => {
75 write!(
76 f,
77 "document is encrypted with an unsupported scheme ({what})"
78 )
79 }
80 Self::Malformed(what) => write!(
81 f,
82 "document is encrypted, but its encryption header is damaged ({what})"
83 ),
84 }
85 }
86}
87
88impl std::error::Error for EncryptionError {}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn only_the_password_cases_need_a_password() {
96 assert!(EncryptionError::NeedPassword.needs_password());
97 assert!(EncryptionError::WrongPassword.needs_password());
98 assert!(!EncryptionError::NotDecryptable("ODF".into()).needs_password());
99 assert!(!EncryptionError::Malformed("keyBits".into()).needs_password());
100 }
101
102 #[test]
103 fn codes_are_distinct_and_stable() {
104 let all = [
105 EncryptionError::NeedPassword,
106 EncryptionError::WrongPassword,
107 EncryptionError::NotDecryptable("x".into()),
108 EncryptionError::Malformed("y".into()),
109 ];
110 let codes: std::collections::BTreeSet<_> = all.iter().map(|e| e.code()).collect();
111 assert_eq!(codes.len(), all.len());
112 assert_eq!(EncryptionError::NeedPassword.code(), "password_required");
113 assert_eq!(EncryptionError::WrongPassword.code(), "wrong_password");
114 }
115
116 /// The wording #624 fixed, which every Office backend prefixes with its
117 /// format name.
118 #[test]
119 fn display_is_the_office_wording() {
120 assert_eq!(
121 EncryptionError::NeedPassword.to_string(),
122 "document is encrypted (a password is required to open it)"
123 );
124 assert_eq!(
125 EncryptionError::WrongPassword.to_string(),
126 "document is encrypted and the password is wrong"
127 );
128 assert_eq!(
129 EncryptionError::NotDecryptable("XOR obfuscation".into()).to_string(),
130 "document is encrypted with an unsupported scheme (XOR obfuscation)"
131 );
132 assert_eq!(
133 EncryptionError::Malformed("keyBits".into()).to_string(),
134 "document is encrypted, but its encryption header is damaged (keyBits)"
135 );
136 }
137}