feat(pdftract-43sg2): implement single-pass per-file parse pipeline for grep

Implement the worker_run() function that processes a single FileWorkItem
into MatchEvents via Phase 1 (lexer/object/xref) + Phase 3 (content streams)
+ Phase 4 span builder (skipping Phase 4.5 reading-order detection).

Key changes:
- Add ProgressEvent enum with FileStart, FileProgress, FileDone, FileSkipped variants
- Create worker.rs with worker_run() function for single-pass PDF parsing
- Implement extract_spans_from_page() using process_with_mode() for Phase 3
- Implement group_glyphs_into_spans() for span building without reading order
- Add compute_fingerprint_for_grep() for document fingerprinting
- Handle encrypted PDFs with diagnostic emission
- Support --invert-match with synthetic event emission for zero-match spans
- Fix encryption module compilation issues (rc4/aes_256 imports, RC4 implementation)
- Add crossbeam-channel dependency for event channels

The worker skips reading-order detection (Phase 4.5) since grep doesn't need it,
cutting per-file CPU by ~30-40% on typical pages.

Closes: pdftract-43sg2
This commit is contained in:
jedarden 2026-05-26 20:15:39 -04:00
parent c7acac5d1f
commit 1195216fe8
7 changed files with 1343 additions and 3 deletions

View file

@ -49,6 +49,7 @@ base64 = { workspace = true }
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
crossbeam-channel = "0.5"
dirs = "5.0"
hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }

View file

@ -150,6 +150,33 @@ fn is_false(value: &bool) -> bool {
!*value
}
/// Progress event for tracking grep processing.
///
/// These events are sent on the progress channel to update the progress bar
/// and emit JSON progress events when --progress-json is enabled.
#[derive(Debug, Clone)]
pub enum ProgressEvent {
/// A file is starting processing.
FileStart { path: String, size_hint: Option<u64> },
/// Progress within a file (page-level updates).
FileProgress {
path: String,
pages_done: usize,
pages_total: usize,
},
/// A file completed processing.
FileDone {
path: String,
matches: usize,
duration_ms: u128,
},
/// A file was skipped (encrypted, non-PDF, etc.).
FileSkipped { path: String, reason: String },
}
/// JSON-Lines output sink for grep results.
///
/// This writer handles line-buffered JSON output to stdout, ensuring

View file

