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/// Uses the Standard Security Handler decrypt path in `pdf-cos` (lopdf)
104/// for revisions 2–6 when a password is supplied.
105pub fn load_with_password(
106    data: &[u8],
107    password: Option<&str>,
108) -> Result<Document, crate::EdgePdfError> {
109    let mut doc = Document::load_mem(data).map_err(|e| {
110        crate::EdgePdfError::LoadError(format!(
111            "Failed to load PDF (may be encrypted — try providing a password): {e}"
112        ))
113    })?;
114
115    decrypt_document(&mut doc, password)?;
116    Ok(doc)
117}
118
119/// Decrypt an already-parsed document in place when encrypted.
120///
121/// No-op when the document is not encrypted. When encrypted and `password` is
122/// `None`, attempts an empty-user-password decrypt (common for owner-only locks).
123pub fn decrypt_document(
124    doc: &mut Document,
125    password: Option<&str>,
126) -> Result<(), crate::EdgePdfError> {
127    if !doc.is_encrypted() {
128        return Ok(());
129    }
130
131    let candidates: Vec<&str> = match password {
132        Some(p) => vec![p, ""],
133        None => vec![""],
134    };
135
136    let mut last_err = None;
137    for pw in candidates {
138        match doc.decrypt(pw) {
139            Ok(()) => return Ok(()),
140            Err(e) => last_err = Some(e),
141        }
142    }
143
144    Err(crate::EdgePdfError::LoadError(format!(
145        "Failed to decrypt PDF (password may be incorrect): {}",
146        last_err
147            .map(|e| e.to_string())
148            .unwrap_or_else(|| "unknown error".into())
149    )))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_unencrypted_document() {
158        let doc = Document::new();
159        let info = detect_encryption(&doc);
160        assert!(!info.is_encrypted);
161        assert!(info.version.is_none());
162        assert!(info.can_print());
163        assert!(info.can_copy());
164        assert!(info.can_modify());
165    }
166
167    #[test]
168    fn test_permissions_parsing() {
169        let info = EncryptionInfo {
170            is_encrypted: true,
171            version: Some(2),
172            key_length: Some(128),
173            filter: Some("Standard".to_string()),
174            permissions: Some(-1),
175        };
176        assert!(info.can_print());
177        assert!(info.can_copy());
178        assert!(info.can_modify());
179    }
180
181    #[test]
182    fn test_restricted_permissions() {
183        let info = EncryptionInfo {
184            is_encrypted: true,
185            version: Some(2),
186            key_length: Some(128),
187            filter: Some("Standard".to_string()),
188            permissions: Some(0),
189        };
190        assert!(!info.can_print());
191        assert!(!info.can_copy());
192        assert!(!info.can_modify());
193    }
194
195    #[test]
196    fn test_load_empty_pdf_bytes() {
197        let mut doc = Document::new();
198        let mut buf = Vec::new();
199        doc.save_to(&mut buf).unwrap();
200        let result = load_with_password(&buf, None);
201        assert!(result.is_ok());
202    }
203
204    #[test]
205    fn decrypt_noop_on_plain_doc() {
206        let mut doc = Document::new();
207        assert!(decrypt_document(&mut doc, Some("secret")).is_ok());
208    }
209}