Skip to main content

edgeparse_core/pdf/
encryption.rs

1//! PDF encryption detection and password-based loading.
2
3use lopdf::Document;
4
5/// Information about a PDF's encryption status.
6#[derive(Debug, Clone)]
7pub struct EncryptionInfo {
8    /// Whether the document is encrypted.
9    pub is_encrypted: bool,
10    /// Encryption algorithm version (V value from /Encrypt).
11    pub version: Option<i64>,
12    /// Key length in bits.
13    pub key_length: Option<i64>,
14    /// Encryption filter name (e.g., "Standard").
15    pub filter: Option<String>,
16    /// Permissions flags (P value).
17    pub permissions: Option<i64>,
18}
19
20impl EncryptionInfo {
21    /// Check if printing is allowed (bit 3 of permissions).
22    pub fn can_print(&self) -> bool {
23        self.permissions.is_none_or(|p| p & 0x4 != 0)
24    }
25
26    /// Check if content copying is allowed (bit 5 of permissions).
27    pub fn can_copy(&self) -> bool {
28        self.permissions.is_none_or(|p| p & 0x10 != 0)
29    }
30
31    /// Check if modification is allowed (bit 4 of permissions).
32    pub fn can_modify(&self) -> bool {
33        self.permissions.is_none_or(|p| p & 0x8 != 0)
34    }
35}
36
37/// Detect encryption info from a loaded PDF document.
38///
39/// Note: if the document couldn't be loaded due to encryption, this function
40/// can't inspect it. Use [`detect_encryption_from_bytes`] for raw file analysis.
41pub fn detect_encryption(doc: &Document) -> EncryptionInfo {
42    let trailer = &doc.trailer;
43
44    let encrypt_dict = trailer.get(b"Encrypt").ok().and_then(|obj| match obj {
45        lopdf::Object::Dictionary(d) => Some(d.clone()),
46        lopdf::Object::Reference(id) => doc
47            .get_object(*id)
48            .ok()
49            .and_then(|o| o.as_dict().ok().cloned()),
50        _ => None,
51    });
52
53    let Some(dict) = encrypt_dict else {
54        return EncryptionInfo {
55            is_encrypted: false,
56            version: None,
57            key_length: None,
58            filter: None,
59            permissions: None,
60        };
61    };
62
63    let version = dict.get(b"V").ok().and_then(|o| {
64        if let lopdf::Object::Integer(i) = o {
65            Some(*i)
66        } else {
67            None
68        }
69    });
70
71    let key_length = dict.get(b"Length").ok().and_then(|o| {
72        if let lopdf::Object::Integer(i) = o {
73            Some(*i)
74        } else {
75            None
76        }
77    });
78
79    let filter = dict.get(b"Filter").ok().and_then(|o| match o {
80        lopdf::Object::Name(n) => String::from_utf8(n.clone()).ok(),
81        _ => None,
82    });
83
84    let permissions = dict.get(b"P").ok().and_then(|o| {
85        if let lopdf::Object::Integer(i) = o {
86            Some(*i)
87        } else {
88            None
89        }
90    });
91
92    EncryptionInfo {
93        is_encrypted: true,
94        version,
95        key_length,
96        filter,
97        permissions,
98    }
99}
100
101/// Try to load a PDF with an optional password.
102///
103/// Returns the loaded document or an error if the password is wrong or the file
104/// is otherwise unreadable.
105pub fn load_with_password(
106    data: &[u8],
107    password: Option<&str>,
108) -> Result<Document, crate::EdgePdfError> {
109    // lopdf doesn't natively support decryption — for encrypted PDFs,
110    // we attempt a plain load and report encryption status on failure.
111    match Document::load_mem(data) {
112        Ok(doc) => {
113            let info = detect_encryption(&doc);
114            if info.is_encrypted && password.is_none() {
115                log::warn!("Document is encrypted but no password was provided");
116            }
117            Ok(doc)
118        }
119        Err(e) => {
120            if password.is_some() {
121                Err(crate::EdgePdfError::LoadError(format!(
122                    "Failed to load encrypted PDF (password may be incorrect): {}",
123                    e
124                )))
125            } else {
126                Err(crate::EdgePdfError::LoadError(format!(
127                    "Failed to load PDF (may be encrypted — try providing a password): {}",
128                    e
129                )))
130            }
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_unencrypted_document() {
141        let doc = Document::new();
142        let info = detect_encryption(&doc);
143        assert!(!info.is_encrypted);
144        assert!(info.version.is_none());
145        assert!(info.can_print());
146        assert!(info.can_copy());
147        assert!(info.can_modify());
148    }
149
150    #[test]
151    fn test_permissions_parsing() {
152        // All permissions granted
153        let info = EncryptionInfo {
154            is_encrypted: true,
155            version: Some(2),
156            key_length: Some(128),
157            filter: Some("Standard".to_string()),
158            permissions: Some(-1), // All bits set
159        };
160        assert!(info.can_print());
161        assert!(info.can_copy());
162        assert!(info.can_modify());
163    }
164
165    #[test]
166    fn test_restricted_permissions() {
167        // No permissions
168        let info = EncryptionInfo {
169            is_encrypted: true,
170            version: Some(2),
171            key_length: Some(128),
172            filter: Some("Standard".to_string()),
173            permissions: Some(0),
174        };
175        assert!(!info.can_print());
176        assert!(!info.can_copy());
177        assert!(!info.can_modify());
178    }
179
180    #[test]
181    fn test_load_empty_pdf_bytes() {
182        // Minimal valid PDF
183        let mut doc = Document::new();
184        let mut buf = Vec::new();
185        doc.save_to(&mut buf).unwrap();
186        let result = load_with_password(&buf, None);
187        assert!(result.is_ok());
188    }
189}