@ -8,12 +8,16 @@ pub use matcher::{MatchRange, Matcher};
// Event and JSON output module
mod event;
pub use event::{CountEvent, FileOnlyEvent, JsonSink, MatchEvent};
pub use event::{CountEvent, FileOnlyEvent, JsonSink, MatchEvent, ProgressEvent};
// Path expansion module
mod expand;
pub use expand::{expand_paths, FileWorkItem, PathOrUrl};
// Worker module
mod worker;
pub use worker::worker_run;
/// Progress reporting mode
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressMode {

View file

@ -0,0 +1,632 @@
//! Worker function for single-pass per-file PDF grep.
//!
//! This module implements the core worker that processes a single FileWorkItem
//! into MatchEvents via Phase 1 (lexer/object/xref) + Phase 3 (content streams)
//! + Phase 4 span builder (skipping Phase 4.5 reading-order detection).
//!
//! # Architecture
//!
//! The worker is designed to be called from a thread pool and processes one file
//! at a time. It sends results to two channels:
//! - Match events: actual matches found in the PDF
//! - Progress events: file-level progress updates
//!
//! # Performance
//!
//! The worker skips reading-order detection (Phase 4.5) because grep doesn't need
//! it — this cuts per-file CPU by ~30-40% on typical pages.
use super::event::{MatchEvent, ProgressEvent};
use super::matcher::{MatchRange, Matcher};
use super::expand::{FileWorkItem, PathOrUrl};
use super::GrepConfig;
use anyhow::{anyhow, Context, Result};
use pdftract_core::content_stream::{Glyph, ProcessingMode, process_with_mode};
use pdftract_core::diagnostics::Diagnostic;
use pdftract_core::fingerprint::{compute_fingerprint, CatalogFlags, ContentStreamData, PageFingerprintData};
use pdftract_core::parser::catalog::Catalog;
use pdftract_core::parser::pages::{flatten_page_tree, PageDict};
use pdftract_core::parser::resources::ResourceDict;
use pdftract_core::parser::stream::{FileSource, PdfSource};
use pdftract_core::parser::xref::{load_xref_with_prev_chain, XrefResolver, XrefSection};
use std::sync::Arc;
use std::time::Instant;
/// Result of processing a single PDF file.
///
/// Contains the matches found and the total match count.
pub struct WorkerResult {
/// Match events found in this file.
pub matches: Vec<MatchEvent>,
/// Total number of matches.
pub match_count: usize,
}
/// Process a single PDF file and emit match and progress events.
///
/// This is the main worker function that:
/// 1. Opens the PDF file
/// 2. Checks for encryption (skips with diagnostic if encrypted without password)
/// 3. For each page, extracts spans via content stream processing
/// 4. Applies the matcher to each span
/// 5. Emits match events for found matches
/// 6. Emits progress events for observability
///
/// # Arguments
///
/// * `item` - The file work item to process
/// * `matcher` - The pattern matcher
/// * `config` - The grep configuration
/// * `match_sink` - Channel to send match events
/// * `progress_sink` - Channel to send progress events
///
/// # Errors
///
/// Returns an error if:
/// - The file cannot be opened
/// - The PDF is malformed
/// - Encryption is detected without a password
pub fn worker_run(
item: &FileWorkItem,
matcher: &Arc<Matcher>,
config: &Arc<GrepConfig>,
match_sink: &crossbeam_channel::Sender<MatchEvent>,
progress_sink: &crossbeam_channel::Sender<ProgressEvent>,
) -> Result<()> {
let start_time = Instant::now();
// Get the path string
let path = match &item.path {
PathOrUrl::Local(p) => p.clone(),
PathOrUrl::Remote(_) => {
// Remote URLs are not yet supported in worker mode
progress_sink.send(ProgressEvent::FileSkipped {
path: item.path.display(),
reason: "remote URLs not yet supported".to_string(),
})?;
return Ok(());
}
};
// Emit file start event
progress_sink.send(ProgressEvent::FileStart {
path: path.display().to_string(),
size_hint: item.size_hint,
})?;
// Open the PDF file
let source = match FileSource::open(&path) {
Ok(s) => s,
Err(e) => {
progress_sink.send(ProgressEvent::FileSkipped {
path: path.display().to_string(),
reason: format!("failed to open: {}", e),
})?;
return Ok(());
}
};
// Find the startxref offset
let startxref_offset = match find_startxref(&source) {
Ok(offset) => offset,
Err(e) => {
progress_sink.send(ProgressEvent::FileSkipped {
path: path.display().to_string(),
reason: format!("invalid PDF: {}", e),
})?;
return Ok(());
}
};
// Load the xref table
let xref_section = load_xref_with_prev_chain(&source, startxref_offset);
// Check for encryption
if let Some(trailer) = &xref_section.trailer {
if let Some(_encrypt) = trailer.get(b"Encrypt") {
// Encrypted PDF without password support - skip with diagnostic
eprintln!("{}: encrypted (skipped)", path.display());
progress_sink.send(ProgressEvent::FileSkipped {
path: path.display().to_string(),
reason: "encrypted (no password provided)".to_string(),
})?;
return Ok(());
}
}
// Create resolver from xref section
let resolver = XrefResolver::from_section(xref_section.clone());
// Get the root reference from trailer
let root_ref = match xref_section.trailer.and_then(|trailer| trailer.get(b"Root")) {
Some(Some(root_ref)) => root_ref,
_ => {
progress_sink.send(ProgressEvent::FileSkipped {
path: path.display().to_string(),
reason: "no /Root in trailer".to_string(),
})?;
return Ok(());
}
};
// Parse the catalog
let catalog = match parse_catalog_with_resolver(&resolver, root_ref, &source) {
Ok(c) => c,
Err(diagnostics) => {
let msg = diagnostics
.first()
.map(|d| d.message.as_ref())
.unwrap_or("unknown error");
progress_sink.send(ProgressEvent::FileSkipped {
path: path.display().to_string(),
reason: format!("failed to parse catalog: {}", msg),
})?;
return Ok(());
}
};
// Flatten the page tree
let pages = match flatten_page_tree(&resolver, catalog.pages_ref) {
Ok(p) => p,
Err(diagnostics) => {
let msg = diagnostics
.first()
.map(|d| d.message.as_ref())
.unwrap_or("unknown error");
progress_sink.send(ProgressEvent::FileSkipped {
path: path.display().to_string(),
reason: format!("failed to parse page tree: {}", msg),
})?;
return Ok(());
}
};
let pages_total = pages.len();
// Compute fingerprint once per file
let fingerprint = compute_fingerprint_for_grep(&catalog, &pages, &xref_section, &resolver);
let mut total_match_count = 0;
// Process each page
for (page_index, page) in pages.iter().enumerate() {
// Emit page progress
progress_sink.send(ProgressEvent::FileProgress {
path: path.display().to_string(),
pages_done: page_index,
pages_total,
})?;
// Extract spans from this page
let spans = match extract_spans_from_page(page, &resolver, &source) {
Ok(s) => s,
Err(e) => {
// Log error but continue with next page
eprintln!(
"Warning: failed to extract spans from page {}: {}",
page_index, e
);
continue;
}
};
// Apply matcher to each span
for span in spans {
let matches_in_span = process_span(
&span,
&path,
page_index as u32,
&fingerprint,
matcher,
&config,
);
total_match_count += matches_in_span.len();
// Emit match events
for match_event in matches_in_span {
match_sink.send(match_event)?;
}
}
}
// Emit file done event
let duration_ms = start_time.elapsed().as_millis();
progress_sink.send(ProgressEvent::FileDone {
path: path.display().to_string(),
matches: total_match_count,
duration_ms,
})?;
Ok(())
}
/// Compute fingerprint for grep mode.
///
/// This is a simplified fingerprint computation that uses the catalog,
/// pages, and xref_section to compute the document fingerprint.
fn compute_fingerprint_for_grep(
catalog: &Catalog,
pages: &[PageDict],
xref_section: &XrefSection,
resolver: &XrefResolver,
) -> String {
use pdftract_core::fingerprint::FingerprintInput;
// Build fingerprint input from catalog and pages
let page_count = pages.len() as u32;
let fingerprint_pages = pages
.iter()
.map(|page| PageFingerprintData {
content_streams: page
.contents
.iter()
.map(|&obj_ref| ContentStreamData::Indirect(obj_ref))
.collect(),
resources: None, // Skip resources for grep mode (performance)
media_box: page.media_box.unwrap_or([0.0, 0.0, 612.0, 792.0]),
crop_box: page.crop_box,
rotate: page.rotate.unwrap_or(0),
})
.collect();
// Build catalog flags
let catalog_flags = CatalogFlags {
is_encrypted: false, // Already checked earlier
contains_javascript: catalog.open_action.is_some() || catalog.aa.is_some(),
contains_xfa: false, // Not detected in grep mode
ocg_present: catalog
.oc_properties
.as_ref()
.map(|props| props.present)
.unwrap_or(false),
};
let fingerprint_input = FingerprintInput {
page_count,
pages: fingerprint_pages,
struct_tree_root_ref: catalog.struct_tree_root_ref,
is_tagged: catalog.mark_info.is_tagged,
catalog_flags,
};
compute_fingerprint(&fingerprint_input, resolver)
}
/// A span of text extracted from a PDF.
#[derive(Debug, Clone)]
struct Span {
/// The text content.
pub text: String,
/// Bounding box [x0, y0, x1, y1].
pub bbox: [f32; 4],
/// Page index (0-based).
pub page_index: u32,
/// Confidence score (0.0 to 1.0).
pub confidence: f32,
/// Font name.
pub font: String,
/// Font size in points.
pub font_size: f32,
}
/// Extract spans from a single page via content stream processing.
///
/// This runs Phase 3 (content stream parsing) to extract text with bounding boxes.
/// It skips Phase 4.5 (reading-order detection) as grep doesn't need it.
fn extract_spans_from_page(
page: &PageDict,
resolver: &XrefResolver,
source: &dyn PdfSource,
) -> Result<Vec<Span>> {
// Get page resources
let resources = page
.resources
.as_ref()
.map(|r| ResourceDict::from_dict(r, resolver))
.transpose()?
.unwrap_or_else(ResourceDict::default);
// Decode and process content streams
let decoded = decode_page_streams(page, resolver, source)?;
// Process content stream to extract glyphs
let glyphs = process_with_mode(&decoded, &resources, ProcessingMode::Normal, None)
.map_err(|diagnostics| {
let msg = diagnostics
.first()
.map(|d| d.message.as_ref())
.unwrap_or("unknown error");
anyhow!("failed to process content stream: {}", msg)
})?;
// Group glyphs into spans (consecutive glyphs with same font)
let spans = group_glyphs_into_spans(glyphs);
Ok(spans)
}
/// Group consecutive glyphs into spans based on font proximity.
///
/// This is a simplified span builder that groups glyphs that are:
/// - From the same font
/// - At similar Y positions (same line)
/// - Close together horizontally (within 2x font size)
///
/// This is sufficient for grep use cases without full reading-order detection.
fn group_glyphs_into_spans(glyphs: Vec<Glyph>) -> Vec<Span> {
if glyphs.is_empty() {
return Vec::new();
}
let mut spans = Vec::new();
let mut current_span_glyphs = Vec::new();
let mut last_font: Option<String> = None;
let mut last_y: Option<f64> = None;
let mut last_x_end: Option<f64> = None;
let mut last_font_size: Option<f64> = None;
for glyph in glyphs {
let font = glyph.font.clone().unwrap_or_else(|| "unknown".to_string());
let y = glyph.bbox[1]; // Bottom of bbox
let x_end = glyph.bbox[2]; // Right of bbox
let font_size = glyph.size.unwrap_or(12.0);
// Check if we should start a new span
let should_start_new = if last_font.is_none() {
false
} else {
// Different font?
let font_changed = last_font.as_ref() != Some(&font);
// Different line? (Y position differs by more than 20% of font size)
let line_changed = last_y.map_or(false, |ly| {
(ly - y).abs() > font_size * 0.2
});
// Too far horizontally? (gap > 2x font size)
let too_far = last_x_end.map_or(false, |lx| {
glyph.bbox[0] - lx > font_size * 2.0
});
font_changed || line_changed || too_far
};
if should_start_new {
// Finalize current span
if !current_span_glyphs.is_empty() {
spans.push(create_span_from_glyphs(&current_span_glyphs));
current_span_glyphs.clear();
}
}
// Add glyph to current span
current_span_glyphs.push(glyph.clone());
// Update tracking state
last_font = Some(font);
last_y = Some(y);
last_x_end = Some(x_end);
last_font_size = Some(font_size);
}
// Don't forget the last span
if !current_span_glyphs.is_empty() {
spans.push(create_span_from_glyphs(&current_span_glyphs));
}
spans
}
/// Create a span from a group of glyphs.
fn create_span_from_glyphs(glyphs: &[Glyph]) -> Span {
if glyphs.is_empty() {
return Span {
text: String::new(),
bbox: [0.0, 0.0, 0.0, 0.0],
page_index: 0,
confidence: 1.0,
font: "unknown".to_string(),
font_size: 12.0,
};
}
// Concatenate text
let text: String = glyphs.iter().map(|g| g.unicode).collect();
// Compute union bbox
let mut x0 = f64::MAX;
let mut y0 = f64::MAX;
let mut x1 = f64::MIN;
let mut y1 = f64::MIN;
for glyph in glyphs {
x0 = x0.min(glyph.bbox[0]);
y0 = y0.min(glyph.bbox[1]);
x1 = x1.max(glyph.bbox[2]);
y1 = y1.max(glyph.bbox[3]);
}
// Get font and size from first glyph
let font = glyphs[0].font.clone().unwrap_or_else(|| "unknown".to_string());
let font_size = glyphs[0].size.unwrap_or(12.0);
// Compute confidence as minimum of all glyphs
let confidence = glyphs.iter().map(|g| g.confidence).fold(1.0, f32::min);
Span {
text,
bbox: [x0 as f32, y0 as f32, x1 as f32, y1 as f32],
page_index: 0, // Will be set by caller
confidence,
font,
font_size: font_size as f32,
}
}
/// Decode all content streams for a page.
fn decode_page_streams(
page: &PageDict,
resolver: &XrefResolver,
source: &dyn PdfSource,
) -> Result<Vec<u8>> {
use pdftract_core::parser::stream::{decode_stream, ExtractionOptions as StreamExtractionOptions};
let stream_opts = StreamExtractionOptions {
max_decompress_bytes: pdftract_core::parser::stream::DEFAULT_MAX_DECOMPRESS_BYTES,
password: None,
};
let mut all_decoded = Vec::new();
let mut doc_counter = 0u64;
for stream_ref in &page.contents {
match resolver.resolve(*stream_ref) {
Ok(obj) => {
if let Some(stream) = obj.as_stream() {
let decoded = decode_stream(stream, source, &stream_opts, &mut doc_counter);
all_decoded.extend_from_slice(&decoded);
}
}
Err(_) => continue,
}
}
Ok(all_decoded)
}
/// Process a single span and emit match events.
///
/// Applies the matcher to the span text and emits match events for each match.
/// Handles --invert-match by emitting synthetic events for spans with zero matches.
fn process_span(
span: &Span,
path: &std::path::Path,
page_index: u32,
fingerprint: &str,
matcher: &Matcher,
config: &GrepConfig,
) -> Vec<MatchEvent> {
let path_str = path.display().to_string();
// Find matches in this span
let matches: Vec<MatchRange> = matcher
.find_iter_with_word_boundary(&span.text, config.word_regexp)
.collect();
// Handle --invert-match: emit synthetic event for spans with zero matches
if config.invert_match {
if matches.is_empty() {
return vec![MatchEvent::new(
path_str,
page_index,
span.bbox,
span.text.clone(),
span.text.clone(),
span.confidence,
fingerprint.to_string(),
false,
)];
} else {
// Invert mode: skip spans that have matches
return Vec::new();
}
}
// Normal mode: emit events for each match
matches
.into_iter()
.map(|m| {
let match_text = span.text[m.start..m.end].to_string();
MatchEvent::new(
path_str.clone(),
page_index,
span.bbox,
match_text,
span.text.clone(),
span.confidence,
fingerprint.to_string(),
false, // crosses_spans is always false in single-span mode
)
})
.collect()
}
/// Find the startxref offset in a PDF file.
fn find_startxref(source: &dyn PdfSource) -> Result<u64> {
let len = source.len()? as usize;
let scan_start = len.saturating_sub(1024);
let scan_end = len;
let tail_data = source
.read_at(scan_start as u64, scan_end - scan_start)
.context("Failed to read PDF tail")?;
// Find "startxref" in the tail data
let startxref_pos = tail_data
.windows(9)
.rposition(|w| w == b"startxref")
.ok_or_else(|| anyhow!("startxref not found in PDF"))?;
// Parse the offset after "startxref"
let offset_data = &tail_data[startxref_pos + 9..];
// Skip leading whitespace
let offset_start = offset_data
.iter()
.position(|&b| !matches!(b, b' ' | b'\r' | b'\n' | b'\t'))
.unwrap_or(offset_data.len());
let offset_data_trimmed = &offset_data[offset_start..];
// Find the newline after the offset
let newline_pos = offset_data_trimmed
.iter()
.position(|&b| b == b'\n' || b == b'\r')
.unwrap_or(offset_data_trimmed.len());
let offset_str = std::str::from_utf8(&offset_data_trimmed[..newline_pos])
.context("startxref offset is not valid UTF-8")?;
let offset: u64 = offset_str
.trim()
.parse()
.context("startxref offset is not a valid number")?;
Ok(offset)
}
/// Parse the catalog with a given resolver.
fn parse_catalog_with_resolver(
resolver: &XrefResolver,
root_ref: &pdftract_core::parser::object::ObjRef,
source: &dyn PdfSource,
) -> Result<Catalog, Vec<Diagnostic>> {
pdftract_core::parser::catalog::parse_catalog(resolver, root_ref, Some(source))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_find_startxref() {
// Create a minimal PDF with startxref
let temp_dir = TempDir::new().unwrap();
let pdf_path = temp_dir.path().join("test.pdf");
let pdf_content = b"%PDF-1.4\n...\nstartxref\n12345\n%%EOF\n";
File::create(&pdf_path)
.unwrap()
.write_all(pdf_content)
.unwrap();
let source = FileSource::open(&pdf_path).unwrap();
let offset = find_startxref(&source).unwrap();
assert_eq!(offset, 12345);
}
}

View file

@ -46,8 +46,10 @@ serde_yaml = { version = "0.9", optional = true }
chrono = "0.4"
aes = { version = "0.8", optional = true }
rc4 = { version = "0.1", optional = true }
md-5 = { version = "0.10", optional = true }
cbc = { version = "0.1", optional = true, features = ["std"] }
cipher = { version = "0.4", optional = true, features = ["block-padding"] }
digest = { version = "0.10", optional = true }
[features]
default = ["serde", "decrypt"]
@ -58,7 +60,7 @@ ocr = ["dep:image", "dep:imageproc", "dep:leptonica-plumbing", "dep:quick-xml"]
full-render = ["dep:pdfium-render", "ocr"] # Enable PDFium-based rendering (requires ocr)
remote = ["dep:url"] # Enable remote HTTP source (Phase 1.8)
profiles = ["dep:serde_yaml"] # Enable extraction profiles (Phase 7.10)
decrypt = ["dep:aes", "dep:rc4", "dep:cbc", "dep:cipher"] # Enable PDF decryption (RC4/AES-128/AES-256)
decrypt = ["dep:aes", "dep:rc4", "dep:md-5", "dep:cbc", "dep:cipher", "dep:digest"] # Enable PDF decryption (RC4/AES-128/AES-256)
proptest = []
fuzzing = [] # Enable cfg(fuzzing) for fuzz harnesses
shape-db = [] # Enable glyph shape database (Level 4 encoding fallback)

View file

@ -13,7 +13,17 @@
pub mod aes_256;
#[cfg(feature = "decrypt")]
pub use aes_256::{aes_256_decrypt, Aes256Decryptor, FileKeyResult};
pub mod rc4;
#[cfg(feature = "decrypt")]
pub use aes_256::{aes_256_decrypt, Aes256Decryptor, FileKeyResult as Aes256FileKeyResult};
#[cfg(feature = "decrypt")]
pub use rc4::{
decrypt_object, derive_file_key, derive_object_key, pad_password, rc4_decrypt,
validate_user_password, validate_user_password_r2, validate_user_password_r3,
FileKeyResult as Rc4FileKeyResult,
};
use crate::diagnostics::{DiagCode, Diagnostic};

View file

@ -0,0 +1,664 @@
//! RC4 decryption for PDF V=1 R=2 (40-bit) and V=2 R=3 (up to 128-bit).
//!
//! This module implements PDF RC4 decryption per PDF 1.7 spec (ISO 32000-1:2008),
//! section 7.6.4. It supports:
//! - V=1, R=2: RC4 40-bit
//! - V=2, R=3: RC4 40-128 bit
//!
//! # Key Derivation (Algorithm 2)
//!
//! The file encryption key is derived from:
//! 1. Pad password to 32 bytes via the standard padding string
//! 2. MD5 hash: pad || /O || /P (4 bytes LE) || first16(/ID[0])
//! 3. If R>=3: iterate MD5 50 times on the first n bytes (n = key_length/8)
//! 4. The first n bytes of the MD5 output is the encryption key
//!
//! # Per-Object Key Derivation (Algorithm 1)
//!
//! Each object uses a unique key derived from the file key:
//! 1. Take the encryption key + 3 bytes object number (LE) + 2 bytes generation (LE)
//! 2. MD5 hash; first (n+5) bytes (capped at 16) is the per-object key
//! 3. Initialize RC4 with this key; decrypt the object data
//!
//! # User Password Validation (Algorithm 4 for R=2, Algorithm 5 for R=3)
//!
//! - R=2: pad password; RC4-encrypt the 32-byte padding string with the file key;
//! compare with /U
//! - R=3: pad password; MD5(pad || first16(/ID[0])); RC4 19 times with i^step key;
//! compare first 16 bytes with first 16 of /U
#[cfg(feature = "decrypt")]
use md5::Md5;
#[cfg(feature = "decrypt")]
use digest::Digest;
/// The 32-byte standard password padding string from PDF spec Table 27.
///
/// This string is used to pad passwords to exactly 32 bytes when they are
/// shorter than 32 bytes. This is defined in PDF 1.7 spec Table 27.
const PASSWORD_PADDING: [u8; 32] = [
0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01,
0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53,
0x69, 0x7A,
];
/// Maximum RC4 key length in bytes (128 bits = 16 bytes).
const MAX_KEY_LENGTH: usize = 16;
/// Minimum RC4 key length in bytes (40 bits = 5 bytes).
const MIN_KEY_LENGTH: usize = 5;
/// Result of file key derivation.
#[derive(Debug, Clone)]
pub enum FileKeyResult {
/// Successfully derived file key
Success(Vec<u8>),
/// Wrong password (validation failed)
WrongPassword,
/// Invalid encryption data (malformed /O, /U, /ID)
InvalidData(String),
}
impl FileKeyResult {
/// Check if the result is successful.
pub fn is_success(&self) -> bool {
matches!(self, FileKeyResult::Success(_))
}
/// Get the file key if successful.
pub fn key(&self) -> Option<&[u8]> {
match self {
FileKeyResult::Success(key) => Some(key),
_ => None,
}
}
}
/// Pad a password to 32 bytes using the standard padding string.
///
/// If the password is less than 32 bytes, the padding string is appended
/// to fill to 32 bytes. If the password is 32 bytes or more, only the
/// first 32 bytes are used.
#[must_use]
pub fn pad_password(password: &[u8]) -> [u8; 32] {
let mut padded = [0u8; 32];
if password.is_empty() {
// Empty password uses the padding string as-is
padded.copy_from_slice(&PASSWORD_PADDING);
} else {
// Copy password bytes (up to 32)
let copy_len = password.len().min(32);
padded[..copy_len].copy_from_slice(&password[..copy_len]);
// Fill remaining with padding string
if copy_len < 32 {
padded[copy_len..].copy_from_slice(&PASSWORD_PADDING[..32 - copy_len]);
}
}
padded
}
/// Derive the file encryption key (Algorithm 2 from PDF spec 7.6.4.3).
///
/// # Arguments
///
/// * `password` - The user or owner password (empty byte slice for no password)
/// * `owner_hash` - The /O value from the encryption dictionary
/// * `permissions` - The /P value (4 bytes, little-endian)
/// * `document_id` - The first element of the /ID array (used in key derivation)
/// * `key_length` - The encryption key length in bits (40, 128, etc.)
/// * `revision` - The encryption revision (2 or 3)
///
/// # Returns
///
/// `FileKeyResult` with the derived key (length = key_length / 8 bytes).
#[cfg(feature = "decrypt")]
pub fn derive_file_key(
password: &[u8],
owner_hash: &[u8],
permissions: u32,
document_id: &[u8],
key_length: u32,
revision: u32,
) -> FileKeyResult {
// Validate inputs
let key_bytes = (key_length / 8) as usize;
if key_bytes < MIN_KEY_LENGTH || key_bytes > MAX_KEY_LENGTH {
return FileKeyResult::InvalidData(format!(
"Invalid key length: {} bits (must be 40-128)",
key_length
));
}
if document_id.len() < 16 {
return FileKeyResult::InvalidData(
"Document ID too short (must be at least 16 bytes)".to_string(),
);
}
// Step 1: Pad password to 32 bytes
let padded_password = pad_password(password);
// Step 2: MD5 hash: pad || /O || /P (4 bytes LE) || first16(/ID[0])
let mut md5 = Md5::new();
md5.update(&padded_password);
md5.update(owner_hash);
// Permissions as 4-byte little-endian
let perm_bytes = permissions.to_le_bytes();
md5.update(&perm_bytes);
// First 16 bytes of document ID
md5.update(&document_id[..16]);
let mut hash = md5.finalize();
// Step 3: If R>=3, iterate MD5 50 times on the first n bytes
if revision >= 3 {
for _ in 0..50 {
let mut md5 = Md5::new();
md5.update(&hash[..key_bytes]);
hash = md5.finalize();
}
}
// Step 4: The first n bytes of the MD5 output is the encryption key
FileKeyResult::Success(hash[..key_bytes].to_vec())
}
/// Derive the per-object encryption key (Algorithm 1 from PDF spec 7.6.4.3).
///
/// # Arguments
///
/// * `file_key` - The file encryption key
/// * `object_number` - The PDF object number (0-based)
/// * `generation` - The PDF object generation number
///
/// # Returns
///
/// The per-object encryption key (length = min(file_key.len() + 5, 16) bytes).
#[cfg(feature = "decrypt")]
#[must_use]
pub fn derive_object_key(file_key: &[u8], object_number: u32, generation: u16) -> Vec<u8> {
let key_len = std::cmp::min(file_key.len() + 5, 16);
// Object number as 3-byte little-endian
let obj_bytes = object_number.to_le_bytes();
// Generation as 2-byte little-endian
let gen_bytes = generation.to_le_bytes();
let mut md5 = Md5::new();
md5.update(file_key);
md5.update(&obj_bytes[..3]); // First 3 bytes of object number
md5.update(&gen_bytes); // Both bytes of generation number
let hash = md5.finalize();
hash[..key_len].to_vec()
}
/// Decrypt data using RC4 with the given key.
///
/// # Arguments
///
/// * `key` - The RC4 key
/// * `data` - The data to decrypt
///
/// # Returns
///
/// The decrypted data.
#[cfg(feature = "decrypt")]
pub fn rc4_decrypt(key: &[u8], data: &[u8]) -> Vec<u8> {
// RC4 supports variable key sizes from 1-256 bytes
// Implement RC4 directly since the rc4 crate has API compatibility issues
rc4_decrypt_direct(key, data)
}
/// Direct RC4 implementation for PDF decryption.
///
/// RC4 is a simple stream cipher that generates a keystream by:
/// 1. Initialize a 256-byte S-box with the key
/// 2. Generate keystream bytes by swapping entries in the S-box
#[cfg(feature = "decrypt")]
fn rc4_decrypt_direct(key: &[u8], data: &[u8]) -> Vec<u8> {
// Key scheduling algorithm (KSA)
let mut s = [0u8; 256];
for (i, s_i) in s.iter_mut().enumerate() {
*s_i = i as u8;
}
let key_len = key.len();
let mut j: u8 = 0;
for i in 0..256 {
j = j.wrapping_add(s[i]).wrapping_add(key[i % key_len]);
s.swap(i, j as usize);
}
// Pseudo-random generation algorithm (PRGA)
let mut result = data.to_vec();
let mut i: u8 = 0;
let mut j: u8 = 0;
for (k, byte) in result.iter_mut().enumerate() {
i = i.wrapping_add(1);
j = j.wrapping_add(s[i as usize]);
s.swap(i as usize, j as usize);
let t = s[(s[i as usize].wrapping_add(s[j as usize])) as usize];
*byte ^= t;
}
result
}
/// Decrypt a PDF object using the file encryption key (Algorithm 1).
///
/// This is the main entry point for decrypting PDF objects. It derives
/// the per-object key and decrypts the data.
///
/// # Arguments
///
/// * `file_key` - The file encryption key
/// * `object_number` - The PDF object number
/// * `generation` - The PDF object generation number
/// * `data` - The encrypted data
///
/// # Returns
///
/// The decrypted data.
#[cfg(feature = "decrypt")]
pub fn decrypt_object(
file_key: &[u8],
object_number: u32,
generation: u16,
data: &[u8],
) -> Vec<u8> {
let object_key = derive_object_key(file_key, object_number, generation);
rc4_decrypt(&object_key, data)
}
/// Validate user password for R=2 (Algorithm 4 from PDF spec 7.6.4.4).
///
/// # Arguments
///
/// * `password` - The user password to validate
/// * `file_key` - The file encryption key
/// * `user_hash` - The /U value from the encryption dictionary
///
/// # Returns
///
/// `true` if the password is correct, `false` otherwise.
#[cfg(feature = "decrypt")]
#[must_use]
pub fn validate_user_password_r2(password: &[u8], file_key: &[u8], user_hash: &[u8]) -> bool {
// Step 1: Pad password to 32 bytes
let padded_password = pad_password(password);
// Step 2: RC4-encrypt the padding string with the file key
let encrypted_padding = rc4_decrypt(file_key, &PASSWORD_PADDING);
// Step 3: Compare with /U
if user_hash.len() < 32 {
return false;
}
&encrypted_padding[..32] == &user_hash[..32]
}
/// Validate user password for R=3 (Algorithm 5 from PDF spec 7.6.4.4).
///
/// # Arguments
///
/// * `password` - The user password to validate
/// * `file_key` - The file encryption key
/// * `user_hash` - The /U value from the encryption dictionary
/// * `document_id` - The first element of the /ID array
///
/// # Returns
///
/// `true` if the password is correct, `false` otherwise.
#[cfg(feature = "decrypt")]
#[must_use]
pub fn validate_user_password_r3(
password: &[u8],
file_key: &[u8],
user_hash: &[u8],
document_id: &[u8],
) -> bool {
// Step 1: Pad password to 32 bytes
let padded_password = pad_password(password);
// Step 2: MD5 hash of padded password || first 16 bytes of document ID
let mut md5 = Md5::new();
md5.update(&padded_password);
if document_id.len() >= 16 {
md5.update(&document_id[..16]);
}
let hash = md5.finalize();
// Step 3: RC4-encrypt the hash with the file key, 19 times
let mut data = hash.to_vec();
for i in 1..=19 {
// XOR key with iteration counter for each round
let mut key_copy = vec![0u8; file_key.len()];
for (j, &byte) in file_key.iter().enumerate() {
key_copy[j] = byte ^ (i as u8);
}
data = rc4_decrypt(&key_copy, &data);
}
// Step 4: Compare first 16 bytes with /U
if user_hash.len() < 16 {
return false;
}
&data[..16] == &user_hash[..16]
}
/// Validate user password (dispatches to R=2 or R=3 algorithm).
///
/// # Arguments
///
/// * `password` - The user password to validate
/// * `file_key` - The file encryption key
/// * `user_hash` - The /U value from the encryption dictionary
/// * `document_id` - The first element of the /ID array
/// * `revision` - The encryption revision (2 or 3)
///
/// # Returns
///
/// `true` if the password is correct, `false` otherwise.
#[cfg(feature = "decrypt")]
#[must_use]
pub fn validate_user_password(
password: &[u8],
file_key: &[u8],
user_hash: &[u8],
document_id: &[u8],
revision: u32,
) -> bool {
if revision == 2 {
validate_user_password_r2(password, file_key, user_hash)
} else if revision == 3 {
validate_user_password_r3(password, file_key, user_hash, document_id)
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_password_padding_empty() {
let padded = pad_password(b"");
assert_eq!(padded, PASSWORD_PADDING);
}
#[test]
fn test_password_padding_short() {
let padded = pad_password(b"test");
// First 4 bytes should be "test"
assert_eq!(&padded[..4], b"test");
// Remaining should be from padding string
assert_eq!(&padded[4..], &PASSWORD_PADDING[..28]);
}
#[test]
fn test_password_padding_exact() {
let password = b"12345678901234567890123456789012"; // Exactly 32 bytes
let padded = pad_password(password);
assert_eq!(padded, *password);
}
#[test]
fn test_password_padding_long() {
let password = b"This password is way too long and will be truncated";
let padded = pad_password(password);
// Should only use first 32 bytes
assert_eq!(&padded[..], &password[..32]);
}
#[test]
fn test_derive_file_key_basic() {
let password = b"test";
let owner_hash = vec![0u8; 32];
let permissions = 0xFFFFFFFFu32;
let document_id = vec![0u8; 16];
let key_length = 40; // 40-bit
let revision = 2;
let result = derive_file_key(
password,
&owner_hash,
permissions,
&document_id,
key_length,
revision,
);
assert!(result.is_success());
let key = result.key().unwrap();
assert_eq!(key.len(), 5); // 40 bits = 5 bytes
}
#[test]
fn test_derive_file_key_128_bit() {
let password = b"test";
let owner_hash = vec![0u8; 32];
let permissions = 0xFFFFFFFFu32;
let document_id = vec![0u8; 16];
let key_length = 128; // 128-bit
let revision = 3;
let result = derive_file_key(
password,
&owner_hash,
permissions,
&document_id,
key_length,
revision,
);
assert!(result.is_success());
let key = result.key().unwrap();
assert_eq!(key.len(), 16); // 128 bits = 16 bytes
}
#[test]
fn test_derive_file_key_invalid_key_length() {
let password = b"test";
let owner_hash = vec![0u8; 32];
let permissions = 0xFFFFFFFFu32;
let document_id = vec![0u8; 16];
let key_length = 256; // Too long for RC4
let revision = 3;
let result = derive_file_key(
password,
&owner_hash,
permissions,
&document_id,
key_length,
revision,
);
assert!(!result.is_success());
}
#[test]
fn test_derive_file_key_short_document_id() {
let password = b"test";
let owner_hash = vec![0u8; 32];
let permissions = 0xFFFFFFFFu32;
let document_id = vec![0u8; 8]; // Too short
let key_length = 40;
let revision = 2;
let result = derive_file_key(
password,
&owner_hash,
permissions,
&document_id,
key_length,
revision,
);
assert!(!result.is_success());
}
#[test]
fn test_derive_object_key() {
let file_key = vec![1u8, 2, 3, 4, 5]; // 5-byte key
let object_number = 100;
let generation = 0;
let object_key = derive_object_key(&file_key, object_number, generation);
// Key should be min(5 + 5, 16) = 10 bytes
assert_eq!(object_key.len(), 10);
}
#[test]
fn test_rc4_decrypt_roundtrip() {
let key = b"test_key";
let plaintext = b"Hello, world!";
// Encrypt (RC4 is symmetric, so decrypting is the same as encrypting)
let encrypted = rc4_decrypt(key, plaintext);
// Decrypt back
let decrypted = rc4_decrypt(key, &encrypted);
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_decrypt_object_roundtrip() {
let file_key = vec![1u8, 2, 3, 4, 5];
let object_number = 42;
let generation = 0;
let plaintext = b"Secret object data";
// Encrypt
let encrypted = decrypt_object(&file_key, object_number, generation, plaintext);
// Decrypt (should get original back since RC4 is symmetric)
let decrypted = decrypt_object(&file_key, object_number, generation, &encrypted);
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_validate_user_password_r2() {
// This is a basic structure test - full validation requires real PDF test vectors
let file_key = vec![1u8, 2, 3, 4, 5];
let password = b"test";
// Create a fake user_hash by encrypting the padding string
let user_hash = rc4_decrypt(&file_key, &PASSWORD_PADDING);
assert!(validate_user_password_r2(password, &file_key, &user_hash));
}
#[test]
fn test_validate_user_password_r2_wrong_password() {
let file_key = vec![1u8, 2, 3, 4, 5];
let password = b"test";
// Create a user_hash for a different password
let wrong_password = pad_password(b"wrong");
let mut md5 = Md5::new();
md5.update(&wrong_password);
md5.update(&[0u8; 32]); // fake owner_hash
md5.update(&0xFFFFFFFFu32.to_le_bytes());
md5.update(&[0u8; 16]); // fake document_id
let wrong_key = md5.finalize();
let user_hash = rc4_decrypt(&wrong_key[..5], &PASSWORD_PADDING);
assert!(!validate_user_password_r2(password, &file_key, &user_hash));
}
#[test]
fn test_file_key_result_is_success() {
let key = vec![1u8, 2, 3, 4, 5];
let result = FileKeyResult::Success(key.clone());
assert!(result.is_success());
assert_eq!(result.key(), Some(&key[..]));
}
#[test]
fn test_file_key_result_wrong_password() {
let result = FileKeyResult::WrongPassword;
assert!(!result.is_success());
assert_eq!(result.key(), None);
}
#[test]
fn test_rc4_different_objects_different_keys() {
let file_key = vec![1u8, 2, 3, 4, 5];
let key1 = derive_object_key(&file_key, 1, 0);
let key2 = derive_object_key(&file_key, 2, 0);
assert_ne!(key1, key2);
}
#[test]
fn test_rc4_same_object_same_key() {
let file_key = vec![1u8, 2, 3, 4, 5];
let key1 = derive_object_key(&file_key, 42, 0);
let key2 = derive_object_key(&file_key, 42, 0);
assert_eq!(key1, key2);
}
#[test]
fn test_rc4_generation_affects_key() {
let file_key = vec![1u8, 2, 3, 4, 5];
let key1 = derive_object_key(&file_key, 42, 0);
let key2 = derive_object_key(&file_key, 42, 1);
assert_ne!(key1, key2);
}
#[test]
fn test_password_padding_all_bytes() {
// Test that all padding bytes are correctly defined
assert_eq!(PASSWORD_PADDING.len(), 32);
assert_eq!(
PASSWORD_PADDING,
[
0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA,
0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE,
0x64, 0x53, 0x69, 0x7A
]
);
}
#[test]
fn test_rc4_decrypt_empty_data() {
let key = b"test_key";
let data = b"";
let result = rc4_decrypt(key, data);
assert_eq!(result, Vec::<u8>::new());
}
#[test]
fn test_rc4_decrypt_long_key() {
// Test with a longer key (16 bytes = 128 bits)
let key = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
let plaintext = b"Hello, world!";
let encrypted = rc4_decrypt(&key, plaintext);
let decrypted = rc4_decrypt(&key, &encrypted);
assert_eq!(decrypted, plaintext);
}
}