From b803ebf35be84410bf9a38dc6bc09bec5bdc6a9c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 09:38:50 +0300 Subject: [PATCH] pdf-document: add read diagnostics and report API Expose recoverable read diagnostics alongside successful document loads. Introduce a lightweight object ID type for parser and diagnostic context. Co-authored-by: Codex --- Cargo.lock | 145 +++- crates/pdf-document/Cargo.toml | 4 + crates/pdf-document/src/decryption.rs | 489 ++++++++++--- crates/pdf-document/src/diagnostic.rs | 50 ++ crates/pdf-document/src/error.rs | 16 +- crates/pdf-document/src/lib.rs | 6 +- crates/pdf-document/src/object_stream.rs | 126 +--- crates/pdf-document/src/pages.rs | 194 ------ crates/pdf-document/src/reader.rs | 776 +++++++++++---------- crates/pdf-document/src/report.rs | 39 ++ crates/pdf-document/tests/object_stream.rs | 84 +++ crates/pdf-document/tests/pages.rs | 261 +++++++ crates/pdf-document/tests/reader.rs | 16 +- crates/pdf-object/src/lib.rs | 1 + crates/pdf-object/src/object_id.rs | 8 + 15 files changed, 1450 insertions(+), 765 deletions(-) create mode 100644 crates/pdf-document/src/diagnostic.rs create mode 100644 crates/pdf-document/src/report.rs create mode 100644 crates/pdf-document/tests/object_stream.rs create mode 100644 crates/pdf-document/tests/pages.rs create mode 100644 crates/pdf-object/src/object_id.rs diff --git a/Cargo.lock b/Cargo.lock index ffe15c75..fc65c7ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,26 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -506,6 +526,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -713,7 +742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.1", - "objc2 0.6.1", + "objc2 0.6.4", ] [[package]] @@ -768,6 +797,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.11" @@ -781,6 +816,7 @@ dependencies = [ name = "examples" version = "0.0.0" dependencies = [ + "arboard", "femtovg", "futures", "gl", @@ -790,6 +826,7 @@ dependencies = [ "pdf-content-stream", "pdf-document", "pdf-font", + "pdf-graphics", "pdf-graphics-femtovg", "pdf-graphics-skia", "pdf-object", @@ -804,6 +841,12 @@ dependencies = [ "winit", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1088,7 +1131,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.1", + "objc2 0.6.4", "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -1240,8 +1283,9 @@ dependencies = [ "moxcms", "num-traits", "png", - "zune-core", - "zune-jpeg", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.15", ] [[package]] @@ -1662,9 +1706,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1692,8 +1736,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ "bitflags 2.9.1", - "objc2 0.6.1", + "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.1", ] @@ -1735,13 +1780,26 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.9.1", "dispatch2", - "objc2 0.6.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -1794,7 +1852,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.9.1", - "objc2 0.6.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -2126,6 +2195,7 @@ dependencies = [ "pdf-object-collection", "pdf-parser", "pdf-resources", + "rc4", "thiserror 2.0.12", ] @@ -2282,6 +2352,7 @@ dependencies = [ "pdf-canvas", "pdf-content-stream", "pdf-document", + "pdf-graphics", "thiserror 2.0.12", ] @@ -2481,6 +2552,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -2537,6 +2614,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc4" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1256e23efe6097f27aa82d6ca6889361c001586ae0f6917cbad072f05eb275" +dependencies = [ + "cipher", +] + [[package]] name = "read-fonts" version = "0.36.0" @@ -3005,6 +3091,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3412,6 +3512,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wgpu" version = "24.0.3" @@ -4084,17 +4190,32 @@ dependencies = [ "syn", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.1", ] diff --git a/crates/pdf-document/Cargo.toml b/crates/pdf-document/Cargo.toml index 1e35133c..de7c9cdc 100644 --- a/crates/pdf-document/Cargo.toml +++ b/crates/pdf-document/Cargo.toml @@ -15,6 +15,10 @@ thiserror = "2.0.12" md-5 = "0.10.6" aes = "0.8.4" cbc = "0.1.2" +rc4 = "0.1.0" + +[lints] +workspace = true [dev-dependencies] pdf-color-space = { path = "../pdf-color-space" } diff --git a/crates/pdf-document/src/decryption.rs b/crates/pdf-document/src/decryption.rs index 6f9624aa..5d86676b 100644 --- a/crates/pdf-document/src/decryption.rs +++ b/crates/pdf-document/src/decryption.rs @@ -23,10 +23,17 @@ use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; use md5::{Digest, Md5}; +use rc4::{ + Key, KeyInit, Rc4, StreamCipher, + consts::{U1, U2, U3, U4, U5, U6, U7, U8, U9, U10, U11, U12, U13, U14, U15, U16}, +}; use thiserror::Error; use crate::encryption::{EncryptDictionary, EncryptionVersion}; -use pdf_object::{dictionary::Dictionary, object_variant::ObjectVariant, stream::StreamObject}; +use pdf_object::{ + dictionary::Dictionary, indirect_object::IndirectObject, object_variant::ObjectVariant, + stream::StreamObject, +}; /// Errors that can occur during PDF decryption. #[derive(Debug, Error)] @@ -94,8 +101,7 @@ impl DocumentDecryptor { let owner_hash = encrypt.owner_password_hash.as_ref(); let user_hash = encrypt.user_password_hash.as_ref(); let permissions = encrypt.permissions; - let key_length_bits = encrypt.effective_key_length(); - let key_length_bytes = (key_length_bits / 8) as usize; + let key_length_bytes = key_length_in_bytes(encrypt.effective_key_length())?; // Validate supported algorithms match (encrypt.version, revision) { @@ -116,7 +122,7 @@ impl DocumentDecryptor { key_length_bytes, revision, encrypt.encrypt_metadata, - ); + )?; // Verify the user password if authenticate_user_password( @@ -125,7 +131,7 @@ impl DocumentDecryptor { document_id, revision, encrypt.encrypt_metadata, - ) { + )? { return Ok(DocumentDecryptor { file_key, version: encrypt.version, @@ -136,7 +142,7 @@ impl DocumentDecryptor { // Try as owner password let user_password = - recover_user_password_from_owner(password, owner_hash, key_length_bytes, revision); + recover_user_password_from_owner(password, owner_hash, key_length_bytes, revision)?; let file_key = compute_file_encryption_key( &user_password, @@ -146,7 +152,7 @@ impl DocumentDecryptor { key_length_bytes, revision, encrypt.encrypt_metadata, - ); + )?; if authenticate_user_password( &file_key, @@ -154,7 +160,7 @@ impl DocumentDecryptor { document_id, revision, encrypt.encrypt_metadata, - ) { + )? { return Ok(DocumentDecryptor { file_key, version: encrypt.version, @@ -189,12 +195,10 @@ impl DocumentDecryptor { object_number, generation_number, self.version, - ); + )?; match self.version { - EncryptionVersion::V1 | EncryptionVersion::V2 => { - Ok(rc4_crypt(&object_key, encrypted_data)) - } + EncryptionVersion::V1 | EncryptionVersion::V2 => rc4_crypt(&object_key, encrypted_data), EncryptionVersion::V4 => aes_128_cbc_decrypt(&object_key, encrypted_data), _ => Err(DecryptionError::UnsupportedAlgorithm { version: self.version, @@ -249,12 +253,118 @@ impl DocumentDecryptor { self.decrypt_stream(object_number, generation_number, encrypted_string) } - /// Returns whether metadata streams should be encrypted. - pub fn encrypt_metadata(&self) -> bool { - self.encrypt_metadata + /// Decrypts every encrypted string and stream contained in an indirect PDF object. + /// + /// PDF encryption derives one key per indirect object, so nested strings use the object + /// number and generation of the indirect object that contains them. + pub(crate) fn decrypt_object( + &self, + object: ObjectVariant, + ) -> Result { + match object { + ObjectVariant::IndirectObject(indirect) => { + let object_number = indirect.object_number; + let generation_number = indirect.generation_number; + let object = indirect + .object + .map(|object| { + self.decrypt_object_value(object, object_number, generation_number) + }) + .transpose()?; + Ok(ObjectVariant::IndirectObject(Box::new( + IndirectObject::new(object_number, generation_number, object), + ))) + } + ObjectVariant::Stream(stream) => self.decrypt_stream_value(stream), + other => Ok(other), + } } + + /// Decrypts a nested value using the key derived for its containing indirect object. + fn decrypt_object_value( + &self, + object: ObjectVariant, + object_number: usize, + generation_number: usize, + ) -> Result { + match object { + ObjectVariant::LiteralString(bytes) => Ok(ObjectVariant::LiteralString( + self.decrypt_string(object_number, generation_number, &bytes)?, + )), + ObjectVariant::HexString(bytes) => Ok(ObjectVariant::HexString(self.decrypt_string( + object_number, + generation_number, + &bytes, + )?)), + ObjectVariant::Array(values) => values + .into_iter() + .map(|value| self.decrypt_object_value(value, object_number, generation_number)) + .collect::, _>>() + .map(ObjectVariant::Array), + ObjectVariant::Dictionary(dictionary) => Ok(ObjectVariant::Dictionary(Box::new( + self.decrypt_dictionary(*dictionary, object_number, generation_number)?, + ))), + ObjectVariant::Stream(stream) => self.decrypt_stream_value(stream), + ObjectVariant::IndirectObject(indirect) => { + self.decrypt_object(ObjectVariant::IndirectObject(indirect)) + } + other => Ok(other), + } + } + + /// Decrypts dictionary values while preserving signature contents required by PDF rules. + fn decrypt_dictionary( + &self, + dictionary: Dictionary, + object_number: usize, + generation_number: usize, + ) -> Result { + let is_signature = is_signature_dictionary(&dictionary); + let entries = dictionary + .dictionary + .into_iter() + .map(|(key, value)| { + if is_signature && key == "Contents" { + Ok((key, value)) + } else { + self.decrypt_object_value(value, object_number, generation_number) + .map(|value| (key, value)) + } + }) + .collect::>()?; + Ok(Dictionary { + dictionary: entries, + object_number: dictionary.object_number, + }) + } + + /// Decrypts a stream's data and recursively transforms its dictionary values. + fn decrypt_stream_value(&self, stream: StreamObject) -> Result { + let object_number = stream.object_number; + let generation_number = stream.generation_number; + let stream = self.decrypt_stream_object(&stream)?; + let dictionary = + self.decrypt_dictionary(*stream.dictionary, object_number, generation_number)?; + Ok(ObjectVariant::Stream(StreamObject::new( + object_number, + generation_number, + Box::new(dictionary), + stream.data, + ))) + } +} + +/// Returns whether a dictionary represents a signature whose `/Contents` is not encrypted. +fn is_signature_dictionary(dictionary: &Dictionary) -> bool { + ["Type", "FT"].into_iter().any(|key| { + matches!( + dictionary.get(key), + Some(ObjectVariant::Name(value)) if value.as_slice() == b"Sig" + ) + }) } +/// Returns whether PDF encryption rules exempt this stream from decryption. fn should_skip_stream_decryption(dictionary: &Dictionary, encrypt_metadata: bool) -> bool { match dictionary.get("Type") { Some(ObjectVariant::Name(name)) if name.as_slice() == b"XRef" => true, @@ -274,7 +384,7 @@ fn compute_file_encryption_key( key_length_bytes: usize, revision: i32, encrypt_metadata: bool, -) -> Vec { +) -> Result, DecryptionError> { // Step 1: Pad or truncate the password to 32 bytes let padded_password = pad_password(password); @@ -304,14 +414,14 @@ fn compute_file_encryption_key( if revision >= 3 { for _ in 0..50 { let mut hasher = Md5::new(); - hasher.update(&hash[..key_length_bytes]); + hasher.update(key_prefix(&hash, key_length_bytes)?); hash = hasher.finalize().to_vec(); } } // Return key of appropriate length hash.truncate(key_length_bytes); - hash + Ok(hash) } /// Authenticates the user password by comparing against the U value. @@ -324,10 +434,10 @@ fn authenticate_user_password( document_id: &[u8], revision: i32, _encrypt_metadata: bool, -) -> bool { +) -> Result { let computed_hash = if revision == 2 { // Algorithm 4: RC4-encrypt the padding string with the file key - rc4_crypt(file_key, &PADDING) + rc4_crypt(file_key, &PADDING)? } else { // Algorithm 5: MD5 hash of padding + document ID, then RC4 with key variations let mut hasher = Md5::new(); @@ -340,11 +450,11 @@ fn authenticate_user_password( // It performs 19 additional encryption rounds (for a total of 20) using // keys derived by XORing the original key with the loop index (1..19). // This "key stretching" was intended to make brute-force attacks more computationally expensive. - let mut result = rc4_crypt(file_key, &hash); + let mut result = rc4_crypt(file_key, &hash)?; for i in 1..=REVISION_3_MIXING_ROUNDS { let modified_key: Vec = file_key.iter().map(|&b| b ^ i).collect(); - result = rc4_crypt(&modified_key, &result); + result = rc4_crypt(&modified_key, &result)?; } result @@ -352,9 +462,9 @@ fn authenticate_user_password( // Compare first 16 bytes for R>=3, or full 32 bytes for R=2 if revision == 2 { - computed_hash == user_hash + Ok(computed_hash == user_hash) } else { - computed_hash.len() >= 16 && user_hash.len() >= 16 && computed_hash[..16] == user_hash[..16] + Ok(computed_hash.get(..16) == user_hash.get(..16) && computed_hash.get(..16).is_some()) } } @@ -366,7 +476,7 @@ fn recover_user_password_from_owner( owner_hash: &[u8], key_length_bytes: usize, revision: i32, -) -> Vec { +) -> Result, DecryptionError> { // Compute the owner key from the owner password let padded = pad_password(owner_password); let mut hasher = Md5::new(); @@ -376,7 +486,7 @@ fn recover_user_password_from_owner( if revision >= 3 { for _ in 0..50 { let mut h = Md5::new(); - h.update(&hash[..key_length_bytes]); + h.update(key_prefix(&hash, key_length_bytes)?); hash = h.finalize().to_vec(); } } @@ -390,9 +500,9 @@ fn recover_user_password_from_owner( let mut result = owner_hash.to_vec(); for i in (0..=REVISION_3_MIXING_ROUNDS).rev() { let modified_key: Vec = hash.iter().map(|&b| b ^ i).collect(); - result = rc4_crypt(&modified_key, &result); + result = rc4_crypt(&modified_key, &result)?; } - result + Ok(result) } } @@ -404,17 +514,27 @@ fn compute_object_key( object_number: usize, generation_number: usize, version: EncryptionVersion, -) -> Vec { +) -> Result, DecryptionError> { let mut hasher = Md5::new(); // Hash the file encryption key hasher.update(file_key); // Hash the object number as 3 bytes (little-endian) - hasher.update(&(object_number as u32).to_le_bytes()[..3]); + let object_number = u32::try_from(object_number).map_err(|_| { + DecryptionError::InvalidData("object number exceeds the PDF encryption range".to_string()) + })?; + hasher.update(object_number.to_le_bytes().get(..3).ok_or_else(|| { + DecryptionError::InvalidData("object number could not be encoded".to_string()) + })?); // Hash the generation number as 2 bytes (little-endian) - hasher.update((generation_number as u16).to_le_bytes()); + let generation_number = u16::try_from(generation_number).map_err(|_| { + DecryptionError::InvalidData( + "generation number exceeds the PDF encryption range".to_string(), + ) + })?; + hasher.update(generation_number.to_le_bytes()); // For AES, add the "sAlT" marker if matches!(version, EncryptionVersion::V4) { @@ -424,70 +544,98 @@ fn compute_object_key( let hash = hasher.finalize(); // Key length is min(file_key.len() + 5, 16) bytes - let key_len = std::cmp::min(file_key.len() + 5, 16); - hash[..key_len].to_vec() + let key_length = file_key.len().saturating_add(5).min(16); + Ok(key_prefix(hash.as_slice(), key_length)?.to_vec()) +} + +/// Validates a PDF encryption key length and converts it to bytes. +fn key_length_in_bytes(key_length_bits: i32) -> Result { + if key_length_bits <= 0 || key_length_bits.rem_euclid(8) != 0 { + return Err(DecryptionError::InvalidData( + "encryption key length must be a positive multiple of eight".to_string(), + )); + } + usize::try_from(key_length_bits / 8) + .map_err(|_| DecryptionError::InvalidData("encryption key length is too large".to_string())) +} + +/// Returns a validated prefix of key material. +fn key_prefix(bytes: &[u8], length: usize) -> Result<&[u8], DecryptionError> { + bytes.get(..length).ok_or_else(|| { + DecryptionError::InvalidData("encryption key material is shorter than declared".to_string()) + }) } /// Pads or truncates a password to exactly 32 bytes using the padding string. fn pad_password(password: &[u8]) -> [u8; 32] { let mut result = [0u8; 32]; - let copy_len = std::cmp::min(password.len(), 32); - - result[..copy_len].copy_from_slice(&password[..copy_len]); - - if copy_len < 32 { - result[copy_len..].copy_from_slice(&PADDING[..(32 - copy_len)]); + for (destination, source) in result.iter_mut().zip(password.iter().chain(PADDING.iter())) { + *destination = *source; } - result } /// RC4 encryption/decryption (symmetric cipher). /// /// RC4 is a stream cipher where encryption and decryption are the same operation. -fn rc4_crypt(key: &[u8], data: &[u8]) -> Vec { - // Initialize the S-box - let mut s: [u8; 256] = [0; 256]; - for (i, item) in s.iter_mut().enumerate() { - *item = i as u8; - } - - // Key-scheduling algorithm (KSA) - let mut j: usize = 0; - for i in 0..256 { - j = (j + s[i] as usize + key[i % key.len()] as usize) % 256; - s.swap(i, j); - } - - // Pseudo-random generation algorithm (PRGA) +fn rc4_crypt(key: &[u8], data: &[u8]) -> Result, DecryptionError> { let mut result = Vec::with_capacity(data.len()); - let mut i: usize = 0; - j = 0; - - for &byte in data { - i = (i + 1) % 256; - j = (j + s[i] as usize) % 256; - s.swap(i, j); - let k = s[(s[i] as usize + s[j] as usize) % 256]; - result.push(byte ^ k); + result.extend_from_slice(data); + match key.len() { + 1 => apply_rc4::(key, &mut result)?, + 2 => apply_rc4::(key, &mut result)?, + 3 => apply_rc4::(key, &mut result)?, + 4 => apply_rc4::(key, &mut result)?, + 5 => apply_rc4::(key, &mut result)?, + 6 => apply_rc4::(key, &mut result)?, + 7 => apply_rc4::(key, &mut result)?, + 8 => apply_rc4::(key, &mut result)?, + 9 => apply_rc4::(key, &mut result)?, + 10 => apply_rc4::(key, &mut result)?, + 11 => apply_rc4::(key, &mut result)?, + 12 => apply_rc4::(key, &mut result)?, + 13 => apply_rc4::(key, &mut result)?, + 14 => apply_rc4::(key, &mut result)?, + 15 => apply_rc4::(key, &mut result)?, + 16 => apply_rc4::(key, &mut result)?, + _ => { + return Err(DecryptionError::InvalidData( + "RC4 keys must contain between 1 and 16 bytes".to_string(), + )); + } } + Ok(result) +} - result +/// Applies RustCrypto RC4 using a compile-time key size selected by the PDF key length. +fn apply_rc4(key: &[u8], data: &mut [u8]) -> Result<(), DecryptionError> +where + KeySize: rc4::cipher::generic_array::ArrayLength, +{ + let mut rc4_key = Key::::default(); + if rc4_key.len() != key.len() { + return Err(DecryptionError::InvalidData( + "RC4 key length did not match its selected size".to_string(), + )); + } + rc4_key + .iter_mut() + .zip(key.iter()) + .for_each(|(destination, source)| *destination = *source); + let mut cipher = Rc4::::new(&rc4_key); + cipher.apply_keystream(data); + Ok(()) } /// AES-128 CBC decryption. /// /// The first 16 bytes of the input are the IV. fn aes_128_cbc_decrypt(key: &[u8], data: &[u8]) -> Result, DecryptionError> { - if data.len() < 16 { + let Some((iv, ciphertext)) = data.split_at_checked(16) else { return Err(DecryptionError::InvalidData( "AES data too short (need at least 16 bytes for IV)".to_string(), )); - } - - // First 16 bytes are the IV - let iv = &data[..16]; - let ciphertext = &data[16..]; + }; if ciphertext.is_empty() { return Ok(Vec::new()); @@ -500,15 +648,9 @@ fn aes_128_cbc_decrypt(key: &[u8], data: &[u8]) -> Result, DecryptionErr } // Ensure key is exactly 16 bytes - let key_16: [u8; 16] = key - .get(..16) - .and_then(|s| s.try_into().ok()) - .unwrap_or_else(|| { - let mut arr = [0u8; 16]; - let len = std::cmp::min(key.len(), 16); - arr[..len].copy_from_slice(&key[..len]); - arr - }); + let key_16: [u8; 16] = key.try_into().map_err(|_| { + DecryptionError::InvalidData("AES-128 requires a 16-byte object key".to_string()) + })?; let iv_16: [u8; 16] = iv .try_into() @@ -528,6 +670,13 @@ fn aes_128_cbc_decrypt(key: &[u8], data: &[u8]) -> Result, DecryptionErr } #[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::as_conversions +)] mod tests { use super::*; use std::collections::BTreeMap; @@ -550,6 +699,36 @@ mod tests { StreamObject::new(7, 0, Box::new(Dictionary::new(entries)), data) } + fn encrypt_for_object( + decryptor: &DocumentDecryptor, + object_number: usize, + generation_number: usize, + plaintext: &[u8], + ) -> Vec { + use aes::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7}; + + type Aes128CbcEnc = cbc::Encryptor; + + let object_key = compute_object_key( + &decryptor.file_key, + object_number, + generation_number, + decryptor.version, + ) + .expect("test object key is valid"); + let iv = [0u8; 16]; + let encryptor = Aes128CbcEnc::new_from_slices(&object_key, &iv) + .expect("AES object key and IV should be valid"); + let mut buffer = vec![0; plaintext.len().saturating_add(16)]; + buffer[..plaintext.len()].copy_from_slice(plaintext); + let encrypted = encryptor + .encrypt_padded_mut::(&mut buffer, plaintext.len()) + .expect("encryption buffer includes a padding block"); + let mut result = iv.to_vec(); + result.extend_from_slice(encrypted); + result + } + #[test] fn test_pad_password_empty() { let padded = pad_password(b""); @@ -583,8 +762,8 @@ mod tests { let key = b"secret"; let plaintext = b"Hello, World!"; - let ciphertext = rc4_crypt(key, plaintext); - let decrypted = rc4_crypt(key, &ciphertext); + let ciphertext = rc4_crypt(key, plaintext).expect("test key is valid"); + let decrypted = rc4_crypt(key, &ciphertext).expect("test key is valid"); assert_eq!(decrypted, plaintext); } @@ -594,7 +773,7 @@ mod tests { // Known test vector for RC4 let key = b"Key"; let plaintext = b"Plaintext"; - let ciphertext = rc4_crypt(key, plaintext); + let ciphertext = rc4_crypt(key, plaintext).expect("test key is valid"); // RC4("Key", "Plaintext") should produce known bytes // This is a basic sanity check @@ -602,7 +781,7 @@ mod tests { assert_ne!(&ciphertext[..], plaintext); // Verify decryption works - let decrypted = rc4_crypt(key, &ciphertext); + let decrypted = rc4_crypt(key, &ciphertext).expect("test key is valid"); assert_eq!(decrypted, plaintext); } @@ -646,7 +825,8 @@ mod tests { #[test] fn test_compute_object_key() { let file_key = vec![0x01, 0x02, 0x03, 0x04, 0x05]; - let object_key = compute_object_key(&file_key, 1, 0, EncryptionVersion::V2); + let object_key = compute_object_key(&file_key, 1, 0, EncryptionVersion::V2) + .expect("test object key is valid"); // Object key should be at most 16 bytes assert!(object_key.len() <= 16); @@ -657,8 +837,10 @@ mod tests { #[test] fn test_compute_object_key_aes() { let file_key = vec![0x01; 16]; - let object_key_rc4 = compute_object_key(&file_key, 1, 0, EncryptionVersion::V2); - let object_key_aes = compute_object_key(&file_key, 1, 0, EncryptionVersion::V4); + let object_key_rc4 = compute_object_key(&file_key, 1, 0, EncryptionVersion::V2) + .expect("test object key is valid"); + let object_key_aes = compute_object_key(&file_key, 1, 0, EncryptionVersion::V4) + .expect("test object key is valid"); // AES adds "sAlT" to the hash, so keys should differ assert_ne!(object_key_rc4, object_key_aes); @@ -704,4 +886,137 @@ mod tests { matches!(error, DecryptionError::InvalidData(message) if message == "AES ciphertext length must be a multiple of 16") ); } + + #[test] + fn decrypt_object_recursively_decrypts_annotation_strings() { + let decryptor = make_decryptor(true); + let object_number = 7; + let generation_number = 0; + let contents = b"H\xF6ll\xF6"; + let rich_contents = b"

H\xF6ll\xF6

"; + let object = ObjectVariant::IndirectObject(Box::new(IndirectObject::new( + object_number, + generation_number, + Some(ObjectVariant::Dictionary(Box::new(Dictionary::new( + BTreeMap::from([ + ( + "Contents".to_owned(), + ObjectVariant::LiteralString(encrypt_for_object( + &decryptor, + object_number, + generation_number, + contents, + )), + ), + ( + "RC".to_owned(), + ObjectVariant::Array(vec![ObjectVariant::HexString(encrypt_for_object( + &decryptor, + object_number, + generation_number, + rich_contents, + ))]), + ), + ( + "Subtype".to_owned(), + ObjectVariant::Name(b"FreeText".to_vec()), + ), + ("Parent".to_owned(), ObjectVariant::Reference(4)), + ]), + )))), + ))); + + let decrypted = decryptor.decrypt_object(object).expect("object decrypts"); + let ObjectVariant::IndirectObject(indirect) = decrypted else { + panic!("decrypted object should remain indirect"); + }; + assert_eq!(indirect.object_number, object_number); + assert_eq!(indirect.generation_number, generation_number); + let Some(ObjectVariant::Dictionary(dictionary)) = indirect.object else { + panic!("indirect object should contain a dictionary"); + }; + assert_eq!( + dictionary.get("Contents"), + Some(&ObjectVariant::LiteralString(contents.to_vec())) + ); + assert_eq!( + dictionary.get("RC"), + Some(&ObjectVariant::Array(vec![ObjectVariant::HexString( + rich_contents.to_vec() + )])) + ); + assert_eq!( + dictionary.get("Subtype"), + Some(&ObjectVariant::Name(b"FreeText".to_vec())) + ); + assert_eq!(dictionary.get("Parent"), Some(&ObjectVariant::Reference(4))); + } + + #[test] + fn decrypt_object_decrypts_stream_dictionary_strings() { + let decryptor = make_decryptor(true); + let object_number = 8; + let generation_number = 0; + let contents = b"appearance"; + let stream = StreamObject::new( + object_number, + generation_number, + Box::new(Dictionary::new(BTreeMap::from([( + "Label".to_owned(), + ObjectVariant::LiteralString(encrypt_for_object( + &decryptor, + object_number, + generation_number, + b"annotation appearance", + )), + )]))), + encrypt_for_object(&decryptor, object_number, generation_number, contents), + ); + + let decrypted = decryptor + .decrypt_object(ObjectVariant::Stream(stream)) + .expect("stream decrypts"); + let ObjectVariant::Stream(stream) = decrypted else { + panic!("decrypted object should remain a stream"); + }; + assert_eq!(stream.raw_data(), contents); + assert_eq!( + stream.dictionary.get("Label"), + Some(&ObjectVariant::LiteralString( + b"annotation appearance".to_vec() + )) + ); + } + + #[test] + fn decrypt_object_preserves_unencrypted_signature_contents() { + let decryptor = make_decryptor(true); + let object = ObjectVariant::IndirectObject(Box::new(IndirectObject::new( + 9, + 0, + Some(ObjectVariant::Dictionary(Box::new(Dictionary::new( + BTreeMap::from([ + ("Type".to_owned(), ObjectVariant::Name(b"Sig".to_vec())), + ( + "Contents".to_owned(), + ObjectVariant::HexString(vec![0, 0, 0, 0]), + ), + ]), + )))), + ))); + + let decrypted = decryptor + .decrypt_object(object) + .expect("signature decrypts"); + let ObjectVariant::IndirectObject(indirect) = decrypted else { + panic!("decrypted object should remain indirect"); + }; + let Some(ObjectVariant::Dictionary(dictionary)) = indirect.object else { + panic!("indirect object should contain a dictionary"); + }; + assert_eq!( + dictionary.get("Contents"), + Some(&ObjectVariant::HexString(vec![0, 0, 0, 0])) + ); + } } diff --git a/crates/pdf-document/src/diagnostic.rs b/crates/pdf-document/src/diagnostic.rs new file mode 100644 index 00000000..c3cc59c1 --- /dev/null +++ b/crates/pdf-document/src/diagnostic.rs @@ -0,0 +1,50 @@ +//! Read diagnostic types for the PDF reader. +//! +//! This module defines the recoverable issues that can be reported while +//! reading a PDF, together with the contextual information attached to each +//! diagnostic. + +use pdf_object::object_id::PdfObjectId; + +/// Categorizes a recoverable PDF read problem. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PdfReadDiagnosticKind { + /// The optional encryption dictionary could not be used. + MalformedEncryption, + /// An optional indirect object could not be parsed. + ObjectParse, + /// An optional object's encrypted data could not be decrypted. + ObjectDecryption, + /// A compressed object or its containing object stream could not be read. + CompressedObject, +} + +/// Describes a recoverable problem encountered while reading a PDF. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PdfReadDiagnostic { + /// The category of recoverable problem. + pub kind: PdfReadDiagnosticKind, + /// The byte offset associated with the problem, when available. + pub byte_offset: Option, + /// The indirect object associated with the problem, when available. + pub object: Option, + /// A human-readable rendering of the underlying error. + pub message: String, +} + +impl PdfReadDiagnostic { + /// Creates a diagnostic with the available PDF location context. + pub(crate) fn new( + kind: PdfReadDiagnosticKind, + byte_offset: Option, + object: Option, + error: impl std::fmt::Display, + ) -> Self { + Self { + kind, + byte_offset, + object, + message: error.to_string(), + } + } +} diff --git a/crates/pdf-document/src/error.rs b/crates/pdf-document/src/error.rs index 0b0825a5..11e15cc9 100644 --- a/crates/pdf-document/src/error.rs +++ b/crates/pdf-document/src/error.rs @@ -26,8 +26,10 @@ pub enum PdfReaderError { InvalidXrefAtOffset { offset: usize }, #[error("unsupported encryption version: {version}")] UnsupportedEncryptionVersion { version: i32 }, - #[error("decryption error: {0}")] - DecryptionError(#[from] DecryptionError), + #[error("incorrect password")] + IncorrectPassword, + #[error("unable to initialize document decryption: {0}")] + DecryptionSetup(String), #[error("missing document ID required for encryption")] MissingDocumentId, #[error( @@ -40,3 +42,13 @@ pub enum PdfReaderError { first_offset: usize, }, } + +impl PdfReaderError { + /// Converts a fatal encryption setup failure into the reader's public error model. + pub(crate) fn from_decryption_setup(error: DecryptionError) -> Self { + match error { + DecryptionError::IncorrectPassword => Self::IncorrectPassword, + error => Self::DecryptionSetup(error.to_string()), + } + } +} diff --git a/crates/pdf-document/src/lib.rs b/crates/pdf-document/src/lib.rs index a2747bf3..c150e34e 100644 --- a/crates/pdf-document/src/lib.rs +++ b/crates/pdf-document/src/lib.rs @@ -1,8 +1,10 @@ -pub mod decryption; +mod decryption; +pub mod diagnostic; pub mod document; -pub mod encryption; +mod encryption; pub mod error; pub mod object_stream; pub mod page; pub mod pages; pub mod reader; +pub mod report; diff --git a/crates/pdf-document/src/object_stream.rs b/crates/pdf-document/src/object_stream.rs index 87b1d28e..54f768c8 100644 --- a/crates/pdf-document/src/object_stream.rs +++ b/crates/pdf-document/src/object_stream.rs @@ -6,6 +6,15 @@ use pdf_parser::parser::PdfParser; use crate::error::PdfReaderError; +/// An object extracted from a compressed PDF object stream. +#[derive(Debug, Clone)] +pub(crate) struct CompressedObject { + /// The extracted object's indirect object number. + pub(crate) number: usize, + /// The extracted object value. + pub(crate) value: ObjectVariant, +} + /// Parses an object stream (PDF 1.5+) and extracts all objects stored within it. /// /// # Parameters @@ -15,11 +24,11 @@ use crate::error::PdfReaderError; /// /// # Returns /// -/// A vector of `(object_number, ObjectVariant)` pairs for each object in the stream. -pub fn read_object_stream( +/// A vector of named compressed objects extracted from the stream. +pub(crate) fn read_object_stream( stream: &StreamObject, objects: &dyn ObjectResolver, -) -> Result, PdfReaderError> { +) -> Result, PdfReaderError> { let dict = stream.dictionary.as_ref(); // /N: number of objects in this stream (required) @@ -33,118 +42,41 @@ pub fn read_object_stream( // Parse the header: N pairs of (object_number, relative_byte_offset) let mut header_parser = PdfParser::from(data.as_ref()); - let mut obj_entries = Vec::with_capacity(n); + let mut object_entries = Vec::with_capacity(n); for _ in 0..n { let obj_num = header_parser.read_number::(true)?; let offset = header_parser.read_number::(true)?; - obj_entries.push((obj_num, offset)); + object_entries.push(ObjectStreamEntry { + number: obj_num, + relative_offset: offset, + }); } // Parse each object from the data section let mut result = Vec::with_capacity(n); - for &(obj_num, rel_offset) in &obj_entries { - let abs_offset = first.saturating_add(rel_offset); + for entry in object_entries { + let abs_offset = first.saturating_add(entry.relative_offset); let Some(slice) = data.get(abs_offset..) else { break; }; let mut obj_parser = PdfParser::from(slice); let object = obj_parser.parse_object(objects)?; - result.push((obj_num, object)); + result.push(CompressedObject { + number: entry.number, + value: object, + }); } Ok(result) } -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::as_conversions, - clippy::indexing_slicing -)] -mod tests { - use pdf_object::{dictionary::Dictionary, object_resolver::PassthroughResolver}; - use std::collections::BTreeMap; - - use super::*; - - #[test] - fn test_parse_object_stream_basic() { - // Build an object stream with 2 objects: - // Object 10: integer 42 - // Object 11: boolean true - // - // Header: "10 0 11 3 " - // obj 10 at relative offset 0 => "/First + 0" - // obj 11 at relative offset 3 => "/First + 3" - // Data after First: - // "42 true" - - let stream_content = b"10 0 11 3 42 true"; - let first = 10; // "10 0 11 3 " is 10 bytes - - let mut dict_map = BTreeMap::new(); - dict_map.insert("Type".to_string(), ObjectVariant::Name(b"ObjStm".to_vec())); - dict_map.insert("N".to_string(), ObjectVariant::Integer(2)); - dict_map.insert("First".to_string(), ObjectVariant::Integer(first as i64)); - dict_map.insert( - "Length".to_string(), - ObjectVariant::Integer(stream_content.len() as i64), - ); - - let stream = StreamObject::new( - 99, // object number of the stream itself - 0, - Box::new(Dictionary::new(dict_map)), - stream_content.to_vec(), - ); - - let result = read_object_stream(&stream, &PassthroughResolver).unwrap(); - assert_eq!(result.len(), 2); - - assert_eq!(result[0].0, 10); - assert_eq!(result[0].1, ObjectVariant::Integer(42)); - - assert_eq!(result[1].0, 11); - assert_eq!(result[1].1, ObjectVariant::Boolean(true)); - } - - #[test] - fn test_parse_object_stream_with_dict() { - // Object 5: a dictionary << /Key /Value >> - // Header: "5 0 " - // Data: "<< /Key /Value >>" - let stream_content = b"5 0 << /Key /Value >>"; - let first = 4; // "5 0 " is 4 bytes - - let mut dict_map = BTreeMap::new(); - dict_map.insert("Type".to_string(), ObjectVariant::Name(b"ObjStm".to_vec())); - dict_map.insert("N".to_string(), ObjectVariant::Integer(1)); - dict_map.insert("First".to_string(), ObjectVariant::Integer(first as i64)); - dict_map.insert( - "Length".to_string(), - ObjectVariant::Integer(stream_content.len() as i64), - ); - - let stream = StreamObject::new( - 99, - 0, - Box::new(Dictionary::new(dict_map)), - stream_content.to_vec(), - ); - - let result = read_object_stream(&stream, &PassthroughResolver).unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].0, 5); - match &result[0].1 { - ObjectVariant::Dictionary(d) => { - assert_eq!(d.get("Key"), Some(&ObjectVariant::Name(b"Value".to_vec()))); - } - other => panic!("Expected dictionary, got {:?}", other), - } - } +/// Locates one object within an object stream's decoded bytes. +struct ObjectStreamEntry { + /// The object's indirect object number. + number: usize, + /// The object's byte offset relative to the stream's `/First` value. + relative_offset: usize, } diff --git a/crates/pdf-document/src/pages.rs b/crates/pdf-document/src/pages.rs index ea70a357..16de7023 100644 --- a/crates/pdf-document/src/pages.rs +++ b/crates/pdf-document/src/pages.rs @@ -125,197 +125,3 @@ impl PdfPages { } } } - -#[cfg(test)] -mod tests { - use std::rc::Rc; - - use pdf_color_space::color_space::ColorSpace; - use pdf_resources::{resource::Resource, resources::Resources}; - - use super::*; - use crate::page::PdfPage; - - /// Builds a `Resources` value with one color space entry. We use `ColorSpace::DeviceGray` - /// because it requires no parsing infrastructure and is cheap to construct. - fn resources_with_color_space(name: &str) -> Resources { - let mut res = Resources::default(); - res.color_spaces.insert( - name.to_owned(), - Resource::ColorSpace(Rc::new(ColorSpace::DeviceGray)), - ); - res - } - - #[test] - fn leaf_page_with_no_resources_inherits_parent_resources() { - let parent = resources_with_color_space("CS1"); - - let mut page = PdfPage { - resources: None, - contents: None, - annotations: None, - media_box: None, - }; - - PdfPages::apply_resource_inheritance(std::slice::from_mut(&mut page), &parent); - - let inherited = page - .resources - .expect("page should have inherited resources"); - assert!( - inherited.color_spaces.contains_key("CS1"), - "inherited resources should contain the parent color space" - ); - } - - #[test] - fn leaf_page_child_entries_take_priority_during_inheritance() { - // Parent has a color space "CS1" (DeviceGray). - let parent = resources_with_color_space("CS1"); - - // Child already defines "CS1" (DeviceRGB) and also has "CS2". - let mut child_res = Resources::default(); - child_res.color_spaces.insert( - "CS1".to_owned(), - Resource::ColorSpace(Rc::new(ColorSpace::DeviceRGB)), - ); - child_res.color_spaces.insert( - "CS2".to_owned(), - Resource::ColorSpace(Rc::new(ColorSpace::DeviceGray)), - ); - - let mut page = PdfPage { - resources: Some(child_res), - contents: None, - annotations: None, - media_box: None, - }; - - PdfPages::apply_resource_inheritance(std::slice::from_mut(&mut page), &parent); - - let result = page - .resources - .as_ref() - .expect("page should still have resources"); - - // "CS1" must remain the child's version (DeviceRGB), not replaced by the parent's (DeviceGray). - assert!( - matches!( - result.color_spaces.get("CS1"), - Some(Resource::ColorSpace(cs)) if matches!(cs.as_ref(), ColorSpace::DeviceRGB) - ), - "child CS1 should not be overwritten by the parent" - ); - - // "CS2" stays. - assert!( - result.color_spaces.contains_key("CS2"), - "child CS2 should still be present" - ); - } - - #[test] - fn parent_entries_are_inherited_when_absent_from_child() { - // Parent defines two color spaces: "CS1" and "CS2". - let mut parent = resources_with_color_space("CS1"); - parent.color_spaces.insert( - "CS2".to_owned(), - Resource::ColorSpace(Rc::new(ColorSpace::DeviceRGB)), - ); - - // Child only defines "CS1" (DeviceRGB); it is missing "CS2". - let mut child_res = Resources::default(); - child_res.color_spaces.insert( - "CS1".to_owned(), - Resource::ColorSpace(Rc::new(ColorSpace::DeviceRGB)), - ); - - let mut page = PdfPage { - resources: Some(child_res), - contents: None, - annotations: None, - media_box: None, - }; - - PdfPages::apply_resource_inheritance(std::slice::from_mut(&mut page), &parent); - - let result = page.resources.as_ref().expect("page should have resources"); - - // "CS1" remains the child's version (DeviceRGB). - assert!( - matches!( - result.color_spaces.get("CS1"), - Some(Resource::ColorSpace(cs)) if matches!(cs.as_ref(), ColorSpace::DeviceRGB) - ), - "child CS1 should not be overwritten by the parent" - ); - - // "CS2" is inherited from the parent because the child didn't define it. - assert!( - result.color_spaces.contains_key("CS2"), - "parent CS2 should have been inherited into the child" - ); - } - - #[test] - fn leaf_page_without_media_box_inherits_parent_media_box() { - let parent_mb = MediaBox { - left: 0.0, - bottom: 0.0, - right: 595.0, - top: 842.0, - }; - - let mut page = PdfPage { - resources: None, - contents: None, - annotations: None, - media_box: None, - }; - - PdfPages::apply_media_box_inheritance(std::slice::from_mut(&mut page), &parent_mb); - - let inherited = page - .media_box - .expect("page should have inherited media box"); - assert_eq!(inherited.right, 595.0); - assert_eq!(inherited.top, 842.0); - } - - #[test] - fn leaf_page_with_own_media_box_is_not_overwritten() { - let parent_mb = MediaBox { - left: 0.0, - bottom: 0.0, - right: 595.0, - top: 842.0, - }; - - let child_mb = MediaBox { - left: 0.0, - bottom: 0.0, - right: 200.0, - top: 300.0, - }; - - let mut page = PdfPage { - resources: None, - contents: None, - annotations: None, - media_box: Some(child_mb), - }; - - PdfPages::apply_media_box_inheritance(std::slice::from_mut(&mut page), &parent_mb); - - let result = page.media_box.expect("page should still have media box"); - assert_eq!( - result.right, 200.0, - "child MediaBox should not be overwritten" - ); - assert_eq!( - result.top, 300.0, - "child MediaBox should not be overwritten" - ); - } -} diff --git a/crates/pdf-document/src/reader.rs b/crates/pdf-document/src/reader.rs index b247f482..a987fb7b 100644 --- a/crates/pdf-document/src/reader.rs +++ b/crates/pdf-document/src/reader.rs @@ -1,13 +1,16 @@ use std::collections::{BTreeMap, HashMap}; -use crate::decryption::DocumentDecryptor; +use crate::decryption::{DecryptionError, DocumentDecryptor}; +use crate::diagnostic::{PdfReadDiagnostic, PdfReadDiagnosticKind}; use crate::document::PdfDocument; +use crate::encryption::EncryptDictionary; use crate::error::PdfReaderError; -use crate::object_stream::read_object_stream; +use crate::object_stream::{CompressedObject, read_object_stream}; use crate::page::PdfPage; use crate::pages::PdfPages; +use crate::report::PdfReadReport; use pdf_content_stream::ContentStreamIdAllocator; -use pdf_object::indirect_object::IndirectObject; +use pdf_object::object_id::PdfObjectId; use pdf_object::object_lookup::ObjectLookupExt; use pdf_object::object_resolver::{ObjectResolver, PassthroughResolver}; use pdf_object::{ @@ -22,316 +25,392 @@ use pdf_parser::parser::PdfParser; use pdf_resources::object_reader::{ReadCycleTracker, ReadFromDictionary}; use pdf_resources::resource_cache::DefaultResourceCache; -use crate::encryption::EncryptDictionary; +const MAX_RESOLVE_ITERATIONS: usize = 16; +const EMPTY_PASSWORD: &[u8] = b""; +/// Reads PDF documents from byte slices. #[derive(Default)] pub struct PdfReader; impl PdfReader { - /// Reads and parses a PDF document from raw bytes. - /// - /// This method performs the following steps: - /// 1. Parses the PDF header when present and validates the version - /// 2. Builds the cross-reference index to locate all objects - /// 3. Checks for encryption and resolves the Encrypt dictionary first - /// 4. Loads all objects referenced in the xref table - /// 5. Extracts the document catalog and page tree - /// - /// # Parameters - /// - /// - `input`: Raw PDF file bytes - /// - `password`: The document password (user or owner password) - /// - /// # Returns - /// - /// Returns a `PdfDocument` containing the parsed objects and page structure. - pub fn read_from_bytes( + /// Reads a PDF and returns both the document and recoverable read diagnostics. + pub fn read_with_report( &self, input: &[u8], password: Option<&[u8]>, - ) -> Result { + ) -> Result { let mut parser = PdfParser::from(input); + validate_header(&mut parser)?; - // Parse and validate the PDF header when it is present near the start of the file. - if let Some(version) = parser.parse_header_in_opening_bytes()? - && version.major() > 2 - { - return Err(PdfReaderError::UnsupportedVersion( - version.major(), - version.minor(), - )); - } - - // Build the cross-reference index let CrossReferenceTable { entries, mut trailer, } = parser.build_xref_table()?; + let mut diagnostics = Vec::new(); + let encryption = EncryptionContext::from_trailer( + &mut trailer, + &entries, + &mut parser, + password.unwrap_or(EMPTY_PASSWORD), + &mut diagnostics, + )?; + let objects = + ObjectLoader::new(&entries, &mut parser, encryption, &mut diagnostics).load()?; + let document = PdfDocument { + pages: extract_page_tree(&trailer, &mut objects.into_resolver())?, + }; - // Check for encryption and handle it before loading other objects. - let decryptor = if let Some(encrypt_ref) = trailer.dictionary.take("Encrypt") { - match load_encrypt_dictionary(encrypt_ref, &entries, &mut parser) { - Ok(encryption) => { - // Get the document ID from the trailer (required for encryption) - let document_id = extract_document_id(&trailer)?; + Ok(PdfReadReport::new(document, diagnostics)) + } - const EMPTY_PASSWORD: &[u8] = b""; + /// Reads a PDF and discards recoverable diagnostics. + pub fn read_from_bytes( + &self, + input: &[u8], + password: Option<&[u8]>, + ) -> Result { + self.read_with_report(input, password) + .map(PdfReadReport::into_document) + } +} - // Create the decryptor by authenticating with the password - let decryptor = DocumentDecryptor::new( - &encryption, - &document_id, - password.unwrap_or(EMPTY_PASSWORD), - )?; +/// Validates the optional header near the start of a PDF. +fn validate_header(parser: &mut PdfParser) -> Result<(), PdfReaderError> { + if let Some(version) = parser.parse_header_in_opening_bytes()? + && version.major() > 2 + { + return Err(PdfReaderError::UnsupportedVersion( + version.major(), + version.minor(), + )); + } + Ok(()) +} - Some(decryptor) - } - Err(error) if is_recoverable_optional_object_error(&error) => None, - Err(error) => return Err(error), +/// Holds decryption state needed while loading objects. +struct EncryptionContext { + decryptor: Option, + dictionary_object_number: Option, +} + +impl EncryptionContext { + /// Creates decryption state from the trailer's optional encryption entry. + fn from_trailer( + trailer: &mut Trailer, + entries: &BTreeMap, + parser: &mut PdfParser, + password: &[u8], + diagnostics: &mut Vec, + ) -> Result { + let Some(encrypt_reference) = trailer.dictionary.take("Encrypt") else { + return Ok(Self { + decryptor: None, + dictionary_object_number: None, + }); + }; + let dictionary_object_number = encrypt_reference.try_object_number().ok(); + let encryption = match load_encrypt_dictionary(encrypt_reference, entries, parser) { + Ok(encryption) => encryption, + Err(error) if is_recoverable_optional_object_error(&error) => { + diagnostics.push(PdfReadDiagnostic::new( + PdfReadDiagnosticKind::MalformedEncryption, + None, + dictionary_object_number.map(object_id), + error, + )); + return Ok(Self { + decryptor: None, + dictionary_object_number: None, + }); } - } else { - None + Err(error) => return Err(error), }; + let document_id = extract_document_id(trailer)?; + let decryptor = DocumentDecryptor::new(&encryption, &document_id, password) + .map_err(PdfReaderError::from_decryption_setup)?; + Ok(Self { + decryptor: Some(decryptor), + dictionary_object_number, + }) + } - // Load all objects from the xref table, decrypting streams if needed - let mut objects = load_objects_with_decryption(&entries, &mut parser, decryptor.as_ref())?; - - // Extract catalog and page tree - let pages = extract_page_tree(&trailer, &mut objects)?; - - Ok(PdfDocument { pages }) + /// Returns the decryptor unless the object is the encryption dictionary itself. + fn decryptor_for(&self, object: Option) -> Option<&DocumentDecryptor> { + let object_number = object.map(|identifier| identifier.number); + (object_number != self.dictionary_object_number) + .then_some(self.decryptor.as_ref()) + .flatten() } } -/// Extracts the page tree from the document catalog using a shared resource cache. -/// -/// Follows the chain: Trailer → /Root (Catalog) → /Pages (Page Tree). -/// A `ResourceCache` is threaded through the traversal so that resources referenced -/// by the same PDF object number are parsed once and shared via `Rc`. -fn extract_page_tree( - trailer: &Trailer, - objects: &mut dyn ObjectResolver, -) -> Result, PdfReaderError> { - // Get the document catalog via the /Root entry in the trailer - let catalog = trailer.dictionary.required_dictionary("Root", objects)?; - - // Get the page tree via the /Pages entry in the catalog - let pages_dict = catalog.required_dictionary("Pages", objects)?; - - let mut cache = DefaultResourceCache::default(); - let mut cycle_tracker = ReadCycleTracker::default(); - let mut content_stream_ids = ContentStreamIdAllocator::new(); - let pages = PdfPages::from_dictionary( - pages_dict, - objects, - &mut cache, - &mut cycle_tracker, - &mut content_stream_ids, - )? - .unwrap_or_default(); - Ok(pages) +/// Loads xref-addressable objects while retaining enough state for retries. +struct ObjectLoader<'a> { + entries: &'a BTreeMap, + parser: &'a mut PdfParser<'a>, + encryption: EncryptionContext, + objects: ObjectCollection, + object_streams: HashMap>, + diagnostics: &'a mut Vec, } -/// Loads and parses the encryption dictionary from the PDF. -/// -/// According to PDF specification, the encryption dictionary itself is NOT encrypted. -/// This allows a reader to parse the encryption parameters needed to decrypt -/// the rest of the document. -/// -/// The `/Encrypt` entry in the trailer is typically an indirect reference, so we need -/// to locate and parse that object first before we can understand how to decrypt -/// other objects. -/// -/// # Parameters -/// -/// - `encrypt_ref`: The `/Encrypt` entry from the trailer (usually an indirect reference). -/// - `entries`: The cross-reference table entries for locating objects. -/// - `parser`: The PDF parser for reading object data. -/// -/// # Returns -/// -/// Returns an `EncryptDictionary` containing the encryption parameters. -fn load_encrypt_dictionary( - encrypt_ref: ObjectVariant, - entries: &BTreeMap, - parser: &mut PdfParser, -) -> Result { - // If the Encrypt entry is an indirect reference, we need to load that object. - let encrypt_dict = match encrypt_ref { - ObjectVariant::Reference(obj_num) => { - // Look up the object in the xref table - let entry = entries - .get(&obj_num) - .ok_or(ObjectError::FailedResolveObjectReference { obj_num })?; - - let byte_offset = entry - .byte_offset() - .ok_or(ObjectError::FailedResolveObjectReference { obj_num })?; - - // Parse the encryption object at the specified offset - let object = parser.parse_object_at(byte_offset, &PassthroughResolver)?; - - // Extract the dictionary from the parsed object - match object { - ObjectVariant::Dictionary(dict) => dict, - ObjectVariant::IndirectObject(indirect) => match indirect.object { - Some(ObjectVariant::Dictionary(dict)) => dict, - _ => { - return Err(ObjectError::FailedResolveDictionaryObject { - resolved_type: "IndirectObject", - } - .into()); - } - }, - other => { - return Err(ObjectError::FailedResolveDictionaryObject { - resolved_type: other.name(), - } - .into()); - } - } +impl<'a> ObjectLoader<'a> { + /// Creates an object loader for one parsed cross-reference table. + fn new( + entries: &'a BTreeMap, + parser: &'a mut PdfParser<'a>, + encryption: EncryptionContext, + diagnostics: &'a mut Vec, + ) -> Self { + Self { + entries, + parser, + encryption, + objects: ObjectCollection::default(), + object_streams: HashMap::new(), + diagnostics, } - ObjectVariant::Dictionary(dict) => dict, - other => { - return Err(ObjectError::FailedResolveDictionaryObject { - resolved_type: other.name(), + } + + /// Loads normal and compressed objects, returning all successfully loaded objects. + fn load(mut self) -> Result { + let mut pending = self.load_normal_objects()?; + self.load_available_compressed_objects(false)?; + let mut iterations = 0usize; + + while !pending.is_empty() && iterations < MAX_RESOLVE_ITERATIONS { + iterations = iterations.saturating_add(1); + let previous_count = pending.len(); + pending = self.retry_pending_objects(pending)?; + let loaded_compressed = self.load_available_compressed_objects(false)?; + if pending.len() == previous_count && !loaded_compressed { + break; } - .into()); } - }; - // Parse the encryption dictionary - EncryptDictionary::from_dictionary(&encrypt_dict, &PassthroughResolver) -} - -/// Extracts the document ID from the trailer's /ID array. -/// -/// The /ID entry is an array of two byte strings that uniquely identify the document. -/// The first element is used for encryption key derivation. -/// -/// # Parameters -/// -/// - `trailer`: The PDF trailer containing the /ID entry. -/// -/// # Returns -/// -/// The first element of the /ID array as a byte vector. -fn extract_document_id(trailer: &Trailer) -> Result, PdfReaderError> { - // Get the first element of the /ID array - let first_element = trailer - .dictionary - .required_array("ID", &PassthroughResolver)? - .first() - .ok_or(PdfReaderError::MissingDocumentId)?; + if let Some(offset) = pending.first().copied() { + return Err(PdfReaderError::UnresolvedObjects { + count: pending.len(), + iterations, + first_offset: offset, + }); + } + self.load_available_compressed_objects(true)?; + Ok(LoadedObjects(self.objects)) + } - Ok(first_element.try_bytes(&PassthroughResolver)?.to_vec()) -} + /// Loads each normal xref entry once and returns references that need a later retry. + fn load_normal_objects(&mut self) -> Result, PdfReaderError> { + let mut pending = Vec::new(); + for entry in self.entries.values().rev() { + let CrossReferenceEntryType::Normal { byte_offset, .. } = entry.entry_type else { + continue; + }; + if byte_offset == 0 { + continue; + } + let result = self.load_object(byte_offset); + self.handle_load_result(byte_offset, &mut pending, result)?; + } + Ok(pending) + } -/// Loads all objects referenced in the cross-reference table with optional decryption. -/// -/// This function extends `load_objects` by decrypting stream data when a decryptor -/// is provided. Only streams are decrypted; strings within dictionaries are decrypted -/// separately during object resolution. -/// -/// # Parameters -/// -/// - `entries`: The cross-reference table entries. -/// - `parser`: The PDF parser for reading object data. -/// - `decryptor`: Optional decryptor for encrypted documents. -/// -/// # Returns -/// -/// Returns an `ObjectCollection` containing all parsed (and decrypted) objects. -fn load_objects_with_decryption( - entries: &BTreeMap, - parser: &mut PdfParser, - decryptor: Option<&DocumentDecryptor>, -) -> Result { - /// Maximum number of retry iterations for resolving forward references. - /// Real PDFs rarely need more than 1–2; this is a safety cap. - const MAX_RESOLVE_ITERATIONS: usize = 16; - - let mut objects = ObjectCollection::default(); - let mut unresolved: Vec = Vec::new(); - let mut parsed_obj_streams: HashMap> = HashMap::new(); - - // Pass 1: Load all type-1 (normal) entries — these are objects at byte offsets, - // including the object streams themselves. - for entry in entries.values().rev() { - let CrossReferenceEntryType::Normal { byte_offset, .. } = entry.entry_type else { - continue; - }; + /// Retries object offsets that previously referred to unavailable objects. + fn retry_pending_objects(&mut self, pending: Vec) -> Result, PdfReaderError> { + let mut still_pending = Vec::new(); + for byte_offset in pending { + let result = self.load_object(byte_offset); + self.handle_load_result(byte_offset, &mut still_pending, result)?; + } + Ok(still_pending) + } - // Offset 0 always points to the PDF header, not a valid indirect object. - // Some PDF generators emit normal entries with offset 0 for deleted/null objects. - if byte_offset == 0 { - continue; + /// Classifies a single object load result according to the best-effort policy. + fn handle_load_result( + &mut self, + byte_offset: usize, + pending: &mut Vec, + result: Result<(), ObjectLoadError>, + ) -> Result<(), PdfReaderError> { + match result { + Ok(()) => Ok(()), + Err(ObjectLoadError::Reader(error)) if is_unresolved_reference(&error) => { + pending.push(byte_offset); + Ok(()) + } + Err(ObjectLoadError::Reader(error)) if is_recoverable_optional_object_error(&error) => { + self.diagnostics.push(PdfReadDiagnostic::new( + PdfReadDiagnosticKind::ObjectParse, + Some(byte_offset), + None, + error, + )); + Ok(()) + } + Err(ObjectLoadError::Decryption(error)) => { + self.diagnostics.push(PdfReadDiagnostic::new( + PdfReadDiagnosticKind::ObjectDecryption, + Some(byte_offset), + None, + error, + )); + Ok(()) + } + Err(ObjectLoadError::Reader(error)) => Err(error), } + } - match try_load_object(byte_offset, parser, &mut objects, decryptor) { - Ok(()) => {} - Err(PdfReaderError::ParserError(ParserError::ObjectError( - ObjectError::FailedResolveObjectReference { .. }, - ))) => { - unresolved.push(byte_offset); + /// Parses, decrypts, and inserts one normal indirect object. + fn load_object(&mut self, byte_offset: usize) -> Result<(), ObjectLoadError> { + let object = self + .parser + .parse_object_at(byte_offset, &self.objects) + .map_err(PdfReaderError::ParserError)?; + if matches!(object, ObjectVariant::Reference(_)) { + return Err(PdfReaderError::UnexpectedReference { + offset: byte_offset, } - Err(e) if is_recoverable_optional_object_error(&e) => {} - Err(e) => return Err(e), + .into()); } + let identifier = object_identifier(&object); + let object = match self.encryption.decryptor_for(identifier) { + Some(decryptor) => decryptor.decrypt_object(object)?, + None => object, + }; + self.objects + .insert(object) + .map_err(PdfReaderError::ObjectError)?; + Ok(()) } - load_available_compressed_objects(entries, &mut objects, &mut parsed_obj_streams, false)?; - - // Iteratively retry unresolved objects until convergence or the cap is reached. - // Each iteration may resolve objects whose dependencies were loaded in a prior - // iteration, unblocking further progress. - let mut iterations: usize = 0; - while !unresolved.is_empty() && iterations < MAX_RESOLVE_ITERATIONS { - iterations = iterations.saturating_add(1); - let mut still_unresolved: Vec = Vec::new(); - - for byte_offset in &unresolved { - match try_load_object(*byte_offset, parser, &mut objects, decryptor) { - Ok(()) => {} - Err(PdfReaderError::ParserError(ParserError::ObjectError( - ObjectError::FailedResolveObjectReference { .. }, - ))) => { - still_unresolved.push(*byte_offset); + /// Loads compressed objects whose containing object streams are available. + fn load_available_compressed_objects(&mut self, strict: bool) -> Result { + let mut loaded_any = false; + for (&object_number, entry) in self.entries { + let CrossReferenceEntryType::Compressed { + object_stream_number, + index_within_stream, + } = entry.entry_type + else { + continue; + }; + if self.objects.get(object_number).is_some() { + continue; + } + if !self.object_streams.contains_key(&object_stream_number) { + let Some(stream_object) = self.objects.get(object_stream_number) else { + if strict { + return Err(ObjectError::FailedResolveObjectReference { + obj_num: object_stream_number, + } + .into()); + } + continue; + }; + let stream = stream_object.try_stream(&self.objects)?; + match read_object_stream(stream, &self.objects) { + Ok(objects) => { + self.object_streams.insert(object_stream_number, objects); + } + Err(error) if !strict && is_recoverable_optional_object_error(&error) => { + self.diagnostics.push(PdfReadDiagnostic::new( + PdfReadDiagnosticKind::CompressedObject, + None, + Some(object_id(object_stream_number)), + error, + )); + continue; + } + Err(error) => return Err(error), } - Err(e) if is_recoverable_optional_object_error(&e) => {} - Err(e) => return Err(e), + } + if let Some(object) = self + .object_streams + .get(&object_stream_number) + .and_then(|objects| objects.get(index_within_stream)) + { + if object.number != object_number { + self.diagnostics.push(PdfReadDiagnostic::new( + PdfReadDiagnosticKind::CompressedObject, + None, + Some(object_id(object_number)), + "xref compressed object number differed from its object stream entry", + )); + } + self.objects + .insert_compressed(object_number, object.value.clone()); + loaded_any = true; } } + Ok(loaded_any) + } +} - let loaded_compressed = load_available_compressed_objects( - entries, - &mut objects, - &mut parsed_obj_streams, - false, - )?; +/// Wraps the loaded collection so ownership can cross the reader boundary explicitly. +struct LoadedObjects(ObjectCollection); - // No progress — the remaining objects are truly unresolvable. - if still_unresolved.len() == unresolved.len() && !loaded_compressed { - break; - } +impl LoadedObjects { + /// Returns the owned collection as an object resolver. + fn into_resolver(self) -> ObjectCollection { + self.0 + } +} - unresolved = still_unresolved; +/// Distinguishes reader failures from recoverable object decryption failures. +enum ObjectLoadError { + /// An ordinary reader failure. + Reader(PdfReaderError), + /// A failure while decrypting one object. + Decryption(DecryptionError), +} + +impl From for ObjectLoadError { + /// Wraps a reader error produced while loading an object. + fn from(error: PdfReaderError) -> Self { + Self::Reader(error) } +} + +impl From for ObjectLoadError { + /// Wraps an object decryption error. + fn from(error: DecryptionError) -> Self { + Self::Decryption(error) + } +} - if let Some(&first_offset) = unresolved.first() { - return Err(PdfReaderError::UnresolvedObjects { - count: unresolved.len(), - iterations, - first_offset, - }); +/// Extracts a named object identifier when an object carries one. +fn object_identifier(object: &ObjectVariant) -> Option { + match object { + ObjectVariant::IndirectObject(indirect) => Some(PdfObjectId { + number: indirect.object_number, + generation: indirect.generation_number, + }), + ObjectVariant::Stream(stream) => Some(PdfObjectId { + number: stream.object_number, + generation: stream.generation_number, + }), + _ => None, } +} - load_available_compressed_objects(entries, &mut objects, &mut parsed_obj_streams, true)?; +/// Creates an identifier for an object whose generation is not available in xref context. +fn object_id(number: usize) -> PdfObjectId { + PdfObjectId { + number, + generation: 0, + } +} - Ok(objects) +/// Returns whether an object failure should be retried after more objects load. +fn is_unresolved_reference(error: &PdfReaderError) -> bool { + matches!( + error, + PdfReaderError::ParserError(ParserError::ObjectError( + ObjectError::FailedResolveObjectReference { .. } + )) + ) } -/// Returns whether a bulk-loaded object can be skipped until a required reference proves otherwise. +/// Returns whether a bulk-loaded object may be skipped without preventing a usable document. fn is_recoverable_optional_object_error(error: &PdfReaderError) -> bool { matches!( error, @@ -340,120 +419,85 @@ fn is_recoverable_optional_object_error(error: &PdfReaderError) -> bool { ) } -/// Unpacks compressed xref entries whose object streams are already available. -/// -/// Early calls run in best-effort mode because object streams can themselves be -/// blocked behind normal-object dependencies. The final call is strict and keeps -/// missing object streams as hard errors. -fn load_available_compressed_objects( - entries: &BTreeMap, - objects: &mut ObjectCollection, - parsed_obj_streams: &mut HashMap>, - strict: bool, -) -> Result { - let mut loaded_any = false; - - for (&obj_num, entry) in entries { - let CrossReferenceEntryType::Compressed { - object_stream_number, - index_within_stream, - } = entry.entry_type - else { - continue; - }; - - if objects.get(obj_num).is_some() { - continue; - } - - // Parse the object stream if we haven't already - if let std::collections::hash_map::Entry::Vacant(e) = - parsed_obj_streams.entry(object_stream_number) - { - let Some(stream_obj) = objects.get(object_stream_number) else { - if strict { - return Err(ObjectError::FailedResolveObjectReference { - obj_num: object_stream_number, - } - .into()); - } - continue; - }; - - let stream_obj = stream_obj.try_stream(&*objects)?; - - let unpacked = read_object_stream(stream_obj, &*objects)?; - e.insert(unpacked); - } - - // Extract the object at the specified index and insert it. - // Use `insert_compressed` to overwrite: the object-stream version is authoritative. - if let Some(cached) = parsed_obj_streams.get(&object_stream_number) - && let Some((_cached_num, obj)) = cached.get(index_within_stream) - { - objects.insert_compressed(obj_num, obj.clone()); - loaded_any = true; - } - } - - Ok(loaded_any) +/// Resolves the page tree rooted at the trailer catalog. +fn extract_page_tree( + trailer: &Trailer, + objects: &mut dyn ObjectResolver, +) -> Result, PdfReaderError> { + let catalog = trailer.dictionary.required_dictionary("Root", objects)?; + let pages = catalog.required_dictionary("Pages", objects)?; + let mut cache = DefaultResourceCache::default(); + let mut cycle_tracker = ReadCycleTracker::default(); + let mut content_stream_ids = ContentStreamIdAllocator::new(); + Ok(PdfPages::from_dictionary( + pages, + objects, + &mut cache, + &mut cycle_tracker, + &mut content_stream_ids, + )? + .unwrap_or_default()) } -/// Parses a single object at `byte_offset`, validates it, optionally decrypts it, -/// and inserts it into the collection. -fn try_load_object( - byte_offset: usize, +/// Resolves and parses the trailer's encryption dictionary without decrypting it. +fn load_encrypt_dictionary( + encrypt_reference: ObjectVariant, + entries: &BTreeMap, parser: &mut PdfParser, - objects: &mut ObjectCollection, - decryptor: Option<&DocumentDecryptor>, -) -> Result<(), PdfReaderError> { - let object = parser.parse_object_at(byte_offset, objects)?; - - if matches!(object, ObjectVariant::Reference(_)) { - return Err(PdfReaderError::UnexpectedReference { - offset: byte_offset, - }); - } - - let object = if let Some(decryptor) = decryptor { - decrypt_object(object, decryptor)? - } else { - object +) -> Result { + let dictionary = match encrypt_reference { + ObjectVariant::Reference(object_number) => { + let entry = + entries + .get(&object_number) + .ok_or(ObjectError::FailedResolveObjectReference { + obj_num: object_number, + })?; + let byte_offset = + entry + .byte_offset() + .ok_or(ObjectError::FailedResolveObjectReference { + obj_num: object_number, + })?; + dictionary_from_object(parser.parse_object_at(byte_offset, &PassthroughResolver)?)? + } + ObjectVariant::Dictionary(dictionary) => dictionary, + object => { + return Err(ObjectError::FailedResolveDictionaryObject { + resolved_type: object.name(), + } + .into()); + } }; - - objects.insert(object)?; - Ok(()) + EncryptDictionary::from_dictionary(&dictionary, &PassthroughResolver) } -/// Decrypts an object's stream data if applicable. -/// -/// Only stream objects are decrypted. The object number and generation number -/// are used to derive the object-specific encryption key. -fn decrypt_object( +/// Extracts a dictionary from an object used as an encryption dictionary. +fn dictionary_from_object( object: ObjectVariant, - decryptor: &DocumentDecryptor, -) -> Result { +) -> Result, PdfReaderError> { match object { - ObjectVariant::IndirectObject(indirect) => { - // Check if the inner object is a stream - if let Some(ObjectVariant::Stream(stream)) = indirect.object { - let decrypted_stream = decryptor.decrypt_stream_object(&stream)?; - // Create a new IndirectObject with the decrypted stream - let new_indirect = IndirectObject::new( - indirect.object_number, - indirect.generation_number, - Some(ObjectVariant::Stream(decrypted_stream)), - ); - return Ok(ObjectVariant::IndirectObject(Box::new(new_indirect))); + ObjectVariant::Dictionary(dictionary) => Ok(dictionary), + ObjectVariant::IndirectObject(indirect) => match indirect.object { + Some(ObjectVariant::Dictionary(dictionary)) => Ok(dictionary), + _ => Err(ObjectError::FailedResolveDictionaryObject { + resolved_type: "IndirectObject", } - // Non-stream indirect objects pass through unchanged - Ok(ObjectVariant::IndirectObject(indirect)) - } - ObjectVariant::Stream(stream) => { - let decrypted = decryptor.decrypt_stream_object(&stream)?; - Ok(ObjectVariant::Stream(decrypted)) + .into()), + }, + object => Err(ObjectError::FailedResolveDictionaryObject { + resolved_type: object.name(), } - // Other objects pass through unchanged. - other => Ok(other), + .into()), } } + +/// Extracts the first trailer document identifier used for encryption key derivation. +fn extract_document_id(trailer: &Trailer) -> Result, PdfReaderError> { + let identifier = trailer + .dictionary + .required_array("ID", &PassthroughResolver)? + .first() + .ok_or(PdfReaderError::MissingDocumentId)?; + Ok(identifier.try_bytes(&PassthroughResolver)?.to_vec()) +} diff --git a/crates/pdf-document/src/report.rs b/crates/pdf-document/src/report.rs new file mode 100644 index 00000000..57060e7e --- /dev/null +++ b/crates/pdf-document/src/report.rs @@ -0,0 +1,39 @@ +//! Read report types for the PDF reader. +//! +//! This module groups the successful read result and the recoverable diagnostics +//! collected while loading a document best-effort. + +use crate::document::PdfDocument; + +use super::diagnostic::PdfReadDiagnostic; + +/// A successfully read document and the recoverable problems encountered while reading it. +pub struct PdfReadReport { + document: PdfDocument, + diagnostics: Vec, +} + +impl PdfReadReport { + /// Creates a report from the loaded document and any recoverable diagnostics. + pub(crate) fn new(document: PdfDocument, diagnostics: Vec) -> Self { + Self { + document, + diagnostics, + } + } + + /// Returns the usable document produced by the best-effort read. + pub fn document(&self) -> &PdfDocument { + &self.document + } + + /// Returns recoverable problems encountered while reading the document. + pub fn diagnostics(&self) -> &[PdfReadDiagnostic] { + &self.diagnostics + } + + /// Consumes the report and returns its document. + pub fn into_document(self) -> PdfDocument { + self.document + } +} diff --git a/crates/pdf-document/tests/object_stream.rs b/crates/pdf-document/tests/object_stream.rs new file mode 100644 index 00000000..76e2fc22 --- /dev/null +++ b/crates/pdf-document/tests/object_stream.rs @@ -0,0 +1,84 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::as_conversions +)] + +use pdf_document::reader::PdfReader; + +fn push_xref_stream_entry(data: &mut Vec, entry_type: u8, field2: usize, field3: u8) { + data.push(entry_type); + data.extend_from_slice( + &u16::try_from(field2) + .expect("xref field2 should fit in 16 bits for this fixture") + .to_be_bytes(), + ); + data.push(field3); +} + +fn build_object_stream_pdf() -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(b"%PDF-1.7\n"); + + let obj1_offset = data.len(); + data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + + let pages_object = b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"; + let page_object = b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"; + let pages_offset = 0usize; + let page_offset = pages_object.len().saturating_add(1); + + let object_stream_header = format!("2 {pages_offset} 3 {page_offset} ").into_bytes(); + let first = object_stream_header.len(); + let mut object_stream_data = object_stream_header; + object_stream_data.extend_from_slice(pages_object); + object_stream_data.push(b' '); + object_stream_data.extend_from_slice(page_object); + + let obj4_offset = data.len(); + data.extend_from_slice( + format!( + "4 0 obj\n<< /Type /ObjStm /N 2 /First {first} /Length {} >>\nstream\n", + object_stream_data.len() + ) + .as_bytes(), + ); + data.extend_from_slice(&object_stream_data); + data.extend_from_slice(b"\nendstream\nendobj\n"); + + let obj5_offset = data.len(); + let mut xref_stream_data = Vec::new(); + push_xref_stream_entry(&mut xref_stream_data, 0, 0, u8::MAX); + push_xref_stream_entry(&mut xref_stream_data, 1, obj1_offset, 0); + push_xref_stream_entry(&mut xref_stream_data, 2, 4, 0); + push_xref_stream_entry(&mut xref_stream_data, 2, 4, 1); + push_xref_stream_entry(&mut xref_stream_data, 1, obj4_offset, 0); + push_xref_stream_entry(&mut xref_stream_data, 1, obj5_offset, 0); + data.extend_from_slice( + format!( + "5 0 obj\n<< /Type /XRef /Size 6 /W [1 2 1] /Index [0 6] /Root 1 0 R /Length {} >>\nstream\n", + xref_stream_data.len() + ) + .as_bytes(), + ); + data.extend_from_slice(&xref_stream_data); + data.extend_from_slice(b"\nendstream\nendobj\n"); + + data.extend_from_slice(format!("startxref\n{obj5_offset}\n%%EOF").as_bytes()); + data +} + +#[test] +fn compressed_page_tree_loads_through_the_reader() { + let reader = PdfReader; + let data = build_object_stream_pdf(); + + let document = reader + .read_from_bytes(&data, None) + .expect("document should load"); + + assert_eq!(document.page_count(), 1); + assert!(document.get_page(0).is_some()); +} diff --git a/crates/pdf-document/tests/pages.rs b/crates/pdf-document/tests/pages.rs new file mode 100644 index 00000000..7a297c4f --- /dev/null +++ b/crates/pdf-document/tests/pages.rs @@ -0,0 +1,261 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::as_conversions +)] + +use std::collections::BTreeMap; + +use pdf_color_space::color_space::ColorSpace; +use pdf_content_stream::ContentStreamIdAllocator; +use pdf_document::pages::PdfPages; +use pdf_object::{ + dictionary::Dictionary, error::ObjectError, object_resolver::ObjectResolver, + object_variant::ObjectVariant, +}; +use pdf_resources::{ + object_reader::{ReadCycleTracker, ReadFromDictionary}, + resource_cache::DefaultResourceCache, +}; + +struct MapResolver { + objects: BTreeMap, +} + +impl ObjectResolver for MapResolver { + fn resolve_object<'a>( + &'a self, + obj: &'a ObjectVariant, + ) -> Result<&'a ObjectVariant, ObjectError> { + match obj { + ObjectVariant::Reference(object_number) => { + self.objects + .get(object_number) + .ok_or(ObjectError::FailedResolveObjectReference { + obj_num: *object_number, + }) + } + _ => Ok(obj), + } + } +} + +fn name(value: &str) -> ObjectVariant { + ObjectVariant::Name(value.as_bytes().to_vec()) +} + +fn integer(value: i64) -> ObjectVariant { + ObjectVariant::Integer(value) +} + +fn color_space(value: &str) -> ObjectVariant { + ObjectVariant::Array(vec![name(value)]) +} + +fn resources_dictionary(entries: &[(&str, &str)]) -> Dictionary { + let color_spaces = Dictionary::new(BTreeMap::from( + entries + .iter() + .map(|(name, value)| ((*name).to_owned(), color_space(value))) + .collect::>(), + )); + + Dictionary::new(BTreeMap::from([( + "ColorSpace".to_owned(), + ObjectVariant::Dictionary(Box::new(color_spaces)), + )])) +} + +fn page_dictionary( + object_number: usize, + parent: Option, + resources: Option, + media_box: Option<[i64; 4]>, +) -> Dictionary { + let mut entries = BTreeMap::from([("Type".to_owned(), name("Page"))]); + + if let Some(parent) = parent { + entries.insert("Parent".to_owned(), ObjectVariant::Reference(parent)); + } + + if let Some(resources) = resources { + entries.insert( + "Resources".to_owned(), + ObjectVariant::Dictionary(Box::new(resources)), + ); + } + + if let Some([left, bottom, right, top]) = media_box { + entries.insert( + "MediaBox".to_owned(), + ObjectVariant::Array(vec![ + integer(left), + integer(bottom), + integer(right), + integer(top), + ]), + ); + } + + let mut dictionary = Dictionary::new(entries); + dictionary.object_number = Some(object_number); + dictionary +} + +fn pages_dictionary( + object_number: usize, + kids: &[usize], + resources: Option, + media_box: Option<[i64; 4]>, +) -> Dictionary { + let mut entries = BTreeMap::from([("Type".to_owned(), name("Pages"))]); + + entries.insert( + "Kids".to_owned(), + ObjectVariant::Array(kids.iter().copied().map(ObjectVariant::Reference).collect()), + ); + entries.insert("Count".to_owned(), integer(kids.len() as i64)); + + if let Some(resources) = resources { + entries.insert( + "Resources".to_owned(), + ObjectVariant::Dictionary(Box::new(resources)), + ); + } + + if let Some([left, bottom, right, top]) = media_box { + entries.insert( + "MediaBox".to_owned(), + ObjectVariant::Array(vec![ + integer(left), + integer(bottom), + integer(right), + integer(top), + ]), + ); + } + + let mut dictionary = Dictionary::new(entries); + dictionary.object_number = Some(object_number); + dictionary +} + +#[test] +fn inherited_resources_and_media_box_apply_to_leaf_pages() { + let root_number = 1; + let page_number = 2; + + let root_pages = pages_dictionary( + root_number, + &[page_number], + Some(resources_dictionary(&[("CS1", "DeviceGray")])), + Some([0, 0, 595, 842]), + ); + + let page = page_dictionary(page_number, Some(root_number), None, None); + + let resolver = MapResolver { + objects: BTreeMap::from([( + page_number, + ObjectVariant::Dictionary(Box::new(page.clone())), + )]), + }; + + let mut cache = DefaultResourceCache::default(); + let mut cycle_tracker = ReadCycleTracker::default(); + let mut id_allocator = ContentStreamIdAllocator::new(); + + let pages = PdfPages::from_dictionary( + &root_pages, + &resolver, + &mut cache, + &mut cycle_tracker, + &mut id_allocator, + ) + .expect("page tree should parse") + .expect("root pages node should produce pages"); + + assert_eq!(pages.len(), 1); + + let page = pages.first().expect("page should exist"); + let resources = page + .resources + .as_ref() + .expect("page should inherit resources"); + assert!(matches!( + resources.color_space("CS1"), + Some(ColorSpace::DeviceGray) + )); + + let media_box = page + .media_box + .as_ref() + .expect("page should inherit media box"); + assert_eq!(media_box.right, 595.0); + assert_eq!(media_box.top, 842.0); +} + +#[test] +fn child_resources_keep_their_own_entries_while_inheriting_missing_ones() { + let root_number = 1; + let page_number = 2; + + let root_pages = pages_dictionary( + root_number, + &[page_number], + Some(resources_dictionary(&[ + ("CS1", "DeviceGray"), + ("CS2", "DeviceRGB"), + ])), + Some([0, 0, 595, 842]), + ); + + let page = page_dictionary( + page_number, + Some(root_number), + Some(resources_dictionary(&[("CS1", "DeviceRGB")])), + Some([0, 0, 200, 300]), + ); + + let resolver = MapResolver { + objects: BTreeMap::from([( + page_number, + ObjectVariant::Dictionary(Box::new(page.clone())), + )]), + }; + + let mut cache = DefaultResourceCache::default(); + let mut cycle_tracker = ReadCycleTracker::default(); + let mut id_allocator = ContentStreamIdAllocator::new(); + + let pages = PdfPages::from_dictionary( + &root_pages, + &resolver, + &mut cache, + &mut cycle_tracker, + &mut id_allocator, + ) + .expect("page tree should parse") + .expect("root pages node should produce pages"); + + let page = pages.first().expect("page should exist"); + let resources = page.resources.as_ref().expect("page should have resources"); + + assert!(matches!( + resources.color_space("CS1"), + Some(ColorSpace::DeviceRGB) + )); + assert!(matches!( + resources.color_space("CS2"), + Some(ColorSpace::DeviceRGB) + )); + + let media_box = page + .media_box + .as_ref() + .expect("page should keep own media box"); + assert_eq!(media_box.right, 200.0); + assert_eq!(media_box.top, 300.0); +} diff --git a/crates/pdf-document/tests/reader.rs b/crates/pdf-document/tests/reader.rs index 5304e8fb..d5e63e71 100644 --- a/crates/pdf-document/tests/reader.rs +++ b/crates/pdf-document/tests/reader.rs @@ -6,7 +6,7 @@ clippy::as_conversions )] -use pdf_document::reader::PdfReader; +use pdf_document::{diagnostic::PdfReadDiagnosticKind, reader::PdfReader}; fn format_xref_entry(offset: usize, generation: u16, used: bool) -> String { let kind = if used { 'n' } else { 'f' }; @@ -750,15 +750,21 @@ fn test_issue1293_pdf_loads_normally() { fn test_pdfbox4352_pdf_loads_normally() { let reader = PdfReader; let data = build_pdfbox4352_like_pdf(); - let result = reader.read_from_bytes(&data, None); + let result = reader.read_with_report(&data, None); assert!( result.is_ok(), "PDFBOX-4352-style PDF should load despite malformed optional encryption object: {:?}", result.err() ); - let doc = result.unwrap(); - assert_eq!(doc.page_count(), 1); - assert!(doc.get_page(0).is_some()); + let report = result.unwrap(); + assert_eq!(report.document().page_count(), 1); + assert!(report.document().get_page(0).is_some()); + assert!( + report + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.kind == PdfReadDiagnosticKind::MalformedEncryption) + ); } #[test] diff --git a/crates/pdf-object/src/lib.rs b/crates/pdf-object/src/lib.rs index fb5740bd..1b6e24c6 100644 --- a/crates/pdf-object/src/lib.rs +++ b/crates/pdf-object/src/lib.rs @@ -2,6 +2,7 @@ pub mod cross_reference_table; pub mod dictionary; pub mod error; pub mod indirect_object; +pub mod object_id; pub mod object_lookup; pub mod object_resolver; pub mod object_variant; diff --git a/crates/pdf-object/src/object_id.rs b/crates/pdf-object/src/object_id.rs new file mode 100644 index 00000000..6f54f194 --- /dev/null +++ b/crates/pdf-object/src/object_id.rs @@ -0,0 +1,8 @@ +/// Identifies an indirect PDF object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdfObjectId { + /// The indirect object's number. + pub number: usize, + /// The indirect object's generation number. + pub generation: usize, +}