pdftract/crates/pdftract-core/src/encryption/detection.rs
jedarden f85e5149dd feat(pdftract-91e1i): HTTP fetch sequence implementation
Implement orchestration layer connecting HttpRangeSource to Phase 1.3
xref resolver and Phase 1.4 document model for remote PDF access:

- Document::open_remote() public API for remote PDF loading
- Progressive tail fetch (16 KB → 1 MB) for startxref location
- Xref forward-scan disabled for remote sources (via is_remote check)
- Page-by-page on-demand fetch via HttpRangeSource caching
- Resource lazy load through XrefResolver cache
- HEAD probe with 405 fallback, no Content-Length handling

Acceptance criteria:
 open_remote(url) returns Document with correct page count
 HEAD failure modes (405, no Content-Length, 401) handled
 xref forward-scan disabled for remote (is_remote check)
 Page-by-page on-demand fetch (HttpRangeSource LRU cache)
 INV-8 maintained (all errors return Result)

Files modified:
- crates/pdftract-core/src/document.rs (Document::open_remote, from_source)
- crates/pdftract-core/src/remote.rs (progressive tail fetch)
- crates/pdftract-core/src/lib.rs (re-exports)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:17:00 -04:00

744 lines
26 KiB
Rust

//! Encryption dictionary detection for PDF trailers.
//!
//! This module implements detection of PDF encryption metadata from the trailer's
//! /Encrypt dictionary. It parses the encryption version, revision, key length,
//! owner/user password hashes, permissions, and crypt filters.
//!
//! Per PDF 2.0 spec (ISO 32000-2:2017), sections 7.6.1-7.6.3.
use std::collections::BTreeMap;
use crate::{emit, diagnostics::{Diagnostic, DiagCode}};
use crate::parser::object::{ObjRef, PdfDict, PdfObject};
/// Encryption metadata extracted from the PDF's /Encrypt dictionary.
#[derive(Debug, Clone)]
pub struct EncryptionInfo {
/// Algorithm version (V): 1, 2, 4, or 5
pub version: u8,
/// Algorithm revision (R): 2, 3, 4, 5, or 6
pub revision: u8,
/// Key length in bits: 40, 128, or 256
pub key_length: u32,
/// Owner password hash (/O)
pub owner_hash: Vec<u8>,
/// User password hash (/U)
pub user_hash: Vec<u8>,
/// Permissions flags (/P for V<5, /Perms for V=5)
pub perms: u32,
/// File ID (first 16 bytes of /ID[0] from trailer)
pub file_id: Vec<u8>,
/// Crypt filter dictionary for V=4 and V=5
pub crypt_filters: Option<CryptFiltersV4>,
/// Encrypted user encryption key (/UE) for V=5 (AES-256)
pub user_key_encrypted: Option<Vec<u8>>,
/// Encrypted owner encryption key (/OE) for V=5 (AES-256)
pub owner_key_encrypted: Option<Vec<u8>>,
/// Encrypted permissions (/Perms) for V=5 (AES-256)
pub perms_encrypted: Option<Vec<u8>>,
}
/// Crypt filter metadata for V=4 and V=5 encryption.
///
/// Per PDF 2.0 spec 7.6.5, crypt filters allow different encryption methods
/// for streams and strings.
#[derive(Debug, Clone)]
pub struct CryptFiltersV4 {
/// Default crypt filter for streams (/StmF)
pub stream_filter: String,
/// Default crypt filter for strings (/StrF)
pub string_filter: String,
/// Named crypt filter definitions (/CF)
pub filters: BTreeMap<String, CryptFilterDef>,
}
/// Individual crypt filter definition.
///
/// Per PDF 2.0 spec 7.6.5, Table 23.
#[derive(Debug, Clone)]
pub struct CryptFilterDef {
/// Crypt filter method (/CFM): V2 (RC4), AESV2, AESV3
pub cfm: CryptFilterMethod,
/// Key length in bits (/Length)
pub length: Option<u32>,
/// When this filter is applied (/AuthEvent)
pub auth_event: AuthEvent,
}
/// Crypt filter method (CFM).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CryptFilterMethod {
/// No encryption (identity)
Identity,
/// RC4 (V2)
V2,
/// AES-128 (AESV2)
AesV2,
/// AES-256 (AESV3)
AesV3,
}
/// When a crypt filter is applied.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthEvent {
/// Applied when opening the document (default)
DocOpen,
/// Applied when performing specific EFS operations
EfoOpen,
}
/// Detect encryption metadata from the trailer's /Encrypt dictionary.
///
/// This function parses the trailer's /Encrypt dictionary and returns
/// structured encryption metadata. It validates the encryption filter
/// (must be /Standard) and checks that required fields are present.
///
/// # Arguments
///
/// * `trailer` - The trailer dictionary from the PDF
/// * `resolver` - The cross-reference resolver for dereferencing indirect objects
/// * `diagnostics` - Diagnostics vector to emit errors to
///
/// # Returns
///
/// * `Some(EncryptionInfo)` - If the PDF is encrypted and the /Encrypt dictionary is valid
/// * `None` - If the PDF is not encrypted, or if the encryption dictionary is invalid
///
/// # Diagnostics
///
/// This function emits diagnostics for:
/// * `ENCRYPTION_UNSUPPORTED` - Non-Standard encryption filter
/// * `ENCRYPTION_INVALID_DICT` - Missing required fields or invalid field values
pub fn detect_encryption(
trailer: &PdfDict,
resolver: &impl XrefResolver,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<EncryptionInfo> {
// Step 1: Look up /Encrypt in trailer
let encrypt_ref = trailer.get("/Encrypt")?;
// Step 2: Resolve ObjRef via XrefResolver
let encrypt_dict = match encrypt_ref {
PdfObject::Ref(obj_ref) => resolver.resolve(*obj_ref).ok()?,
PdfObject::Dict(dict) => PdfObject::Dict(dict.clone()),
_ => return None,
};
let encrypt_dict = encrypt_dict.as_dict()?;
// Step 3: Check /Filter == /Standard
let filter = encrypt_dict.get("/Filter")?;
let filter_name = filter.as_name()?;
if filter_name != "Standard" {
// Emit ENCRYPTION_UNSUPPORTED with the filter name
emit!(
diagnostics,
EncryptionUnsupported,
message = format!(
"Unsupported encryption filter: /{}. Only /Standard is supported.",
filter_name
)
);
return None;
}
// Step 4: Parse /V, /R, /KeyLength
let version = parse_version(encrypt_dict)?;
let revision = parse_revision(encrypt_dict)?;
let key_length = parse_key_length(encrypt_dict, version)?;
// Step 5: Parse /O, /U with length validation
let owner_hash = match parse_hash_with_diagnostics(encrypt_dict, "/O", revision, diagnostics) {
Some(hash) => hash,
None => return None,
};
let user_hash = match parse_hash_with_diagnostics(encrypt_dict, "/U", revision, diagnostics) {
Some(hash) => hash,
None => return None,
};
// Step 6: Parse /P (32-bit signed int; perms bitfield)
let perms = parse_permissions(encrypt_dict)?;
// Step 7: For V>=4, parse /CF, /StmF, /StrF
let crypt_filters = if version >= 4 {
Some(parse_crypt_filters(encrypt_dict)?)
} else {
None
};
// Step 8: For V=5, parse /Perms, /UE, /OE
let (perms, user_key_encrypted, owner_key_encrypted, perms_encrypted) = if version == 5 {
let perms = parse_v5_perms(encrypt_dict)?;
let user_key_encrypted = parse_v5_key(encrypt_dict, "/UE")?;
let owner_key_encrypted = parse_v5_key(encrypt_dict, "/OE")?;
let perms_encrypted = parse_v5_perms_bytes(encrypt_dict)?;
(perms, Some(user_key_encrypted), Some(owner_key_encrypted), Some(perms_encrypted))
} else {
(perms, None, None, None)
};
// Step 9: Extract /ID[0] from trailer
let file_id = extract_file_id(trailer);
// Step 10: Return Some(EncryptionInfo)
Some(EncryptionInfo {
version,
revision,
key_length,
owner_hash,
user_hash,
perms,
file_id,
crypt_filters,
user_key_encrypted,
owner_key_encrypted,
perms_encrypted,
})
}
/// Trait for xref resolution (to avoid coupling to specific resolver type).
///
/// This trait is implemented by the actual XrefResolver from the xref module,
/// and also by MockResolver for testing.
pub trait XrefResolver {
/// Resolve an object reference to its underlying PDF object.
///
/// # Arguments
///
/// * `obj_ref` - The object reference to resolve
///
/// # Returns
///
/// * `Ok(PdfObject)` - The resolved object
/// * `Err(ResolveError)` - If the object cannot be resolved
fn resolve(&self, obj_ref: ObjRef) -> Result<PdfObject, ResolveError>;
}
/// Resolution error type.
#[derive(Debug, Clone)]
pub enum ResolveError {
/// Object reference not found in the xref table
NotFound(ObjRef),
/// Circular reference detected during resolution
CircularRef(ObjRef),
/// I/O error during resolution (with error message)
Io(String),
}
// Implement the detection module's XrefResolver trait for the actual xref::XrefResolver
impl XrefResolver for crate::parser::xref::XrefResolver {
fn resolve(&self, obj_ref: ObjRef) -> Result<PdfObject, ResolveError> {
// Convert ResolveError from xref module to detection module's ResolveError
self.resolve(obj_ref).map_err(|e| match e {
crate::parser::xref::ResolveError::NotFound(obj_ref) => ResolveError::NotFound(obj_ref),
crate::parser::xref::ResolveError::CircularRef(obj_ref) => ResolveError::CircularRef(obj_ref),
crate::parser::xref::ResolveError::Io(msg) => ResolveError::Io(msg),
})
}
}
/// Parse /V field from encryption dictionary.
fn parse_version(dict: &PdfDict) -> Option<u8> {
dict.get("/V")?.as_int()?.try_into().ok()
}
/// Parse /R field from encryption dictionary.
fn parse_revision(dict: &PdfDict) -> Option<u8> {
dict.get("/R")?.as_int()?.try_into().ok()
}
/// Parse /KeyLength field from encryption dictionary.
///
/// If not present, derive from V: V=1/2 -> 40, V=4 -> 128, V=5 -> 256
fn parse_key_length(dict: &PdfDict, version: u8) -> Option<u32> {
if let Some(key_length) = dict.get("/Length") {
let length = key_length.as_int()? as u32;
// Validate key length is a multiple of 8
if length % 8 != 0 {
return None;
}
return Some(length);
}
// Default key lengths per version
match version {
1 | 2 => Some(40),
4 => Some(128),
5 => Some(256),
_ => None,
}
}
/// Parse a hash field (/O or /U) with length validation and diagnostic emission.
fn parse_hash_with_diagnostics(
dict: &PdfDict,
key: &str,
revision: u8,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Vec<u8>> {
let hash_bytes = dict.get(key)?.as_string()?.to_vec();
// Validate length
let expected_len = if revision >= 5 { 48 } else { 32 };
if hash_bytes.len() != expected_len {
emit!(
diagnostics,
EncryptionInvalidDict,
message = format!(
"Invalid {} length: expected {} bytes, got {}",
key,
expected_len,
hash_bytes.len()
)
);
return None;
}
Some(hash_bytes)
}
/// Parse /P permissions field.
fn parse_permissions(dict: &PdfDict) -> Option<u32> {
dict.get("/P")?.as_int()?.try_into().ok()
}
/// Parse /Perms field for V=5 encryption.
fn parse_v5_perms(dict: &PdfDict) -> Option<u32> {
let perms_bytes = dict.get("/Perms")?.as_string()?;
if perms_bytes.len() != 16 {
return None;
}
// First 4 bytes are the permissions (little-endian)
let mut bytes = [0u8; 4];
bytes.copy_from_slice(&perms_bytes[..4]);
Some(u32::from_le_bytes(bytes))
}
/// Parse /UE or /OE field for V=5 encryption (32-byte encrypted key).
fn parse_v5_key(dict: &PdfDict, key: &str) -> Option<Vec<u8>> {
let key_bytes = dict.get(key)?.as_string()?.to_vec();
if key_bytes.len() != 32 {
return None;
}
Some(key_bytes)
}
/// Parse /Perms field as raw bytes for V=5 encryption (16-byte encrypted permissions).
fn parse_v5_perms_bytes(dict: &PdfDict) -> Option<Vec<u8>> {
let perms_bytes = dict.get("/Perms")?.as_string()?.to_vec();
if perms_bytes.len() != 16 {
return None;
}
Some(perms_bytes)
}
/// Extract first 16 bytes of /ID[0] from trailer.
fn extract_file_id(trailer: &PdfDict) -> Vec<u8> {
trailer
.get("/ID")
.and_then(|id| id.as_array())
.and_then(|arr| arr.first())
.and_then(|id| id.as_string())
.map(|s| s.iter().copied().take(16).collect())
.unwrap_or_default()
}
/// Parse crypt filter dictionary for V>=4 encryption.
fn parse_crypt_filters(dict: &PdfDict) -> Option<CryptFiltersV4> {
let stream_filter = parse_filter_name(dict.get("/StmF"))?;
let string_filter = parse_filter_name(dict.get("/StrF"))?;
// /CF is optional - if not present, use empty filters map
let filters = if let Some(cf_obj) = dict.get("/CF") {
let cf_dict = cf_obj.as_dict()?;
let mut filters = BTreeMap::new();
for (name, filter_def) in cf_dict {
let name_str = name.strip_prefix('/')?;
let def = parse_crypt_filter_def(filter_def.as_dict()?)?;
filters.insert(name_str.to_string(), def);
}
filters
} else {
BTreeMap::new()
};
Some(CryptFiltersV4 {
stream_filter,
string_filter,
filters,
})
}
/// Parse a filter name, defaulting to "Identity" if not present.
fn parse_filter_name(obj: Option<&PdfObject>) -> Option<String> {
match obj {
Some(PdfObject::Name(name)) => Some(name.strip_prefix('/').unwrap_or(name).to_string()),
Some(_) => None,
None => Some("Identity".to_string()),
}
}
/// Parse a single crypt filter definition.
fn parse_crypt_filter_def(dict: &PdfDict) -> Option<CryptFilterDef> {
let cfm = parse_cfm(dict.get("/CFM"))?;
let length = dict.get("/Length").and_then(|l| l.as_int()).map(|l| l as u32);
let auth_event = parse_auth_event(dict.get("/AuthEvent")).unwrap_or(AuthEvent::DocOpen);
Some(CryptFilterDef {
cfm,
length,
auth_event,
})
}
/// Parse crypt filter method (/CFM).
fn parse_cfm(obj: Option<&PdfObject>) -> Option<CryptFilterMethod> {
match obj {
Some(PdfObject::Name(name)) => match name.strip_prefix('/') {
Some("Identity") => Some(CryptFilterMethod::Identity),
Some("V2") => Some(CryptFilterMethod::V2),
Some("AESV2") => Some(CryptFilterMethod::AesV2),
Some("AESV3") => Some(CryptFilterMethod::AesV3),
_ => None,
},
None => Some(CryptFilterMethod::Identity),
_ => None,
}
}
/// Parse auth event (/AuthEvent).
fn parse_auth_event(obj: Option<&PdfObject>) -> Option<AuthEvent> {
match obj {
Some(PdfObject::Name(name)) => match name.strip_prefix('/') {
Some("DocOpen") => Some(AuthEvent::DocOpen),
Some("EFOpen") => Some(AuthEvent::EfoOpen),
_ => None,
},
None => Some(AuthEvent::DocOpen),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
// Mock resolver for testing
struct MockResolver;
impl XrefResolver for MockResolver {
fn resolve(&self, _obj_ref: ObjRef) -> Result<PdfObject, ResolveError> {
Err(ResolveError::NotFound(ObjRef::new(0, 0)))
}
}
fn make_dict(entries: Vec<(&str, PdfObject)>) -> PdfDict {
entries
.into_iter()
.map(|(k, v)| (k.into(), v))
.collect()
}
#[test]
fn test_no_encrypt_key() {
let trailer = make_dict(vec![]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_none());
assert!(diagnostics.is_empty());
}
#[test]
fn test_v1_r2_rc4_40() {
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
("/ID", PdfObject::Array(Box::new(vec![PdfObject::String(Box::new(vec![0u8; 16]))]))),
]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some());
assert!(diagnostics.is_empty());
let info = result.unwrap();
assert_eq!(info.version, 1);
assert_eq!(info.revision, 2);
assert_eq!(info.key_length, 40);
assert_eq!(info.owner_hash.len(), 32);
assert_eq!(info.user_hash.len(), 32);
assert_eq!(info.perms, 0xFFFFFFFF);
assert!(info.crypt_filters.is_none());
}
#[test]
fn test_v5_r6_aes_256() {
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(5)),
("/R", PdfObject::Integer(6)),
("/O", PdfObject::String(Box::new(vec![0u8; 48]))),
("/U", PdfObject::String(Box::new(vec![0u8; 48]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
("/UE", PdfObject::String(Box::new(vec![0u8; 32]))),
("/OE", PdfObject::String(Box::new(vec![0u8; 32]))),
("/Perms", PdfObject::String(Box::new({
let mut perms = [0u8; 16];
perms[0..4].copy_from_slice(&0xFFFFFFFFu32.to_le_bytes());
perms.to_vec()
}))),
]);
let trailer = make_dict(vec![
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
("/ID", PdfObject::Array(Box::new(vec![PdfObject::String(Box::new(vec![0u8; 16]))]))),
]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some());
assert!(diagnostics.is_empty());
let info = result.unwrap();
assert_eq!(info.version, 5);
assert_eq!(info.revision, 6);
assert_eq!(info.key_length, 256);
assert_eq!(info.owner_hash.len(), 48);
assert_eq!(info.user_hash.len(), 48);
assert_eq!(info.perms, 0xFFFFFFFF);
}
#[test]
fn test_non_standard_filter_emits_diagnostic() {
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Custom".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
]);
let trailer = make_dict(vec![("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict)))]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
// Non-Standard filter returns None
assert!(result.is_none());
// Should emit ENCRYPTION_UNSUPPORTED diagnostic
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagCode::EncryptionUnsupported);
assert!(diagnostics[0].message.contains("Custom"));
}
#[test]
fn test_invalid_o_length_emits_diagnostic() {
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 31]))), // Wrong length
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict)))]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
// Invalid /O length returns None
assert!(result.is_none());
// Should emit ENCRYPTION_INVALID_DICT diagnostic
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagCode::EncryptionInvalidDict);
assert!(diagnostics[0].message.contains("/O"));
assert!(diagnostics[0].message.contains("expected 32"));
}
#[test]
fn test_invalid_u_length_emits_diagnostic() {
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 31]))), // Wrong length
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict)))]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
// Invalid /U length returns None
assert!(result.is_none());
// Should emit ENCRYPTION_INVALID_DICT diagnostic
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagCode::EncryptionInvalidDict);
assert!(diagnostics[0].message.contains("/U"));
assert!(diagnostics[0].message.contains("expected 32"));
}
#[test]
fn test_v5_invalid_hash_length_emits_diagnostic() {
// For R>=5, /O and /U should be 48 bytes
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(5)),
("/R", PdfObject::Integer(6)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))), // Wrong length for R=6
("/U", PdfObject::String(Box::new(vec![0u8; 48]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict)))]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
// Invalid /O length returns None
assert!(result.is_none());
// Should emit ENCRYPTION_INVALID_DICT diagnostic
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagCode::EncryptionInvalidDict);
assert!(diagnostics[0].message.contains("/O"));
assert!(diagnostics[0].message.contains("expected 48"));
}
#[test]
fn test_v4_crypt_filters() {
let cf_dict = make_dict(vec![
("/CFM", PdfObject::Name("/AESV2".into())),
("/Length", PdfObject::Integer(128)),
]);
let filters = make_dict(vec![("/Identity", PdfObject::Dict(Box::new(cf_dict)))]);
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(4)),
("/R", PdfObject::Integer(4)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
("/StmF", PdfObject::Name("/Identity".into())),
("/StrF", PdfObject::Name("/Identity".into())),
("/CF", PdfObject::Dict(Box::new(filters))),
]);
let trailer = make_dict(vec![("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict)))]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some());
assert!(diagnostics.is_empty());
let info = result.unwrap();
assert_eq!(info.version, 4);
assert!(info.crypt_filters.is_some());
let cf = info.crypt_filters.unwrap();
assert_eq!(cf.stream_filter, "Identity");
assert_eq!(cf.string_filter, "Identity");
assert_eq!(cf.filters.len(), 1);
}
#[test]
fn test_missing_id() {
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict)))]);
let resolver = MockResolver;
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some());
assert!(diagnostics.is_empty());
let info = result.unwrap();
// Missing /ID should result in empty file_id
assert!(info.file_id.is_empty());
}
#[test]
fn test_all_v_r_combinations() {
// Test V=1, R=2 (RC4-40)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
("/ID", PdfObject::Array(Box::new(vec![PdfObject::String(Box::new(vec![0u8; 16]))]))),
]);
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &MockResolver, &mut diagnostics);
assert!(result.is_some());
assert_eq!(result.unwrap().key_length, 40);
// Test V=2, R=3 (RC4-128)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(2)),
("/R", PdfObject::Integer(3)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
("/ID", PdfObject::Array(Box::new(vec![PdfObject::String(Box::new(vec![0u8; 16]))]))),
]);
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &MockResolver, &mut diagnostics);
assert!(result.is_some());
assert_eq!(result.unwrap().key_length, 40);
// Test V=4, R=4 (RC4 or AES-128)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(4)),
("/R", PdfObject::Integer(4)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
("/ID", PdfObject::Array(Box::new(vec![PdfObject::String(Box::new(vec![0u8; 16]))]))),
]);
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &MockResolver, &mut diagnostics);
assert!(result.is_some());
assert_eq!(result.unwrap().key_length, 128);
}
}