pdftract/crates/pdftract-core/src/parser/stream.rs
jedarden 6000c654ce fix: resolve compilation errors across codebase
- Fixed missing fields in BlockJson, SpanJson, ExtractionOptions initializations
- Added feature gates to ocr_integration tests for conditional compilation
- Fixed McpServerState::new calls to include audit writer argument
- Fixed CCITTFaxDecoder::decode calls to use instance method
- Fixed type casts for ObjRef::new calls
- Fixed serde_json::Value method calls (is_some -> !is_null)
- Fixed ProfileType test feature gates
- Worked around lifetime issues in schema roundtrip tests

These changes fix numerous compilation errors that were blocking the
codebase from building. The main library and tests now compile successfully.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 08:38:04 -04:00

5585 lines
202 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! PDF stream decoding and filter pipeline.
//!
//! This module implements the filter pipeline for decoding PDF stream data.
//! PDF streams can have multiple filters applied in sequence (e.g., /ASCII85Decode
//! followed by /FlateDecode). This module handles:
//!
//! - Dispatching to the appropriate filter decoder
//! - Managing filter parameters (/DecodeParms)
//! - Enforcing decompression limits (bomb protection)
//! - Error recovery per INV-8 (never panic, always return partial bytes)
use std::io::Read;
use std::io::Seek;
use std::path::Path;
use flate2::read::ZlibDecoder;
use lzw::{Decoder, DecoderEarlyChange, MsbReader};
use secrecy::SecretString;
use crate::diagnostics::{DiagCode, Diagnostic};
use crate::parser::object::{PdfObject, PdfStream};
/// Maximum number of filters allowed in a single stream's pipeline.
/// This prevents stack overflow and excessive computation.
const MAX_FILTERS: usize = 16;
/// Chunk size for checking decompression limits during decoding.
const BOMB_CHECK_CHUNK: usize = 64 * 1024; // 64 KB
/// Maximum bytes per row for predictor decoding.
/// Prevents OOM from malicious columns/colors/bits_per_component values.
/// Bound matches BOMB_CHECK_CHUNK to keep peak memory at 2x stride (prev_row + current_row).
const MAX_ROW_BYTES: usize = 64 * 1024; // 64 KB
/// Default maximum decompressed bytes per document (512 MiB).
pub const DEFAULT_MAX_DECOMPRESS_BYTES: u64 = 512 * 1024_u64.pow(2);
/// Errors that can occur during stream decoding.
///
/// Per INV-8, these are "hard" errors that prevent decoding from starting.
/// Soft errors (corrupt data, EOF mid-stream) return Ok(partial_bytes) with
/// a diagnostic instead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterError {
/// Unknown filter name (e.g., /CustomDecode)
UnknownFilter(String),
/// Invalid filter parameters (wrong type, missing required key)
InvalidParams(String),
/// Unsupported encryption (custom crypt filter, not /Identity)
EncryptionUnsupported,
}
impl std::fmt::Display for FilterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FilterError::UnknownFilter(name) => write!(f, "unknown filter: {}", name),
FilterError::InvalidParams(msg) => write!(f, "invalid filter parameters: {}", msg),
FilterError::EncryptionUnsupported => {
write!(f, "unsupported encryption: custom crypt filter")
}
}
}
}
impl std::error::Error for FilterError {}
/// A stream decoder for a specific PDF filter type.
///
/// Each filter implements this trait to decode its specific format.
pub trait StreamDecoder: Send + Sync {
/// Decode the input bytes using this filter.
///
/// # Parameters
/// - `input`: The raw bytes to decode
/// - `params`: Optional filter parameters from /DecodeParms
/// - `doc_counter`: Cumulative decompressed bytes for the document (mutated)
/// - `max_bytes`: Maximum bytes allowed before emitting STREAM_BOMB
///
/// # Returns
/// - `Ok(bytes)`: Decoded bytes (may be partial if bomb limit hit)
/// - `Err(FilterError)`: Hard error (unknown filter, invalid params)
///
/// Per INV-8, corrupt data mid-stream returns Ok(partial) with diagnostic,
/// not Err. Err is only for "couldn't even start decoding".
fn decode(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError>;
/// Get the filter name (e.g., "FlateDecode", "ASCII85Decode").
fn name(&self) -> &'static str;
}
/// Predictor decode parameters for FlateDecode and LZWDecode.
///
/// Per PDF spec 7.4.4, these parameters control how predictors are applied
/// after decompression to reconstruct the original image data.
#[derive(Debug, Clone, Copy)]
pub struct PredictorParams {
/// Predictor type: 1 = none, 2 = TIFF, 10-15 = PNG
pub predictor: i32,
/// Number of columns (samples) per row
pub columns: i32,
/// Number of color components per sample (1 = grayscale, 3 = RGB, 4 = RGBA)
pub colors: i32,
/// Bits per color component (typically 8)
pub bits_per_component: i32,
}
impl Default for PredictorParams {
fn default() -> Self {
Self {
predictor: 1, // No prediction
columns: 1,
colors: 1,
bits_per_component: 8,
}
}
}
impl PredictorParams {
/// Parse predictor parameters from a /DecodeParms dictionary.
///
/// Per PDF spec 7.4.4, the following keys are recognized:
/// - /Predictor (int, default 1)
/// - /Columns (int, default 1)
/// - /Colors (int, default 1)
/// - /BitsPerComponent (int, default 8)
///
/// Returns None if params is None or not a dictionary.
/// Returns Some(defaults) if params is a dictionary but missing required keys
/// (predictor is disabled in this case).
pub fn from_pdf_object(params: Option<&PdfObject>) -> Option<Self> {
let dict = match params {
Some(PdfObject::Dict(d)) => d.as_ref(),
_ => return None,
};
let predictor = match dict.get("/Predictor") {
Some(PdfObject::Integer(n)) => *n,
Some(PdfObject::Bool(b)) => {
if *b {
2
} else {
1
}
}
_ => 1, // Default: no predictor
};
// For predictors other than 1, require the other parameters
let columns = match dict.get("/Columns") {
Some(PdfObject::Integer(n)) => *n,
_ if predictor != 1 => 1, // Default for predictors
_ => 1,
};
let colors = match dict.get("/Colors") {
Some(PdfObject::Integer(n)) => *n,
_ if predictor != 1 => 1, // Default for predictors
_ => 1,
};
let bits_per_component = match dict.get("/BitsPerComponent") {
Some(PdfObject::Integer(n)) => *n,
_ if predictor != 1 => 8, // Default for predictors
_ => 8,
};
// Validate parameters
if predictor != 1 && predictor != 2 && !(10..=15).contains(&predictor) {
// Invalid predictor value - disable prediction
return Some(PredictorParams::default());
}
if columns <= 0 || colors <= 0 || bits_per_component <= 0 {
// Invalid parameters - disable prediction
return Some(PredictorParams::default());
}
Some(PredictorParams {
predictor: predictor as i32,
columns: columns as i32,
colors: colors as i32,
bits_per_component: bits_per_component as i32,
})
}
/// Calculate bytes per pixel (for PNG predictors).
#[inline]
pub fn bytes_per_pixel(&self) -> usize {
// bpp = ceil(colors * bits_per_component / 8)
((self.colors * self.bits_per_component) + 7) as usize / 8
}
/// Calculate bytes per row (before PNG predictor selector).
///
/// Returns a bounded value to prevent OOM from malicious PDF parameters.
/// Per docs/research/image-and-figure-extraction.md, peak memory should be
/// bounded to 2 × stride_bytes regardless of image height.
#[inline]
pub fn bytes_per_row(&self) -> usize {
// bytes_per_row = ceil(columns * colors * bits_per_component / 8)
let raw = ((self.columns * self.colors * self.bits_per_component) + 7) as usize / 8;
raw.min(MAX_ROW_BYTES)
}
/// Check if predictor parameters are suspicious (potentially malicious).
///
/// Returns true if the calculated row_size was clamped, indicating
/// that the PDF parameters claim an unrealistically large row size.
#[inline]
pub fn is_row_size_clamped(&self) -> bool {
let raw = ((self.columns * self.colors * self.bits_per_component) + 7) as usize / 8;
raw > MAX_ROW_BYTES
}
/// Calculate bytes per row including PNG predictor selector byte.
#[inline]
pub fn bytes_per_row_with_selector(&self) -> usize {
1 + self.bytes_per_row()
}
/// Extract /EarlyChange parameter from a /DecodeParms dictionary.
///
/// Per PDF spec 7.4.4, /EarlyChange controls when the LZW code size increases:
/// - 1 = early change (default, Adobe/TIFF variant)
/// - 0 = late change (GIF variant)
///
/// Returns None if params is None or not a dictionary, or if /EarlyChange is not present.
pub fn extract_early_change(params: Option<&PdfObject>) -> Option<i32> {
let dict = match params {
Some(PdfObject::Dict(d)) => d.as_ref(),
_ => return None,
};
match dict.get("/EarlyChange") {
Some(PdfObject::Integer(n)) => Some(*n as i32),
Some(PdfObject::Bool(b)) => Some(if *b { 1 } else { 0 }),
_ => None,
}
}
}
/// Apply the predictor to decoded data.
///
/// This function implements TIFF predictor 2 and PNG predictors 10-15
/// as specified in the PDF specification and PNG specification.
///
/// # Parameters
/// - `data`: The decoded (but still predicted) data
/// - `params`: Predictor parameters
/// - `max_output`: Maximum number of output bytes to produce (for bomb protection)
///
/// # Returns
/// The unpredicted data, or the original data if predictor is 1 or params are invalid
pub fn apply_predictor(data: &[u8], params: &PredictorParams, max_output: u64) -> Vec<u8> {
if data.is_empty() || params.predictor == 1 {
return data.to_vec();
}
match params.predictor {
2 => apply_tiff_predictor_2(data, params, max_output),
10..=15 => apply_png_predictors(data, params, max_output),
_ => data.to_vec(), // Unknown predictor - return as-is
}
}
/// Apply TIFF predictor 2 (horizontal differencing).
///
/// Each byte is the difference from the corresponding byte in the previous column.
/// For multi-byte pixels (e.g., 16-bit), the differencing is per-component.
///
/// Formula: output[j] = (input[j] + output[j-1]) % 256
fn apply_tiff_predictor_2(data: &[u8], params: &PredictorParams, max_output: u64) -> Vec<u8> {
let mut output = Vec::new(); // Don't pre-allocate - grow row-by-row
let row_size = params.bytes_per_row();
let bpp = params.bytes_per_pixel();
if row_size == 0 || data.len() % row_size != 0 {
// Invalid data - return as-is
return data.to_vec();
}
// If row_size was clamped, the PDF parameters are suspicious.
// Return data as-is rather than risking incorrect decoding.
if params.is_row_size_clamped() {
return data.to_vec();
}
for chunk in data.chunks_exact(row_size) {
// Check budget before processing this row
if output.len() as u64 + row_size as u64 > max_output {
break; // Budget exceeded - return partial data
}
// First byte of each row is copied as-is
output.push(chunk[0]);
// For each subsequent byte, add the byte bpp positions back
for i in 1..chunk.len() {
let prev = if i >= bpp {
output[output.len() - bpp]
} else {
0 // First byte of component - no previous
};
output.push(chunk[i].wrapping_add(prev));
}
}
output
}
/// Apply PNG predictors (10-15).
///
/// PNG predictors include a selector byte at the start of each row that
/// specifies which prediction algorithm to use for that row.
///
/// Predictors:
/// - 10 (None): Copy row as-is
/// - 11 (Sub): output[j] = input[j] + output[j - bpp]
/// - 12 (Up): output[j] = input[j] + prev_row[j]
/// - 13 (Average): output[j] = input[j] + (output[j - bpp] + prev_row[j]) / 2
/// - 14 (Paeth): output[j] = input[j] + paeth(output[j - bpp], prev_row[j], prev_row[j - bpp])
/// - 15 (Optimum): Selector byte chooses one of 10-14 per-row
fn apply_png_predictors(data: &[u8], params: &PredictorParams, max_output: u64) -> Vec<u8> {
let row_size_with_selector = params.bytes_per_row_with_selector();
let row_size = params.bytes_per_row();
let bpp = params.bytes_per_pixel();
if row_size == 0 || row_size_with_selector == 0 {
return data.to_vec();
}
// If row_size was clamped, the PDF parameters are suspicious.
// Return data as-is rather than risking incorrect decoding.
if params.is_row_size_clamped() {
return data.to_vec();
}
let num_rows = data.len() / row_size_with_selector;
if num_rows == 0 {
return data.to_vec();
}
let mut output = Vec::new(); // Don't pre-allocate - grow row-by-row
let mut prev_row: Vec<u8> = vec![0; row_size];
for row_idx in 0..num_rows {
let row_start = row_idx * row_size_with_selector;
let row_end = row_start + row_size_with_selector;
if row_end > data.len() {
break; // Incomplete row
}
let row_data = &data[row_start..row_end];
let selector = row_data[0];
let filtered = &row_data[1..];
if filtered.len() != row_size {
// Row size mismatch - copy as-is
if output.len() as u64 + filtered.len() as u64 > max_output {
break; // Budget exceeded
}
output.extend_from_slice(filtered);
continue;
}
// Check budget before processing this row
if output.len() as u64 + row_size as u64 > max_output {
break; // Budget exceeded - return partial data
}
let mut current_row = vec![0u8; row_size];
match selector {
0 | 10 => {
// None - copy as-is
current_row.copy_from_slice(filtered);
}
1 | 11 => {
// Sub: each byte is the difference from the corresponding byte of the prior pixel
for (i, &val) in filtered.iter().enumerate() {
let left = if i >= bpp { current_row[i - bpp] } else { 0 };
current_row[i] = val.wrapping_add(left);
}
}
2 | 12 => {
// Up: each byte is the difference from the corresponding byte of the previous row
for (i, &val) in filtered.iter().enumerate() {
current_row[i] = val.wrapping_add(prev_row[i]);
}
}
3 | 13 => {
// Average: each byte is the difference from the average of left and up
for (i, &val) in filtered.iter().enumerate() {
let left = if i >= bpp { current_row[i - bpp] } else { 0 };
let up = prev_row[i];
// Average using integer division
let avg = ((left as u16 + up as u16) / 2) as u8;
current_row[i] = val.wrapping_add(avg);
}
}
4 | 14 => {
// Paeth: each byte is the difference from the Paeth predictor
for (i, &val) in filtered.iter().enumerate() {
let left = if i >= bpp { current_row[i - bpp] } else { 0 };
let up = prev_row[i];
let up_left = if i >= bpp { prev_row[i - bpp] } else { 0 };
current_row[i] = val.wrapping_add(paeth(left, up, up_left));
}
}
_ => {
// Unknown selector - copy as-is
current_row.copy_from_slice(filtered);
}
}
output.extend_from_slice(&current_row);
prev_row = current_row;
}
output
}
/// Paeth predictor function for PNG filter type 4.
///
/// Computes a linear function of a, b, and c, choosing the predictor
/// that is closest to the true value.
#[inline]
fn paeth(a: u8, b: u8, c: u8) -> u8 {
let a = a as i16;
let b = b as i16;
let c = c as i16;
let p = a + b - c;
let pa = (p - a).abs();
let pb = (p - b).abs();
let pc = (p - c).abs();
if pa <= pb && pa <= pc {
a as u8
} else if pb <= pc {
b as u8
} else {
c as u8
}
}
/// FlateDecode filter (zlib/comflate compression).
#[derive(Debug, Clone, Copy)]
pub struct FlateDecoder;
impl FlateDecoder {
/// Decode with optional predictor application.
fn decode_with_predictor(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
if input.is_empty() {
return Ok(Vec::new());
}
// Parse predictor parameters
let pred_params = PredictorParams::from_pdf_object(params).unwrap_or_default();
let mut decoder = ZlibDecoder::new(input);
let mut output = Vec::new();
let mut chunk = vec![0u8; BOMB_CHECK_CHUNK];
// Track flate output separately - we'll count the final predictor output against doc_counter
let mut flate_bytes = 0u64;
loop {
match decoder.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
// Check bomb limit BEFORE adding bytes to output
if *doc_counter + flate_bytes + n as u64 > max_bytes {
// Bomb limit exceeded - return partial bytes
let remaining = (max_bytes - *doc_counter - flate_bytes) as usize;
let to_add = remaining.min(n);
output.extend_from_slice(&chunk[..to_add]);
// Pass remaining budget to predictor
let predictor_budget = max_bytes.saturating_sub(*doc_counter);
let predicted = apply_predictor(&output, &pred_params, predictor_budget);
// Update doc_counter with actual predictor output size
*doc_counter += predicted.len() as u64;
return Ok(predicted);
}
flate_bytes += n as u64;
output.extend_from_slice(&chunk[..n]);
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
// Truncated stream - return partial bytes (INV-8)
break;
}
Err(_) => {
// Other zlib errors - return partial bytes decoded so far
break;
}
}
}
// Pass remaining budget to predictor
let predictor_budget = max_bytes.saturating_sub(*doc_counter);
let predicted = apply_predictor(&output, &pred_params, predictor_budget);
// Update doc_counter with actual predictor output size
*doc_counter += predicted.len() as u64;
Ok(predicted)
}
}
impl StreamDecoder for FlateDecoder {
fn decode(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
self.decode_with_predictor(input, params, doc_counter, max_bytes)
}
fn name(&self) -> &'static str {
"FlateDecode"
}
}
/// LZWDecode filter (LZW compression).
///
/// LZW is an older compression scheme (PDF 1.2+) that uses variable-length codes.
/// The /EarlyChange parameter controls when code size increases:
/// - 1 = early change (default, Adobe/ TIFF variant)
/// - 0 = late change (GIF variant)
#[derive(Debug, Clone, Copy)]
pub struct LZWDecoder;
impl LZWDecoder {
/// Decode with optional predictor application.
fn decode_with_predictor(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
if input.is_empty() {
return Ok(Vec::new());
}
// Parse predictor parameters
let pred_params = PredictorParams::from_pdf_object(params).unwrap_or_default();
// Parse /EarlyChange parameter (default 1)
let early_change = PredictorParams::extract_early_change(params).unwrap_or(1);
// LZW min code size is always 8 bits in PDF
const MIN_CODE_SIZE: u8 = 8;
let mut output = Vec::new();
let mut remaining = input;
// Bomb limit tracking
let budget_remaining = max_bytes.saturating_sub(*doc_counter);
if early_change == 1 {
// Early change variant (Adobe/TIFF, PDF default)
let mut decoder = DecoderEarlyChange::new(MsbReader::new(), MIN_CODE_SIZE);
while !remaining.is_empty() {
match decoder.decode_bytes(remaining) {
Ok((consumed, data)) => {
remaining = &remaining[consumed..];
// Check bomb limit
if output.len() as u64 + data.len() as u64 > budget_remaining {
// Bomb limit exceeded - return partial bytes
let remaining_budget =
(budget_remaining as usize).saturating_sub(output.len());
output.extend_from_slice(&data[..remaining_budget.min(data.len())]);
let predictor_budget = max_bytes.saturating_sub(*doc_counter);
let predicted =
apply_predictor(&output, &pred_params, predictor_budget);
*doc_counter += predicted.len() as u64;
return Ok(predicted);
}
output.extend_from_slice(data);
// Empty data means we hit END_CODE
if data.is_empty() && consumed == 0 {
break;
}
}
Err(_) => {
// LZW decode error - return partial bytes (INV-8)
break;
}
}
}
} else {
// Late change variant (GIF)
let mut decoder = Decoder::new(MsbReader::new(), MIN_CODE_SIZE);
while !remaining.is_empty() {
match decoder.decode_bytes(remaining) {
Ok((consumed, data)) => {
remaining = &remaining[consumed..];
// Check bomb limit
if output.len() as u64 + data.len() as u64 > budget_remaining {
// Bomb limit exceeded - return partial bytes
let remaining_budget =
(budget_remaining as usize).saturating_sub(output.len());
output.extend_from_slice(&data[..remaining_budget.min(data.len())]);
let predictor_budget = max_bytes.saturating_sub(*doc_counter);
let predicted =
apply_predictor(&output, &pred_params, predictor_budget);
*doc_counter += predicted.len() as u64;
return Ok(predicted);
}
output.extend_from_slice(data);
// Empty data means we hit END_CODE
if data.is_empty() && consumed == 0 {
break;
}
}
Err(_) => {
// LZW decode error - return partial bytes (INV-8)
break;
}
}
}
}
// Apply predictor
let predictor_budget = max_bytes.saturating_sub(*doc_counter);
let predicted = apply_predictor(&output, &pred_params, predictor_budget);
*doc_counter += predicted.len() as u64;
Ok(predicted)
}
}
impl StreamDecoder for LZWDecoder {
fn decode(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
self.decode_with_predictor(input, params, doc_counter, max_bytes)
}
fn name(&self) -> &'static str {
"LZWDecode"
}
}
/// ASCII85Decode filter (Base85 encoding).
///
/// Converts 5 ASCII characters to 4 bytes. Special handling:
/// - 'z' shortcut for 4 zero bytes
/// - '~>' terminator
/// - PDF spec whitespace ignored (0x00, 0x09, 0x0A, 0x0C, 0x0D, 0x20)
///
/// Per PDF spec 7.4.3:
/// - Valid ASCII85 range: 0x21 (!) through 0x75 (u), mapped to values 0-84
/// - Whitespace is ignored (per spec 7.2.2: NUL, HT, LF, FF, CR, Space)
/// - 'z' shortcut emits 4 zero bytes, valid only at start of a 5-tuple
/// - '~>' terminator marks end of data
/// - Partial final tuple: for n chars, output (n-1) bytes
#[derive(Debug, Clone, Copy)]
pub struct ASCII85Decoder;
impl ASCII85Decoder {
/// Check if a byte is PDF whitespace per spec 7.2.2.
///
/// PDF whitespace is: NUL (0), HT (9), LF (10), FF (12), CR (13), Space (32).
/// Note: This is NOT the same as Rust's `is_ascii_whitespace()`.
#[inline]
fn is_pdf_whitespace(byte: u8) -> bool {
matches!(byte, 0 | 9 | 10 | 12 | 13 | 32)
}
/// Check if adding a value to the accumulator would overflow u32.
#[inline]
fn check_overflow(acc: u32, value: u32) -> bool {
// Check: acc * 85 + value > u32::MAX
// This is equivalent to: acc > (u32::MAX - value) / 85
acc > (u32::MAX - value) / 85
}
}
impl StreamDecoder for ASCII85Decoder {
fn decode(
&self,
input: &[u8],
_params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
let mut output = Vec::new();
let mut tuple = [0u32; 5];
let mut count = 0;
let mut total_output = 0u64;
let mut i = 0;
while i < input.len() {
let byte = input[i];
// Skip '<~' prefix
if byte == b'<' && i + 1 < input.len() && input[i + 1] == b'~' {
i += 2;
continue;
}
// Skip '<' alone (partial prefix)
if byte == b'<' {
i += 1;
continue;
}
// Skip PDF whitespace (per spec 7.2.2: NUL, HT, LF, FF, CR, Space)
if Self::is_pdf_whitespace(byte) {
i += 1;
continue;
}
// Check for '~>' terminator
// This must come after whitespace/prefix checks so we don't break on
// whitespace before the terminator
if byte == b'~' && i + 1 < input.len() && input[i + 1] == b'>' {
break;
}
// 'z' shortcut: 4 zero bytes
// Per spec: 'z' MUST only be valid at count == 0 (start of a tuple)
// A 'z' mid-group is an error - we skip it and continue (INV-8)
if byte == b'z' {
if count == 0 {
// Valid 'z' shortcut
if total_output + 4 > max_bytes - *doc_counter {
*doc_counter += total_output;
return Ok(output);
}
output.extend_from_slice(&[0u8; 4]);
total_output += 4;
}
// If count != 0, 'z' is mid-group - skip it (error recovery per INV-8)
i += 1;
continue;
}
// Decode ASCII85 character (0x21..0x75 range -> 0-84)
// Per spec: bytes outside ! through u (33-117) are invalid
// We skip them and continue (INV-8 error recovery)
if byte < 0x21 || byte > 0x75 {
i += 1;
continue;
}
let value = (byte - 0x21) as u32;
// Check for overflow before adding to accumulator
// Per spec: accumulator * 85 + value can overflow - we skip the tuple
if count > 0 && Self::check_overflow(tuple[count - 1], value) {
// Overflow detected - reset and continue (error recovery per INV-8)
count = 0;
i += 1;
continue;
}
tuple[count] = value;
count += 1;
if count == 5 {
// Decode 5-tuple to 4 bytes using iterative algorithm
// accumulator = (((v0 * 85 + v1) * 85 + v2) * 85 + v3) * 85 + v4
let mut acc: u32 = 0;
for &v in &tuple {
acc = acc.wrapping_mul(85).wrapping_add(v);
}
if total_output + 4 > max_bytes - *doc_counter {
*doc_counter += total_output;
return Ok(output);
}
output.extend_from_slice(&[
(acc >> 24) as u8,
((acc >> 16) & 0xFF) as u8,
((acc >> 8) & 0xFF) as u8,
(acc & 0xFF) as u8,
]);
total_output += 4;
count = 0;
}
i += 1;
}
// Handle partial final tuple
// Per PDF spec: for n chars, output (n-1) bytes
// The partial tuple is padded with 'u' (value 84) and then extra bytes removed
if count > 0 {
// Pad remaining tuple slots with 'u' (value 84)
// 'u' (117) - '!' (33) = 84
for j in count..5 {
tuple[j] = 84;
}
// Decode using iterative algorithm
let mut acc: u32 = 0;
for &v in &tuple {
acc = acc.wrapping_mul(85).wrapping_add(v);
}
// Output only (count - 1) bytes from the 4-byte tuple
// The remaining bytes are padding and should be discarded
let bytes_to_output = count - 1;
if total_output + bytes_to_output as u64 > max_bytes - *doc_counter {
*doc_counter += total_output;
return Ok(output);
}
for j in 0..bytes_to_output {
output.push((acc >> (24 - 8 * j)) as u8);
}
total_output += bytes_to_output as u64;
}
*doc_counter += total_output;
Ok(output)
}
fn name(&self) -> &'static str {
"ASCII85Decode"
}
}
/// ASCIIHexDecode filter (hexadecimal encoding).
///
/// Converts hex digit pairs to bytes. Whitespace ignored.
/// '>' terminator marks end of data.
///
/// Per PDF spec 7.4.2:
/// - Hex digit pairs are decoded to bytes
/// - Whitespace (NUL, HT, LF, FF, CR, Space) is ignored
/// - '>' terminator marks end of data
/// - Odd-length final pair pads with low nibble = 0
/// - Non-hex bytes are skipped (invalid, decoder continues)
#[derive(Debug, Clone, Copy)]
pub struct ASCIIHexDecoder;
impl ASCIIHexDecoder {
/// Check if a byte is PDF whitespace per spec 7.2.2.
///
/// PDF whitespace is: NUL (0), HT (9), LF (10), FF (12), CR (13), Space (32).
/// Note: This is NOT the same as Rust's char::is_whitespace().
#[inline]
fn is_pdf_whitespace(byte: u8) -> bool {
matches!(byte, 0 | 9 | 10 | 12 | 13 | 32)
}
/// Decode a hex nibble (0-15) from a byte, or None if not a valid hex digit.
#[inline]
fn decode_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'A'..=b'F' => Some(byte - b'A' + 10),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
}
impl StreamDecoder for ASCIIHexDecoder {
fn decode(
&self,
input: &[u8],
_params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
let mut output = Vec::new();
let mut high_nibble: Option<u8> = None;
for &byte in input {
// Check for '>' terminator first
if byte == b'>' {
break;
}
// Skip PDF whitespace (not Rust's is_whitespace)
if Self::is_pdf_whitespace(byte) {
continue;
}
// Try to decode hex nibble
let nibble = match Self::decode_nibble(byte) {
Some(n) => n,
None => continue, // Invalid hex - skip and continue per INV-8
};
match high_nibble {
Some(high) => {
// Complete byte: high nibble (from before) + low nibble (current)
// Check bomb limit BEFORE adding the byte
if *doc_counter >= max_bytes {
return Ok(output);
}
output.push((high << 4) | nibble);
*doc_counter += 1;
high_nibble = None;
}
None => {
// Store high nibble, wait for low nibble
high_nibble = Some(nibble);
}
}
}
// Handle odd-length final pair: pad with low nibble = 0
// Per PDF spec 7.4.2 and bead requirements:
// <3> decodes to 0x30 (3 is HIGH nibble, low nibble is implicit 0)
if let Some(high) = high_nibble {
output.push(high << 4);
*doc_counter += 1;
}
Ok(output)
}
fn name(&self) -> &'static str {
"ASCIIHexDecode"
}
}
/// Crypt filter (PDF spec 7.4.10).
///
/// The Crypt filter controls per-stream decryption in PDFs with V=4 / V=5 encryption.
/// This implementation:
/// - /Identity (or missing /Name): pass through unchanged (no-op)
/// - Custom crypt filter: return FilterError::EncryptionUnsupported
///
/// Per PDF spec, the Crypt filter is a marker that indicates whether the stream
/// should be decrypted with a specific algorithm. The actual decryption happens
/// in the encryption handler (Phase 1.4), not in this filter. This filter is just
/// a no-op/reject marker.
#[derive(Debug, Clone, Copy)]
pub struct CryptDecoder;
impl CryptDecoder {
/// Decode with crypt filter parameter checking.
fn decode_with_params(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
// Extract /DecodeParms to check /Name
let decode_parms = match params {
Some(PdfObject::Dict(d)) => d.as_ref(),
Some(_) => {
// Invalid /DecodeParms type - treat as missing (default to /Identity)
return Self::pass_through(input, doc_counter, max_bytes);
}
None => {
// No /DecodeParms - default to /Identity per spec
return Self::pass_through(input, doc_counter, max_bytes);
}
};
// Check for /Type /CryptFilterDecodeParms (optional per spec)
if let Some(PdfObject::Name(type_name)) = decode_parms.get("/Type") {
if type_name.as_ref() != "CryptFilterDecodeParms" {
// Wrong type - treat as missing (default to /Identity)
return Self::pass_through(input, doc_counter, max_bytes);
}
}
// Check /Name parameter
let crypt_name = match decode_parms.get("/Name") {
Some(PdfObject::Name(n)) => n.as_ref(),
Some(_) => {
// /Name is not a name object - treat as missing (default to /Identity)
return Self::pass_through(input, doc_counter, max_bytes);
}
None => {
// /Name missing - default to /Identity per spec
return Self::pass_through(input, doc_counter, max_bytes);
}
};
// Check if /Name is /Identity
if crypt_name == "Identity" {
Self::pass_through(input, doc_counter, max_bytes)
} else {
// Custom crypt filter - not supported
Err(FilterError::EncryptionUnsupported)
}
}
/// Pass input through unchanged, enforcing bomb limit.
fn pass_through(
input: &[u8],
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
let len = input.len() as u64;
*doc_counter += len;
if *doc_counter > max_bytes {
// Truncate to stay within limit
let remaining = max_bytes.saturating_sub(*doc_counter - len);
return Ok(input[..remaining.min(len) as usize].to_vec());
}
Ok(input.to_vec())
}
}
impl StreamDecoder for CryptDecoder {
fn decode(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
self.decode_with_params(input, params, doc_counter, max_bytes)
}
fn name(&self) -> &'static str {
"Crypt"
}
}
/// RunLengthDecode filter (RLE compression).
///
/// Per PDF spec 7.4.5:
/// - Length byte 0-127: copy next (len+1) bytes literally
/// - Length byte 128: end of data
/// - Length byte 129-255: repeat next byte (257-len) times
///
/// This is a simple compression scheme used for bitmap data and some
/// content streams. The algorithm is byte-oriented and handles
/// truncated input gracefully per INV-8.
#[derive(Debug, Clone, Copy)]
pub struct RunLengthDecoder;
impl RunLengthDecoder {
/// Decode RunLength-encoded data.
///
/// Per PDF spec 7.4.5, the length byte determines the action:
/// - 0..=127: copy the next (len+1) bytes literally
/// - 128: end of data (EOD marker)
/// - 129..=255: repeat the next byte (257-len) times (range 2..=128)
///
/// Unexpected EOF mid-run returns partial bytes decoded so far
/// (INV-8: never panic on malformed input).
fn decode_internal(input: &[u8], doc_counter: &mut u64, max_bytes: u64) -> Vec<u8> {
let mut output = Vec::new();
let mut iter = input.iter().copied();
while let Some(len_byte) = iter.next() {
match len_byte {
0..=127 => {
// Copy next (len+1) bytes literally
let copy_count = (len_byte + 1) as usize;
// Check bomb limit
if *doc_counter + copy_count as u64 > max_bytes {
// Bomb limit exceeded - copy what we can and stop
let remaining = (max_bytes - *doc_counter) as usize;
let to_copy = remaining.min(copy_count);
for _ in 0..to_copy {
if let Some(byte) = iter.next() {
output.push(byte);
*doc_counter += 1;
} else {
break; // EOF reached
}
}
break; // Stop decoding
}
// Copy bytes
for _ in 0..copy_count {
match iter.next() {
Some(byte) => output.push(byte),
None => break, // Truncated input - stop here
}
}
*doc_counter += copy_count as u64;
}
128 => {
// End of data marker
break;
}
129..=255 => {
// Repeat next byte (257 - len) times
// 129 -> 128 repeats, ..., 255 -> 2 repeats
let repeat_count = (257 - len_byte as usize) as usize;
// Get the byte to repeat
let byte = match iter.next() {
Some(b) => b,
None => break, // Truncated input - no byte to repeat
};
// Check bomb limit
if *doc_counter + repeat_count as u64 > max_bytes {
// Bomb limit exceeded - repeat what we can and stop
let remaining = (max_bytes - *doc_counter) as usize;
let to_repeat = remaining.min(repeat_count);
for _ in 0..to_repeat {
output.push(byte);
*doc_counter += 1;
}
break; // Stop decoding
}
// Repeat the byte
for _ in 0..repeat_count {
output.push(byte);
}
*doc_counter += repeat_count as u64;
}
}
}
output
}
}
impl StreamDecoder for RunLengthDecoder {
fn decode(
&self,
input: &[u8],
_params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
if input.is_empty() {
return Ok(Vec::new());
}
Ok(Self::decode_internal(input, doc_counter, max_bytes))
}
fn name(&self) -> &'static str {
"RunLengthDecode"
}
}
/// Passthrough decoder for filters we don't decode (DCTDecode, JBIG2Decode, etc.).
///
/// Returns the raw bytes unchanged. Used for:
/// - DCTDecode (JPEG) - pass raw JPEG bytes
/// - JBIG2Decode - pass raw JBIG2 bytes
/// - JPXDecode - pass raw JPEG2000 bytes
/// - Crypt with /Identity
#[derive(Debug, Clone, Copy)]
pub struct PassthroughDecoder {
name: &'static str,
}
impl PassthroughDecoder {
pub fn new(name: &'static str) -> Self {
Self { name }
}
}
impl StreamDecoder for PassthroughDecoder {
fn decode(
&self,
input: &[u8],
_params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
let len = input.len() as u64;
*doc_counter += len;
if *doc_counter > max_bytes {
// Truncate to stay within limit
let remaining = max_bytes.saturating_sub(*doc_counter - len);
return Ok(input[..remaining.min(len) as usize].to_vec());
}
Ok(input.to_vec())
}
fn name(&self) -> &'static str {
self.name
}
}
/// CCITTFaxDecode filter (Group 3/4 fax compression) passthrough with parameter parsing.
///
/// CCITT Group 3/4 is the dominant compression for scanned legal documents and faxed PDFs.
/// This decoder:
/// - Passes through raw CCITT bytes unchanged (pdftract-core does not decode CCITT)
/// - Parses and validates /DecodeParms (/K, /Columns, /Rows, /EncodedByteAlign, /EndOfLine, /BlackIs1)
/// - Records parameters for downstream consumers (via PdfStream dict)
///
/// For OCR path: requires `full-render` feature or libtiff system library.
/// Without either, emit OCR_CCITT_UNSUPPORTED diagnostic (handled at call site).
///
/// Per PDF spec 7.4.6:
/// - /K: encoding type (-1 = Group 4, 0 = Group 3 1D, > 0 = Group 3 2D with K rows)
/// - /Columns: image width in pixels (REQUIRED)
/// - /Rows: image height in pixels (optional)
/// - /EncodedByteAlign: whether each line is byte-aligned (bool, default false)
/// - /EndOfLine: whether EOL markers are present (bool, default false)
/// - /BlackIs1: whether 1 bit means black or white (bool, default false)
#[derive(Debug, Clone, Copy)]
pub struct CCITTFaxDecoder;
impl CCITTFaxDecoder {
/// Default /Columns value for CCITT when not specified (standard A4 width at 204 DPI).
/// Per PDF spec 7.4.6, /Columns is required, but we use a default for error recovery.
const DEFAULT_COLUMNS: u32 = 1728;
/// Parse CCITT /DecodeParms from a PDF object.
///
/// Returns None if params is None or not a dictionary.
/// Returns Some(ParsedCCITTParams) if params is a dictionary (missing keys use defaults).
///
/// Per INV-8 and the passthrough pattern, this function never returns an error.
/// Missing /Columns uses DEFAULT_COLUMNS (1728, standard fax width).
pub fn parse_params(params: Option<&PdfObject>) -> Option<ParsedCCITTParams> {
let dict = match params {
Some(PdfObject::Dict(d)) => d.as_ref(),
Some(_) => return None, // Invalid type - treat as missing
None => return None, // No params - use defaults
};
// /Columns is REQUIRED per PDF spec 7.4.6, but we use a default for error recovery.
// If /Columns is missing or invalid, we use DEFAULT_COLUMNS (1728, standard fax width).
let columns = match dict.get("/Columns") {
Some(PdfObject::Integer(n)) if *n > 0 => *n as u32,
_ => Self::DEFAULT_COLUMNS, // Missing, invalid, or non-positive -> use default
};
// /K: encoding type (default = 0, which means Group 3 1D)
// -1 = Group 4, 0 = Group 3 1D, > 0 = Group 3 2D
let k = match dict.get("/K") {
Some(PdfObject::Integer(n)) => *n as i32,
_ => 0, // Invalid type or missing -> use default
};
// /Rows: image height in pixels (optional)
let rows = match dict.get("/Rows") {
Some(PdfObject::Integer(n)) if *n > 0 => Some(*n as u32),
_ => None, // Invalid value, missing, or invalid type -> treat as missing
};
// /EncodedByteAlign: whether each line is byte-aligned (default false)
let encoded_byte_align = match dict.get("/EncodedByteAlign") {
Some(PdfObject::Bool(b)) => *b,
_ => false, // Invalid type or missing -> use default
};
// /EndOfLine: whether EOL markers are present (default false)
let end_of_line = match dict.get("/EndOfLine") {
Some(PdfObject::Bool(b)) => *b,
_ => false, // Invalid type or missing -> use default
};
// /BlackIs1: whether 1 bit means black (default false = white)
let black_is_1 = match dict.get("/BlackIs1") {
Some(PdfObject::Bool(b)) => *b,
_ => false, // Invalid type or missing -> use default
};
Some(ParsedCCITTParams {
k,
columns,
rows,
encoded_byte_align,
end_of_line,
black_is_1,
})
}
}
impl StreamDecoder for CCITTFaxDecoder {
fn decode(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
// Parse /DecodeParms (uses defaults for missing/invalid values per INV-8)
let _parsed = Self::parse_params(params);
// Pass through raw bytes unchanged
let len = input.len() as u64;
*doc_counter += len;
if *doc_counter > max_bytes {
// Truncate to stay within limit
let remaining = max_bytes.saturating_sub(*doc_counter - len);
return Ok(input[..remaining.min(len) as usize].to_vec());
}
Ok(input.to_vec())
}
fn name(&self) -> &'static str {
"CCITTFaxDecode"
}
}
/// Parsed CCITT /DecodeParms.
///
/// These parameters are extracted from the /DecodeParms dictionary
/// and describe the CCITT encoding parameters for the image.
///
/// Per PDF spec 7.4.6:
/// - /K: encoding type (-1 = Group 4, 0 = Group 3 1D, > 0 = Group 3 2D)
/// - /Columns: image width in pixels (REQUIRED)
/// - /Rows: image height in pixels (optional)
/// - /EncodedByteAlign: whether each line is byte-aligned (default false)
/// - /EndOfLine: whether EOL markers are present (default false)
/// - /BlackIs1: whether 1 bit means black (default false = white)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedCCITTParams {
/// Encoding type: -1 = Group 4, 0 = Group 3 1D, > 0 = Group 3 2D
pub k: i32,
/// Image width in pixels (REQUIRED)
pub columns: u32,
/// Image height in pixels (optional)
pub rows: Option<u32>,
/// Whether each line is byte-aligned
pub encoded_byte_align: bool,
/// Whether EOL markers are present
pub end_of_line: bool,
/// Whether 1 bit means black (true) or white (false)
pub black_is_1: bool,
}
/// DCTDecode filter (JPEG) passthrough with SOI/EOI marker validation.
///
/// This decoder:
/// - Validates SOI (0xFFD8) and EOI (0xFFD9) markers
/// - Parses and records /ColorTransform from /DecodeParms
/// - Passes through raw JPEG bytes unchanged (pdftract-core does not decode JPEG)
///
/// Per PDF spec 7.4.8:
/// - /ColorTransform: 0 = none, 1 = YCbCr conversion (default for 3-channel images)
///
/// For OCR path: JPEG data is passed to libjpeg-turbo / image crate for decoding.
/// For no-OCR case: raw bytes are passed through unchanged.
///
/// Note: Some buggy PDF producers omit EOI; we emit STREAM_INVALID_JPEG warning
/// but pass through the data anyway (INV-8 error recovery).
#[derive(Debug, Clone, Copy)]
pub struct DCTDecoder;
impl DCTDecoder {
/// JPEG SOI (Start Of Image) marker: 0xFFD8
const JPEG_SOI: [u8; 2] = [0xFF, 0xD8];
/// JPEG EOI (End Of Image) marker: 0xFFD9
const JPEG_EOI: [u8; 2] = [0xFF, 0xD9];
/// Parse DCTDecode /DecodeParms to extract /ColorTransform.
///
/// Returns None if params is None or not a dictionary.
/// Returns Some(color_transform) if params is a dictionary (missing /ColorTransform defaults to None).
///
/// Per PDF spec 7.4.8:
/// - /ColorTransform 0 = no transformation (RGB or grayscale)
/// - /ColorTransform 1 = YCbCr to RGB conversion (default for 3-component images)
pub fn parse_color_transform(params: Option<&PdfObject>) -> Option<i64> {
let dict = match params {
Some(PdfObject::Dict(d)) => d.as_ref(),
Some(_) => return None, // Invalid type - treat as missing
None => return None, // No params - use default
};
match dict.get("/ColorTransform") {
Some(PdfObject::Integer(n)) => Some(*n),
Some(PdfObject::Bool(b)) => Some(if *b { 1 } else { 0 }),
_ => None, // Missing /ColorTransform - use default
}
}
/// Validate JPEG markers (SOI at start, EOI at end).
///
/// Returns (has_soi, has_eoi). Missing markers emit diagnostics but don't
/// fail the decode (INV-8: always return partial bytes).
fn validate_markers(input: &[u8], diagnostics: &mut Vec<Diagnostic>) -> (bool, bool) {
let has_soi = input.len() >= 2 && input[0..2] == Self::JPEG_SOI;
let has_eoi = input.len() >= 2 && input[input.len() - 2..] == Self::JPEG_EOI;
if !has_soi {
diagnostics.push(Diagnostic::with_static_no_offset(
DiagCode::StreamInvalidJpeg,
"Missing SOI (Start Of Image) marker at start of JPEG data",
));
}
if !has_eoi {
diagnostics.push(Diagnostic::with_dynamic(
DiagCode::StreamInvalidJpeg,
input.len().saturating_sub(2) as u64,
format!(
"Missing EOI (End Of Image) marker at end of JPEG data (length: {})",
input.len()
),
));
}
(has_soi, has_eoi)
}
}
impl StreamDecoder for DCTDecoder {
fn decode(
&self,
input: &[u8],
params: Option<&PdfObject>,
doc_counter: &mut u64,
max_bytes: u64,
) -> Result<Vec<u8>, FilterError> {
// Parse /ColorTransform from /DecodeParms (for downstream consumers)
let _color_transform = Self::parse_color_transform(params);
// Validate SOI/EOI markers (emit diagnostics if missing, but pass through anyway)
let mut diagnostics = Vec::new();
let (_has_soi, _has_eoi) = Self::validate_markers(input, &mut diagnostics);
// TODO: Store diagnostics somewhere for downstream consumers
// For now, we'll just drop them since the StreamDecoder trait doesn't
// provide a way to emit them. In a future change, we may extend the
// trait to accept a diagnostics buffer.
// Pass through raw bytes unchanged, enforcing bomb limit
let len = input.len() as u64;
*doc_counter += len;
if *doc_counter > max_bytes {
// Truncate to stay within limit
let remaining = max_bytes.saturating_sub(*doc_counter - len);
return Ok(input[..remaining.min(len) as usize].to_vec());
}
Ok(input.to_vec())
}
fn name(&self) -> &'static str {
"DCTDecode"
}
}
/// Normalize a filter name, expanding abbreviations per PDF spec 7.4.2 Table 6.
///
/// Abbreviations:
/// - /A85 -> /ASCII85Decode
/// - /AHx -> /ASCIIHexDecode
/// - /CCF -> /CCITTFaxDecode
/// - /Fl -> /FlateDecode
/// - /LZW -> /LZWDecode
/// - /RL -> /RunLengthDecode
/// - /DCT -> /DCTDecode
pub fn normalize_filter_name(name: &str) -> &str {
match name {
"A85" => "ASCII85Decode",
"AHx" => "ASCIIHexDecode",
"CCF" => "CCITTFaxDecode",
"Fl" => "FlateDecode",
"LZW" => "LZWDecode",
"RL" => "RunLengthDecode",
"DCT" => "DCTDecode",
other => other,
}
}
/// Get a decoder for the given filter name.
///
/// Returns None for unknown filters (should emit STRUCT_UNKNOWN_FILTER).
pub fn get_decoder(name: &str) -> Option<Box<dyn StreamDecoder>> {
match normalize_filter_name(name) {
"FlateDecode" => Some(Box::new(FlateDecoder)),
"LZWDecode" => Some(Box::new(LZWDecoder)),
"ASCII85Decode" => Some(Box::new(ASCII85Decoder)),
"ASCIIHexDecode" => Some(Box::new(ASCIIHexDecoder)),
"Crypt" => Some(Box::new(CryptDecoder)),
"DCTDecode" => Some(Box::new(DCTDecoder)),
"JBIG2Decode" => Some(Box::new(PassthroughDecoder::new("JBIG2Decode"))),
"JPXDecode" => Some(Box::new(PassthroughDecoder::new("JPXDecode"))),
"CCITTFaxDecode" => Some(Box::new(CCITTFaxDecoder)),
"RunLengthDecode" => Some(Box::new(RunLengthDecoder)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
#[test]
fn test_flate_decode_simple() {
let input = b"\x78\x9c\xcbH\xcd\xc9\xc9\x07\x00\x06,\x02\x15"; // "hello" compressed
let mut counter = 0;
let result = FlateDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, b"hello");
}
#[test]
fn test_ascii85_decode() {
// "Hello" encoded in ASCII85
let input = b"<~87cURDZ~>";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(String::from_utf8_lossy(&output), "Hello");
}
#[test]
fn test_ascii85_z_shortcut() {
// 'z' should decode to 4 zero bytes
let input = b"z";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0u8; 4]);
}
#[test]
fn test_ascii85_partial_final_group() {
// 3 characters (less than 5) - should output 2 bytes
let input = b"<~87c~>"; // First 3 chars of a 5-tuple (decodes to "He")
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// Partial tuple with 3 chars -> 2 bytes output
assert_eq!(output.len(), 2);
assert_eq!(output, b"He");
}
#[test]
fn test_ascii85_zz_double_shortcut() {
// "zz" should decode to 8 zero bytes
let input = b"zz";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0u8; 8]);
}
#[test]
fn test_ascii85_pdf_whitespace() {
// Test all PDF whitespace types: NUL(0), HT(9), LF(10), FF(12), CR(13), Space(32)
// "Hello" encoded with various whitespace chars interspersed
let input = b"<~\t87\n\rcUR\r\nDZ~>"; // 87cURDZ = "Hello"
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(String::from_utf8_lossy(&output), "Hello");
}
#[test]
fn test_ascii85_invalid_bytes_skipped() {
// Invalid bytes outside 0x21..0x75 range should be skipped
// "Hello" with some invalid chars that should be ignored
let input = b"<~87c\x00URDZ~>"; // NUL in middle should be skipped
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// With NUL skipped, we get partial decoding
assert!(!output.is_empty());
}
#[test]
fn test_ascii85_z_mid_group_skipped() {
// 'z' mid-group should be skipped (error recovery)
// <~abcz~> - the 'z' appears after 3 chars, should be skipped
let input = b"<~abcz~>";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// 'z' is skipped, we get partial output from "abc"
assert_eq!(output.len(), 2); // 3 chars -> 2 bytes
}
#[test]
fn test_ascii85_roundtrip_known_vectors() {
// Test roundtrip with known good ASCII85 encodings
// These verify the decoding algorithm is correct
// Test 1: Multiple 4-byte groups
// Original: "HelloWorld!" (12 bytes = 3 groups of 4)
let input = b"<~87cURDZ~>"; // First group only
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(String::from_utf8_lossy(&output), "Hello");
// Test 2: All zeros (uses 'z' shortcut)
let input = b"<~zz~>";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0u8; 8]); // 2 'z' chars = 8 zero bytes
// Test 3: Partial group at end
// "ABC" (3 bytes) encodes to 4 chars
let input = b"<~5sdp~>";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, b"ABC");
}
#[test]
fn test_ascii85_bomb_limit() {
// Test that bomb limit is enforced
let input = b"zzzzzz"; // 6 'z' chars = 24 zero bytes
let mut counter = 0;
let limit = 10; // Only allow 10 bytes
let result = ASCII85Decoder.decode(input, None, &mut counter, limit);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.len() <= 10); // Should truncate at bomb limit
}
#[test]
fn test_ascii85_empty_stream() {
// Empty input should produce empty output
let input = b"";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 0);
}
#[test]
fn test_ascii85_no_delimiters() {
// Input without <~ ~> should still decode
let input = b"87cURDZ"; // "Hello" without delimiters
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(String::from_utf8_lossy(&output), "Hello");
}
#[test]
fn test_ascii85_full_range() {
// Test decoding the maximum ASCII85 value (0xFFFFFFFF)
// The encoding of 0xFFFFFFFF is "s8W-!" (per the spec)
let input = b"<~s8W-!~>";
let mut counter = 0;
let result = ASCII85Decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0xFF, 0xFF, 0xFF, 0xFF]);
}
#[test]
fn test_asciihex_decode() {
let input = b"48656C6C6F>"; // "Hello" in hex
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, b"Hello");
}
#[test]
fn test_asciihex_odd_length_single() {
// <3> should decode to [0x30]
// 3 is the HIGH nibble, low nibble is implicit 0
let input = b"3>";
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0x30]);
}
#[test]
fn test_asciihex_odd_length_triple() {
// <ABC> should decode to [0xAB, 0xC0]
// AB forms a complete byte (0xAB)
// C is the HIGH nibble of the second byte, low nibble is implicit 0
let input = b"ABC>";
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0xAB, 0xC0]);
}
#[test]
fn test_asciihex_mixed_case() {
// <aF> and <Af> should both decode to [0xAF]
let input1 = b"aF>";
let input2 = b"Af>";
let mut counter = 0;
let result1 =
ASCIIHexDecoder.decode(input1, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result1.is_ok());
let output1 = result1.unwrap();
assert_eq!(output1, &[0xAF]);
let mut counter = 0;
let result2 =
ASCIIHexDecoder.decode(input2, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result2.is_ok());
let output2 = result2.unwrap();
assert_eq!(output2, &[0xAF]);
}
#[test]
fn test_asciihex_whitespace_ignored() {
// <A B C D> should decode to [0xAB, 0xCD]
// PDF spec whitespace: NUL, HT, LF, FF, CR, Space
let input = b"A B\tC\nD\r>";
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, &[0xAB, 0xCD]);
}
#[test]
fn test_asciihex_pdf_whitespace_types() {
// Test all PDF whitespace types: NUL(0), HT(9), LF(10), FF(12), CR(13), Space(32)
let input = b"\x00\x09\x0A\x0C\x0D\x20 41 42>"; // "AB" with all whitespace types
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, b"AB");
}
#[test]
fn test_asciihex_invalid_bytes_continue() {
// Invalid hex bytes should be skipped, decoder continues
// <A G B> -> A=valid, G=invalid (skip), B=valid (needs pair)
// Since A is waiting for a pair and G is skipped, we get just A padded
let input = b"AxGBy>";
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// A (0xA) is high nibble, x is invalid, G is invalid, B (0xB) forms 0xAB
// y is invalid, so we get 0xAB
assert_eq!(output, &[0xAB]);
}
#[test]
fn test_asciihex_empty_stream() {
// <> should decode to empty bytes
let input = b">";
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 0);
}
#[test]
fn test_asciihex_no_terminator() {
// Input without '>' should decode all valid hex pairs
let input = b"4142"; // "AB"
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, b"AB");
}
#[test]
fn test_asciihex_roundtrip_random() {
// Round-trip test: encode random bytes as hex, decode back
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// Create a deterministic 1 KB pattern using a seed
let mut hasher = DefaultHasher::new();
42u64.hash(&mut hasher);
let mut original = Vec::with_capacity(1024);
for i in 0..1024 {
let byte = ((i * 17 + 42) % 256) as u8;
original.push(byte);
}
// Encode as hex
let mut hex = Vec::new();
for &byte in &original {
hex.push(b"0123456789ABCDEF"[(byte >> 4) as usize]);
hex.push(b"0123456789ABCDEF"[(byte & 0x0F) as usize]);
}
hex.push(b'>');
// Decode back
let mut counter = 0;
let result = ASCIIHexDecoder.decode(&hex, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let decoded = result.unwrap();
// Should be byte-identical
assert_eq!(decoded, original);
}
#[test]
fn test_asciihex_bomb_limit() {
// Test that bomb limit is enforced
let input = b"0102030405060708"; // 4 bytes
let mut counter = 0;
let limit = 2; // Only allow 2 bytes
let result = ASCIIHexDecoder.decode(input, None, &mut counter, limit);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 2); // Should truncate at bomb limit
}
#[test]
fn test_asciihex_all_nibbles() {
// Test all 16 hex digits in both cases
let input = b"0123456789ABCDEFabcdef>";
let mut counter = 0;
let result =
ASCIIHexDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xab, 0xcd, 0xef (odd last)
assert_eq!(
output,
&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xAB, 0xCD, 0xEF,]
);
}
#[test]
fn test_normalize_filter_names() {
assert_eq!(normalize_filter_name("A85"), "ASCII85Decode");
assert_eq!(normalize_filter_name("AHx"), "ASCIIHexDecode");
assert_eq!(normalize_filter_name("Fl"), "FlateDecode");
assert_eq!(normalize_filter_name("LZW"), "LZWDecode");
assert_eq!(normalize_filter_name("FlateDecode"), "FlateDecode"); // No change
}
#[test]
fn test_dctdecode_passthrough_valid_jpeg() {
// Valid JPEG with SOI and EOI markers
let mut jpeg_data = vec![0xFF, 0xD8]; // SOI
jpeg_data.extend_from_slice(b"fake_jpeg_data");
jpeg_data.extend_from_slice(&[0xFF, 0xD9]); // EOI
let mut counter = 0;
let result =
DCTDecoder.decode(&jpeg_data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// Pass through unchanged
assert_eq!(output, jpeg_data);
// Byte counter should be incremented
assert_eq!(counter, jpeg_data.len() as u64);
}
#[test]
fn test_dctdecode_passthrough_missing_soi() {
// JPEG data without SOI marker (still passes through)
let jpeg_data = b"fake_jpeg_data\xFF\xD9"; // Missing SOI, has EOI
let mut counter = 0;
let result = DCTDecoder.decode(jpeg_data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// Still passes through unchanged even without SOI
assert_eq!(output, jpeg_data.to_vec());
}
#[test]
fn test_dctdecode_passthrough_missing_eoi() {
// JPEG data without EOI marker (some buggy PDFs omit this)
let mut jpeg_data = vec![0xFF, 0xD8]; // SOI
jpeg_data.extend_from_slice(b"fake_jpeg_data"); // Missing EOI
let mut counter = 0;
let result =
DCTDecoder.decode(&jpeg_data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// Still passes through unchanged even without EOI
assert_eq!(output, jpeg_data);
}
#[test]
fn test_dctdecode_passthrough_empty() {
// Empty JPEG data (edge case)
let jpeg_data = b"";
let mut counter = 0;
let result = DCTDecoder.decode(jpeg_data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 0);
}
#[test]
fn test_dctdecode_bomb_limit() {
// Test that bomb limit is enforced
let mut jpeg_data = vec![0xFF, 0xD8]; // SOI
jpeg_data.extend_from_slice(&[0u8; 1000]); // 1000 bytes of data
jpeg_data.extend_from_slice(&[0xFF, 0xD9]); // EOI
let mut counter = 0;
let limit = 100; // Only allow 100 bytes
let result = DCTDecoder.decode(&jpeg_data, None, &mut counter, limit);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 100); // Should truncate at bomb limit
}
#[test]
fn test_dctdecode_color_transform_parsing() {
use std::sync::Arc;
// Test /ColorTransform = 1 (YCbCr)
let mut params = indexmap::IndexMap::new();
params.insert(Arc::from("/ColorTransform"), PdfObject::Integer(1));
let result = DCTDecoder::parse_color_transform(Some(&PdfObject::Dict(params.into())));
assert_eq!(result, Some(1));
// Test /ColorTransform = 0 (none)
let mut params = indexmap::IndexMap::new();
params.insert(Arc::from("/ColorTransform"), PdfObject::Integer(0));
let result = DCTDecoder::parse_color_transform(Some(&PdfObject::Dict(params.into())));
assert_eq!(result, Some(0));
// Test /ColorTransform = true (treated as 1)
let mut params = indexmap::IndexMap::new();
params.insert(Arc::from("/ColorTransform"), PdfObject::Bool(true));
let result = DCTDecoder::parse_color_transform(Some(&PdfObject::Dict(params.into())));
assert_eq!(result, Some(1));
// Test missing /ColorTransform (returns None)
let params = indexmap::IndexMap::new();
let result = DCTDecoder::parse_color_transform(Some(&PdfObject::Dict(params.into())));
assert_eq!(result, None);
// Test no params (returns None)
let result = DCTDecoder::parse_color_transform(None);
assert_eq!(result, None);
}
#[test]
fn test_ccittfax_passthrough_with_columns() {
// CCITT data with valid /Columns parameter should pass through unchanged
let ccitt_data = b"\x00\x01\x02\x03"; // Fake CCITT data
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), PdfObject::Integer(1728));
dict.insert("/K".into(), PdfObject::Integer(-1)); // Group 4
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = CCITTFaxDecoder.decode(
ccitt_data,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, ccitt_data);
assert_eq!(counter, ccitt_data.len() as u64);
}
#[test]
fn test_ccittfax_passthrough_missing_columns() {
// CCITT data with missing /Columns should use default (1728) and pass through
let ccitt_data = b"\x00\x01\x02\x03"; // Fake CCITT data
let dict = indexmap::IndexMap::new();
let params = Some(PdfObject::Dict(Box::new(dict))); // No /Columns
let mut counter = 0;
let result = CCITTFaxDecoder.decode(
ccitt_data,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, ccitt_data);
}
#[test]
fn test_ccittfax_passthrough_no_params() {
// CCITT data with no /DecodeParms should pass through unchanged
let ccitt_data = b"\x00\x01\x02\x03"; // Fake CCITT data
let mut counter = 0;
let result =
CCITTFaxDecoder.decode(ccitt_data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, ccitt_data);
}
#[test]
fn test_ccittfax_parse_params_with_all_fields() {
// Test parsing all CCITT parameters
let mut dict = indexmap::IndexMap::new();
dict.insert("/K".into(), PdfObject::Integer(-1)); // Group 4
dict.insert("/Columns".into(), PdfObject::Integer(2480));
dict.insert("/Rows".into(), PdfObject::Integer(3508));
dict.insert("/EncodedByteAlign".into(), PdfObject::Bool(true));
dict.insert("/EndOfLine".into(), PdfObject::Bool(false));
dict.insert("/BlackIs1".into(), PdfObject::Bool(true));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some());
let parsed = result.unwrap();
assert_eq!(parsed.k, -1);
assert_eq!(parsed.columns, 2480);
assert_eq!(parsed.rows, Some(3508));
assert_eq!(parsed.encoded_byte_align, true);
assert_eq!(parsed.end_of_line, false);
assert_eq!(parsed.black_is_1, true);
}
#[test]
fn test_ccittfax_parse_params_defaults() {
// Test that missing parameters use defaults
let dict = indexmap::IndexMap::new();
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some());
let parsed = result.unwrap();
assert_eq!(parsed.k, 0); // Default: Group 3 1D
assert_eq!(parsed.columns, CCITTFaxDecoder::DEFAULT_COLUMNS); // Default: 1728
assert_eq!(parsed.rows, None); // Optional
assert_eq!(parsed.encoded_byte_align, false); // Default: false
assert_eq!(parsed.end_of_line, false); // Default: false
assert_eq!(parsed.black_is_1, false); // Default: false
}
#[test]
fn test_ccittfax_parse_params_invalid_columns() {
// Test that invalid /Columns values use default
let test_cases = vec![
(PdfObject::Integer(0), "zero columns"), // Zero -> use default
(PdfObject::Integer(-100), "negative columns"), // Negative -> use default
(PdfObject::Bool(true), "bool columns"), // Wrong type -> use default
(PdfObject::Name("Test".into()), "name columns"), // Wrong type -> use default
];
for (value, desc) in test_cases {
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), value);
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some(), "{} should return Some", desc);
let parsed = result.unwrap();
assert_eq!(parsed.columns, CCITTFaxDecoder::DEFAULT_COLUMNS, "{}", desc);
}
}
#[test]
fn test_ccittfax_bomb_limit() {
// Test that bomb limit is enforced
let ccitt_data = vec![0u8; 1000];
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), PdfObject::Integer(1728));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let limit = 100; // Only allow 100 bytes
let result = CCITTFaxDecoder.decode(&ccitt_data, params.as_ref(), &mut counter, limit);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 100); // Should truncate at bomb limit
}
#[test]
fn test_ccittfax_roundtrip_empty() {
// Empty CCITT data
let ccitt_data = b"";
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), PdfObject::Integer(1728));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = CCITTFaxDecoder.decode(
ccitt_data,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 0);
}
/// Test FlateDecode bomb limit with minimal crafted input.
///
/// This test uses a minimal compressed payload that decodes to ~200 bytes
/// from only ~50 bytes of compressed data (4:1 compression ratio).
/// The decoder must stop at the bomb limit (50 bytes) WITHOUT materializing
/// the full 200-byte output in memory.
///
/// Per TH-01 and the bead requirement: "must trigger the STREAM_BOMB abort
/// WITHOUT building the multi-GB decoded output in memory. Use minimal crafted
/// inputs and assert the byte-budget limit fires early. Never pre-size a Vec
/// to the claimed or decompressed length inside a test."
///
/// CRITICAL: This test NEVER creates the 200-byte expanded form in memory.
/// The compressed payload is created inline (~50 bytes), decompression
/// is done incrementally, and we assert early truncation occurs.
#[test]
fn test_bomb_limit_flate() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
// Create a SMALL pattern (200 bytes) and compress it.
// We NEVER create a large buffer - just 200 bytes of repeated pattern.
// The compression ratio is ~4:1 (200 bytes -> ~50 bytes compressed).
let pattern = b"ABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJ";
// Compress the pattern - this is where the "bomb" property comes from
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(pattern).unwrap();
let compressed = encoder.finish().unwrap();
// Verify we're using a minimal crafted input (not a large buffer)
assert!(
compressed.len() < 100,
"Compressed payload should be minimal, got {} bytes",
compressed.len()
);
assert!(
pattern.len() < 250,
"Pattern should be small, got {} bytes",
pattern.len()
);
// Set bomb limit to 50 bytes (much less than the 200-byte decoded size)
// This forces early abort during decompression
let bomb_limit = 50;
let mut counter = 0;
let result = FlateDecoder.decode(&compressed, None, &mut counter, bomb_limit);
assert!(result.is_ok());
let output = result.unwrap();
// CRITICAL ASSERTION: The decoder MUST stop at or before the bomb limit
// It MUST NOT materialize the full 200-byte output
assert!(
output.len() <= bomb_limit as usize,
"STREAM_BOMB abort failed: decoded {} bytes, exceeding bomb limit of {} \
- decoder did not stop early!",
output.len(),
bomb_limit
);
// Verify the counter stayed within bounds
assert!(
counter <= bomb_limit as u64,
"Counter {} exceeds bomb limit {}",
counter,
bomb_limit
);
// Verify we actually hit the limit (got partial output, not full)
// If output.len() == 200, the bomb check failed completely
assert!(
output.len() < pattern.len(),
"Got full output ({} bytes) - bomb limit was not enforced",
output.len()
);
}
#[test]
fn test_passthrough_decoder() {
let input = b"raw bytes";
let mut counter = 0;
let decoder = PassthroughDecoder::new("DCTDecode");
let result = decoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, input);
}
#[test]
fn test_lzw_decode_simple_early_change() {
// Test with /EarlyChange = 1 (default, Adobe/TIFF variant)
let encoded = [
0x80, 0x1a, 0x0c, 0xa6, 0xc3, 0x61, 0xbc, 0x40, 0x77, 0x37, 0x9c, 0x8d, 0x86, 0x41,
0x0c, 0x04,
];
let expected = b"hello world!";
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_lzw_decode_with_params_early_change() {
// Test with explicit /EarlyChange = 1
let encoded = [
0x80, 0x1a, 0x0c, 0xa6, 0xc3, 0x61, 0xbc, 0x40, 0x77, 0x37, 0x9c, 0x8d, 0x86, 0x41,
0x0c, 0x04,
];
let expected = b"hello world!";
// Create /DecodeParms dict with /EarlyChange = 1
let mut dict = IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(1));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_lzw_decode_with_params_late_change() {
// Test with /EarlyChange = 0 (GIF variant)
// The late change decoder should still handle valid LZW data
let encoded = [
0x80, 0x1a, 0x0c, 0xa6, 0xc3, 0x61, 0xbc, 0x40, 0x77, 0x37, 0x9c, 0x8d, 0x86, 0x41,
0x0c, 0x04,
];
let expected = b"hello world!";
// Create /DecodeParms dict with /EarlyChange = 0
let mut dict = IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(0));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_lzw_decode_repeated_pattern() {
// Test with repeated pattern (compresses well)
let encoded = [
0x80, 0x10, 0x60, 0x50, 0x22, 0x14, 0x16, 0x0a, 0x43, 0x84, 0x42, 0x08, 0x90, 0xb8,
0x59, 0x16, 0x1d, 0x0e, 0x80, 0x80,
];
let expected = b"AAAAABBBBBCCCCCDDDDDEEEEE";
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_lzw_decode_empty() {
let encoded: [u8; 0] = [];
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 0);
}
#[test]
fn test_lzw_bomb_limit() {
// Test that bomb limit is enforced
let encoded = [
0x80, 0x1a, 0x0c, 0xa6, 0xc3, 0x61, 0xbc, 0x40, 0x77, 0x37, 0x9c, 0x8d, 0x86, 0x41,
0x0c, 0x04,
];
let mut counter = 0;
// Set a very low limit (5 bytes)
let result = LZWDecoder.decode(&encoded, None, &mut counter, 5);
assert!(result.is_ok());
let output = result.unwrap();
// Should have gotten partial output (5 bytes or less)
assert!(output.len() <= 5);
}
#[test]
fn test_lzw_decode_predictor() {
// Test LZW + PNG predictor 12
// This tests that the predictor is applied after LZW decode
let encoded = [
0x80, 0x05, 0x61, 0x09, 0xa1, 0xd4, 0xc0, 0x80, 0x60, 0x20, 0x20, 0x10, 0x08, 0x04,
0x02,
];
let mut counter = 0;
// Create /DecodeParms dict with predictor parameters
let mut dict = IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(12));
dict.insert("/Columns".into(), PdfObject::Integer(4));
dict.insert("/Colors".into(), PdfObject::Integer(1));
dict.insert("/BitsPerComponent".into(), PdfObject::Integer(8));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
// The output should be different with predictor applied
let output = result.unwrap();
assert!(!output.is_empty());
}
#[test]
fn test_lzw_decode_truncated_stream() {
// Truncated LZW stream should return partial bytes (INV-8)
// This fixture is the predictor fixture with 5 bytes removed
let truncated = [0x80, 0x10, 0x48, 0x44, 0x32, 0x24, 0x0a, 0x09, 0x06];
let mut counter = 0;
let result =
LZWDecoder.decode(&truncated, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
// Should return Ok with partial bytes, not Err
assert!(result.is_ok());
let decoded = result.unwrap();
// We should get some partial output, even if incomplete
// The exact amount depends on how much data could be decoded
// before hitting the truncation
assert!(!decoded.is_empty() || decoded.is_empty()); // Either way is fine - no panic
}
#[test]
fn test_lzw_decode_incremental() {
// Test incremental decoding with small chunks
// This verifies the decoder handles chunked input correctly
let encoded = [
0x80, 0x1a, 0x0c, 0xa6, 0xc3, 0x61, 0xbc, 0x40, 0x77, 0x37, 0x9c, 0x8d, 0x86, 0x41,
0x0c, 0x04,
];
let expected = b"hello world!";
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_lzw_fixture_simple_early_change() {
// Critical test: verify LZWDecode with /EarlyChange=1 decodes byte-perfectly
// against the reference fixture generated by the lzw crate.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_simple_early.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_simple_orig.bin", fixture_base))
.expect("original fixture should exist");
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok(), "LZWDecode should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_repeated_early_change() {
// Test with repeated pattern data (compresses well)
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_repeated_early.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_repeated_orig.bin", fixture_base))
.expect("original fixture should exist");
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok(), "LZWDecode should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_incremental_early_change() {
// Test with incremental data (no repeated patterns)
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_incremental_early.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_incremental_orig.bin", fixture_base))
.expect("original fixture should exist");
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok(), "LZWDecode should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_mixed_early_change() {
// Test with mixed data (some patterns, some variation)
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_mixed_early.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_mixed_orig.bin", fixture_base))
.expect("original fixture should exist");
let mut counter = 0;
let result = LZWDecoder.decode(&encoded, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok(), "LZWDecode should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_with_predictor() {
// Test LZW + PNG predictor 12
// This verifies the predictor is applied after LZW decode
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_predictor_encoded.bin", fixture_base))
.expect("fixture file should exist");
let _original = std::fs::read(format!("{}/lzw_predictor_orig.bin", fixture_base))
.expect("original fixture should exist");
let mut dict = indexmap::IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(12));
dict.insert("/Columns".into(), PdfObject::Integer(4));
dict.insert("/Colors".into(), PdfObject::Integer(1));
dict.insert("/BitsPerComponent".into(), PdfObject::Integer(8));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok(), "LZWDecode with predictor should succeed");
let output = result.unwrap();
// With predictor applied, output should differ from raw LZW decode
// The predictor should reconstruct the original pattern
assert!(!output.is_empty(), "predictor output should not be empty");
}
#[test]
fn test_lzw_fixture_simple_late_change() {
// Critical test: verify LZWDecode with /EarlyChange=0 (late change, GIF variant)
// decodes byte-perfectly against the reference fixture.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_simple_late.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_simple_orig.bin", fixture_base))
.expect("original fixture should exist");
// Create /DecodeParms dict with /EarlyChange = 0
let mut dict = indexmap::IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(0));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok(), "LZWDecode with late change should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_repeated_late_change() {
// Test late change with repeated pattern data
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_repeated_late.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_repeated_orig.bin", fixture_base))
.expect("original fixture should exist");
// Create /DecodeParms dict with /EarlyChange = 0
let mut dict = indexmap::IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(0));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok(), "LZWDecode with late change should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_incremental_late_change() {
// Test late change with incremental data (no repeated patterns)
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_incremental_late.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_incremental_orig.bin", fixture_base))
.expect("original fixture should exist");
// Create /DecodeParms dict with /EarlyChange = 0
let mut dict = indexmap::IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(0));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok(), "LZWDecode with late change should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_mixed_late_change() {
// Test late change with mixed data (some patterns, some variation)
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let encoded = std::fs::read(format!("{}/lzw_mixed_late.bin", fixture_base))
.expect("fixture file should exist");
let expected = std::fs::read(format!("{}/lzw_mixed_orig.bin", fixture_base))
.expect("original fixture should exist");
// Create /DecodeParms dict with /EarlyChange = 0
let mut dict = indexmap::IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(0));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = LZWDecoder.decode(
&encoded,
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok(), "LZWDecode with late change should succeed");
let output = result.unwrap();
assert_eq!(
output, expected,
"decoded output must match reference byte-perfectly"
);
}
#[test]
fn test_lzw_fixture_truncated() {
// Truncated LZW stream should return partial bytes (INV-8)
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_base = format!("{}/../../tests/fixtures", manifest_dir);
let truncated = std::fs::read(format!("{}/lzw_truncated.bin", fixture_base))
.expect("fixture file should exist");
let mut counter = 0;
let result =
LZWDecoder.decode(&truncated, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
// Should return Ok with partial bytes, not Err
assert!(
result.is_ok(),
"truncated stream should return Ok with partial bytes"
);
let decoded = result.unwrap();
// We should get some partial output, even if incomplete
// The exact amount depends on how much data could be decoded
// before hitting the truncation
assert!(!decoded.is_empty() || decoded.is_empty()); // Either way is fine - no panic
}
#[test]
fn test_runlength_decode_literal_copy() {
// Literal copy: input [3, 65, 66, 67, 68] (len=3 means copy 4 bytes)
// Per PDF spec: 0-127 means copy next (len+1) bytes literally
let input = vec![3, 65, 66, 67, 68]; // len=3, copy 4 bytes: A, B, C, D
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, vec![65, 66, 67, 68]);
}
#[test]
fn test_runlength_decode_repeat() {
// Repeat: input [254, 65] (len=254 means repeat 3 times)
// Per PDF spec: 129-255 means repeat next byte (257-len) times
// 257 - 254 = 3
let input = vec![254, 65]; // Repeat 'A' 3 times
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, vec![65, 65, 65]);
}
#[test]
fn test_runlength_decode_eod() {
// EOD: input [128, 65, 66, 67] stops at the 128 byte
// Per PDF spec: 128 is end-of-data marker
let input = vec![128, 65, 66, 67]; // 128 = EOD, subsequent bytes ignored
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, Vec::<u8>::new()); // Empty output - stopped at EOD
}
#[test]
fn test_runlength_decode_truncated_input() {
// Truncated input: [5, 65, 66] (expected copy of 6 bytes, only 2 available)
// Per INV-8: emit partial bytes decoded, no panic
let input = vec![5, 65, 66]; // len=5 means copy 6 bytes, but only 2 available
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// Should emit the partial bytes available
assert_eq!(output, vec![65, 66]);
}
#[test]
fn test_runlength_decode_truncated_repeat() {
// Truncated repeat: [200] (repeat 57 times, but no byte to repeat)
// 257 - 200 = 57, but no byte follows
let input = vec![200];
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
// No byte to repeat, so empty output
assert_eq!(output, Vec::<u8>::new());
}
#[test]
fn test_runlength_decode_empty_input() {
// Empty input should produce empty output
let input = vec![];
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 0);
}
#[test]
fn test_runlength_decode_max_repeat() {
// Maximum repeat count: len=129 -> repeat 128 times
// 257 - 129 = 128
let input = vec![129, 88]; // Repeat 'X' 128 times
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 128);
assert!(output.iter().all(|&b| b == 88));
}
#[test]
fn test_runlength_decode_min_repeat() {
// Minimum repeat count: len=255 -> repeat 2 times
// 257 - 255 = 2
let input = vec![255, 90]; // Repeat 'Z' 2 times
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, vec![90, 90]);
}
#[test]
fn test_runlength_decode_mixed_literal_and_repeat() {
// Mixed literal and repeat operations
// len=2 -> copy 3 bytes (A, B, C)
// len=250 -> repeat next byte 7 times (D x 7)
let input = vec![2, 65, 66, 67, 250, 68];
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, vec![65, 66, 67, 68, 68, 68, 68, 68, 68, 68]);
}
#[test]
fn test_runlength_decode_bomb_limit() {
// Test that bomb limit is enforced
// len=100 -> copy 101 bytes, but limit is 10
let input = vec![100, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74];
let mut counter = 0;
let limit = 10; // Only allow 10 bytes
let result = RunLengthDecoder.decode(&input, None, &mut counter, limit);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.len() <= 10); // Should truncate at bomb limit
}
#[test]
fn test_runlength_decode_zero_literal() {
// len=0 means copy 1 byte
let input = vec![0, 65];
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, vec![65]);
}
#[test]
fn test_runlength_decode_max_literal() {
// len=127 means copy 128 bytes
let mut input = vec![127];
input.extend_from_slice(&[65; 128]); // Copy 128 'A' bytes
let mut counter = 0;
let result =
RunLengthDecoder.decode(&input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 128);
assert!(output.iter().all(|&b| b == 65));
}
#[test]
fn test_runlength_decode_name() {
assert_eq!(RunLengthDecoder.name(), "RunLengthDecode");
}
#[test]
fn test_runlength_decode_normalize_filter_name() {
assert_eq!(normalize_filter_name("RL"), "RunLengthDecode");
assert_eq!(normalize_filter_name("RunLengthDecode"), "RunLengthDecode");
}
#[test]
fn test_ccitt_decode_passthrough() {
// CCITTFaxDecode should pass through raw bytes unchanged
let input = b"\x00\x01\x02\x03\x04\x05";
let mut counter = 0;
let result =
CCITTFaxDecoder.decode(input, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output, input);
}
#[test]
fn test_ccitt_parse_params_missing_columns() {
// /Columns is REQUIRED - but per INV-8, we use a default for error recovery
let mut dict = indexmap::IndexMap::new();
dict.insert("/K".into(), PdfObject::Integer(-1));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some()); // Should return default params instead of error
let parsed = result.unwrap();
assert_eq!(parsed.columns, CCITTFaxDecoder::DEFAULT_COLUMNS); // 1728 default
assert_eq!(parsed.k, -1); // Group 4
}
#[test]
fn test_ccitt_parse_params_group4() {
// Parse Group 4 params (K=-1)
let mut dict = indexmap::IndexMap::new();
dict.insert("/K".into(), PdfObject::Integer(-1));
dict.insert("/Columns".into(), PdfObject::Integer(2480));
dict.insert("/Rows".into(), PdfObject::Integer(3508));
dict.insert("/BlackIs1".into(), PdfObject::Bool(true));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some());
let parsed = result.unwrap();
assert_eq!(parsed.k, -1);
assert_eq!(parsed.columns, 2480);
assert_eq!(parsed.rows, Some(3508));
assert!(parsed.black_is_1);
}
#[test]
fn test_ccitt_parse_params_defaults() {
// Parse with only required /Columns param
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), PdfObject::Integer(1728));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some());
let parsed = result.unwrap();
assert_eq!(parsed.k, 0); // Default: Group 3 1D
assert_eq!(parsed.columns, 1728);
assert_eq!(parsed.rows, None);
assert!(!parsed.encoded_byte_align);
assert!(!parsed.end_of_line);
assert!(!parsed.black_is_1);
}
#[test]
fn test_ccitt_decode_with_invalid_columns() {
// /Columns = 0 should return InvalidParams error
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), PdfObject::Integer(0));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
let result = CCITTFaxDecoder.decode(
b"test",
params.as_ref(),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_err());
}
#[test]
fn test_ccitt_decode_bomb_limit() {
// CCITTFaxDecode should respect bomb limits
let input = vec![0u8; 1000];
let mut counter = 0;
let mut dict = indexmap::IndexMap::new();
dict.insert("/Columns".into(), PdfObject::Integer(100));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder.decode(&input, params.as_ref(), &mut counter, 500);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.len(), 500); // Truncated to bomb limit
}
#[test]
fn test_ccitt_parse_params_group3_2d() {
// Parse Group 3 2D params (K>0)
let mut dict = indexmap::IndexMap::new();
dict.insert("/K".into(), PdfObject::Integer(5)); // Group 3 2D with K=5
dict.insert("/Columns".into(), PdfObject::Integer(1728));
dict.insert("/EndOfLine".into(), PdfObject::Bool(true));
dict.insert("/EncodedByteAlign".into(), PdfObject::Bool(true));
let params = Some(PdfObject::Dict(Box::new(dict)));
let result = CCITTFaxDecoder::parse_params(params.as_ref());
assert!(result.is_some());
let parsed = result.unwrap();
assert_eq!(parsed.k, 5);
assert!(parsed.end_of_line);
assert!(parsed.encoded_byte_align);
}
}
/// Extraction options controlling resource limits and behavior.
///
/// # Example
///
/// ```
/// use pdftract_core::parser::stream::ExtractionOptions;
/// use secrecy::SecretString;
///
/// let mut opts = ExtractionOptions::default();
/// opts.password = Some(SecretString::new("my_secret_password".to_string().into()));
///
/// // Debug output never leaks the password value
/// let debug_str = format!("{:?}", opts);
/// assert!(!debug_str.contains("my_secret_password"));
/// assert!(debug_str.contains("<REDACTED>"));
/// ```
#[derive(Clone)]
pub struct ExtractionOptions {
/// Maximum decompressed bytes per document (default: 512 MiB).
pub max_decompress_bytes: u64,
/// PDF password for encrypted documents.
///
/// This is wrapped in SecretString to prevent accidental leakage via Debug printing.
/// The password is only exposed when explicitly needed for PDF decryption.
pub password: Option<SecretString>,
}
impl Default for ExtractionOptions {
fn default() -> Self {
Self {
max_decompress_bytes: DEFAULT_MAX_DECOMPRESS_BYTES,
password: None,
}
}
}
impl std::fmt::Debug for ExtractionOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExtractionOptions")
.field("max_decompress_bytes", &self.max_decompress_bytes)
.field("password", &self.password.as_ref().map(|_| "<REDACTED>"))
.finish()
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for ExtractionOptions {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("ExtractionOptions", 2)?;
state.serialize_field("max_decompress_bytes", &self.max_decompress_bytes)?;
state.serialize_field("password", &self.password.as_ref().map(|_| "<REDACTED>"))?;
state.end()
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ExtractionOptions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use secrecy::SecretString;
use serde::de::{self, MapAccess, SeqAccess, Visitor};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(field_identifier)]
enum Field {
MaxDecompressBytes,
Password,
}
const FIELDS: &[&str] = &["max_decompress_bytes", "password"];
struct ExtractionOptionsVisitor;
impl<'de> Visitor<'de> for ExtractionOptionsVisitor {
type Value = ExtractionOptions;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("struct ExtractionOptions")
}
fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut max_decompress_bytes = None;
let mut password = None;
while let Some(key) = map.next_key()? {
match key {
Field::MaxDecompressBytes => {
if max_decompress_bytes.is_some() {
return Err(de::Error::duplicate_field("max_decompress_bytes"));
}
max_decompress_bytes = Some(map.next_value()?);
}
Field::Password => {
if password.is_some() {
return Err(de::Error::duplicate_field("password"));
}
let pwd: Option<String> = map.next_value()?;
password = pwd.map(|p| SecretString::new(p.into()));
}
}
}
let max_decompress_bytes = max_decompress_bytes
.ok_or_else(|| de::Error::missing_field("max_decompress_bytes"))?;
Ok(ExtractionOptions {
max_decompress_bytes,
password,
})
}
}
deserializer.deserialize_struct("ExtractionOptions", FIELDS, ExtractionOptionsVisitor)
}
}
/// A source for reading PDF file data.
///
/// This trait allows the parser to read from different sources (files, memory, etc.).
pub trait PdfSource {
/// Read raw bytes from the source at the given offset.
fn read_at(&self, offset: u64, len: usize) -> std::io::Result<Vec<u8>>;
/// Get the total length of the source.
fn len(&self) -> std::io::Result<u64>;
/// Check if the source is empty.
fn is_empty(&self) -> std::io::Result<bool> {
Ok(self.len()? == 0)
}
}
/// A memory-backed PDF source.
#[derive(Debug, Clone)]
pub struct MemorySource {
data: Vec<u8>,
}
impl MemorySource {
pub fn new(data: Vec<u8>) -> Self {
Self { data }
}
pub fn from_slice(data: &[u8]) -> Self {
Self {
data: data.to_vec(),
}
}
}
impl PdfSource for MemorySource {
fn read_at(&self, offset: u64, len: usize) -> std::io::Result<Vec<u8>> {
let start = offset as usize;
let end = (start + len).min(self.data.len());
if start >= self.data.len() {
return Ok(Vec::new());
}
Ok(self.data[start..end].to_vec())
}
fn len(&self) -> std::io::Result<u64> {
Ok(self.data.len() as u64)
}
}
/// A file-backed PDF source using memory-mapped I/O.
///
/// This implementation uses `memmap2` to map the file into memory,
/// allowing the OS to manage paging via the page cache. This avoids
/// allocating anonymous RSS for the entire file and enables on-demand
/// loading of only the portions of the file that are actually accessed.
pub struct FileSource {
mmap: memmap2::Mmap,
}
impl FileSource {
/// Open a PDF file using memory-mapped I/O.
///
/// # Errors
///
/// Returns an error if the file cannot be opened or memory-mapped.
/// This includes:
/// - File not found
/// - Permission denied
/// - File too large to address (near address space limit)
/// - Kernel refuses mmap (e.g., certain FUSE mounts)
pub fn open<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let file = std::fs::File::open(&path)?;
let mmap = unsafe { memmap2::Mmap::map(&file)? };
Ok(Self { mmap })
}
}
impl PdfSource for FileSource {
fn read_at(&self, offset: u64, len: usize) -> std::io::Result<Vec<u8>> {
let start = offset as usize;
let end = (start + len).min(self.mmap.len());
if start >= self.mmap.len() {
return Ok(Vec::new());
}
// Slice the mmap region - this is a zero-copy operation
// that returns bytes directly from the memory-mapped region.
Ok(self.mmap[start..end].to_vec())
}
fn len(&self) -> std::io::Result<u64> {
Ok(self.mmap.len() as u64)
}
}
/// Decode result containing both bytes and diagnostics.
#[derive(Debug, Clone)]
pub struct DecodeResult {
/// Decoded bytes (may be partial if bomb limit hit)
pub bytes: Vec<u8>,
/// Diagnostics emitted during decoding
pub diagnostics: Vec<Diagnostic>,
}
impl DecodeResult {
/// Create a new decode result with no diagnostics.
pub fn ok(bytes: Vec<u8>) -> Self {
Self {
bytes,
diagnostics: Vec::new(),
}
}
/// Create a decode result with a diagnostic.
pub fn with_diagnostic(bytes: Vec<u8>, diagnostic: Diagnostic) -> Self {
Self {
bytes,
diagnostics: vec![diagnostic],
}
}
}
/// Scan for the `endstream` keyword starting at the given offset.
///
/// This is a fallback for streams where /Length is indirect or missing.
/// The scan reads chunks and searches for the "endstream" keyword,
/// which must appear at a token boundary (after optional whitespace).
///
/// Returns the offset of the byte immediately after "endstream",
/// or None if the keyword is not found within a reasonable limit.
fn scan_for_endstream(source: &dyn PdfSource, start_offset: u64) -> Option<u64> {
const ENDSTREAM: &[u8] = b"endstream";
const SCAN_LIMIT: u64 = 16 * 1024 * 1024; // 16 MB max scan to avoid DoS
let source_len = source.len().ok()?;
let search_end = (start_offset + SCAN_LIMIT).min(source_len);
// Read in chunks to avoid loading huge amounts of data
const CHUNK_SIZE: usize = 64 * 1024; // 64 KB
let mut offset = start_offset;
while offset < search_end {
let to_read = CHUNK_SIZE.min((search_end - offset) as usize);
let chunk = source.read_at(offset, to_read).ok()?;
// Search for "endstream" in this chunk
if let Some(pos) = chunk.windows(ENDSTREAM.len()).position(|w| w == ENDSTREAM) {
// Found it! Verify it's at a token boundary (preceded by whitespace or start)
let abs_pos = offset + pos as u64;
// Check if preceded by whitespace or at chunk start
let preceded_by_whitespace = if pos > 0 {
chunk[pos - 1].is_ascii_whitespace()
} else if abs_pos > start_offset {
// Need to check previous chunk - for simplicity, accept it
true
} else {
true // At the very start of search area
};
if preceded_by_whitespace {
// Return the position after "endstream"
return Some(abs_pos + ENDSTREAM.len() as u64);
}
}
offset += to_read as u64;
// Slide back by ENDSTREAM.len() - 1 to catch matches spanning chunk boundaries
if offset > 0 {
offset = offset.saturating_sub((ENDSTREAM.len() - 1) as u64);
}
}
None
}
/// Decode a PDF stream by applying its filter pipeline.
///
/// # Parameters
/// - `stream`: The PDF stream to decode
/// - `source`: The PDF source to read raw bytes from
/// - `opts`: Extraction options (bomb limits, etc.)
/// - `doc_decompress_counter`: Cumulative decompressed bytes for the document
///
/// # Returns
/// The decoded stream bytes, or an empty Vec if decoding failed completely.
pub fn decode_stream(
stream: &PdfStream,
source: &dyn PdfSource,
opts: &ExtractionOptions,
doc_decompress_counter: &mut u64,
) -> Vec<u8> {
decode_stream_impl(stream, source, opts, doc_decompress_counter).bytes
}
/// Internal implementation that returns both bytes and diagnostics.
fn decode_stream_impl(
stream: &PdfStream,
source: &dyn PdfSource,
opts: &ExtractionOptions,
doc_decompress_counter: &mut u64,
) -> DecodeResult {
// Step 1: Read raw bytes from source
let raw_bytes = if let Some(len) = stream.len_hint.or_else(|| stream.length()) {
match source.read_at(stream.offset, len as usize) {
Ok(bytes) if !bytes.is_empty() => bytes,
_ => Vec::new(),
}
} else {
// No direct /Length - scan for endstream keyword
match scan_for_endstream(source, stream.offset) {
Some(end_offset) => {
let len = (end_offset - stream.offset) as usize;
source.read_at(stream.offset, len).unwrap_or_default()
}
None => Vec::new(),
}
};
// Step 2: Get filter list (empty = raw stream, no filtering)
let filters = match stream.filter() {
Some(f) => f,
None => {
// No filter - enforce bomb limit and return raw bytes
let len = raw_bytes.len() as u64;
if *doc_decompress_counter + len > opts.max_decompress_bytes {
// Bomb limit exceeded - truncate
let remaining = (opts.max_decompress_bytes - *doc_decompress_counter) as usize;
*doc_decompress_counter += remaining as u64;
let truncated = raw_bytes[..remaining.min(raw_bytes.len())].to_vec();
return DecodeResult::with_diagnostic(
truncated,
Diagnostic::with_dynamic_no_offset(
DiagCode::StreamBomb,
format!(
"Decompression bomb limit exceeded: {} bytes",
opts.max_decompress_bytes
),
),
);
}
*doc_decompress_counter += len;
return DecodeResult::ok(raw_bytes);
}
};
// Safety check: limit filter pipeline depth
if filters.len() > MAX_FILTERS {
// Too many filters - return raw bytes to avoid DoS
return DecodeResult::ok(raw_bytes);
}
// Step 3: Get decode params (aligned with filters, may be shorter)
let decode_params = stream.decode_params().unwrap_or_default();
// Validate /Filter and /DecodeParms array lengths
// Per PDF spec, /DecodeParms can be shorter than /Filter (missing params are treated as null).
// But /DecodeParms cannot be longer than /Filter.
if decode_params.len() > filters.len() {
return DecodeResult::with_diagnostic(
raw_bytes,
Diagnostic::with_dynamic_no_offset(
DiagCode::StreamInvalidParams,
format!(
"/DecodeParms array length ({}) > /Filter array length ({})",
decode_params.len(),
filters.len()
),
),
);
}
// Step 4: Apply filters in order
let mut current_bytes = raw_bytes;
let mut diagnostics = Vec::new();
let mut bomb_limit_hit = false;
for (i, filter_name) in filters.iter().enumerate() {
let normalized_name = normalize_filter_name(filter_name);
let params = if i < decode_params.len() {
Some(&decode_params[i])
} else {
None
};
// Check for CCITTFaxDecode with missing /Columns (emit STREAM_INVALID_CCITT)
if normalized_name == "CCITTFaxDecode" {
if let Some(PdfObject::Dict(dict)) = params {
if !dict.contains_key("/Columns") {
diagnostics.push(Diagnostic::with_static_no_offset(
DiagCode::StreamInvalidCcitt,
"CCITTFaxDecode stream missing required /Columns parameter; using default width 1728",
));
}
} else if params.is_none() {
diagnostics.push(Diagnostic::with_static_no_offset(
DiagCode::StreamInvalidCcitt,
"CCITTFaxDecode stream missing /DecodeParms; using default parameters",
));
}
// Emit OCR_CCITT_UNSUPPORTED if full-render and libtiff are both unavailable
// cfg!(feature = "full-render") checks if pdfium-render is available
// We check if we have libtiff support by seeing if the image crate is available
let has_full_render = cfg!(feature = "full-render");
let has_libtiff = cfg!(feature = "image"); // image crate with tiff feature
if !has_full_render && !has_libtiff {
diagnostics.push(Diagnostic::with_static_no_offset(
DiagCode::OcrCcittUnsupported,
"CCITT fax compression detected but neither full-render nor libtiff is available; OCR will skip CCITT images",
));
}
}
match get_decoder(&normalized_name) {
Some(decoder) => {
let counter_before = *doc_decompress_counter;
match decoder.decode(
&current_bytes,
params,
doc_decompress_counter,
opts.max_decompress_bytes,
) {
Ok(decoded) => {
// Check if we hit the bomb limit during this filter
if *doc_decompress_counter >= opts.max_decompress_bytes
&& counter_before < opts.max_decompress_bytes
{
bomb_limit_hit = true;
}
current_bytes = decoded;
}
Err(FilterError::EncryptionUnsupported) => {
// Crypt filter with custom /Name - emit ENCRYPTION_UNSUPPORTED
// and return empty bytes (stream is undecryptable)
diagnostics.push(Diagnostic::with_static_no_offset(
DiagCode::EncryptionUnsupported,
"Crypt filter with custom /Name parameter is not supported",
));
return DecodeResult {
bytes: Vec::new(),
diagnostics,
};
}
Err(e) => {
// Hard error - return raw bytes for this filter
break;
}
}
}
None => {
// Unknown filter - emit diagnostic and return current bytes (partial decode) per INV-8
diagnostics.push(Diagnostic::with_dynamic_no_offset(
DiagCode::StreamUnknownFilter,
format!("Unknown filter: {}, returning partial decode", filter_name),
));
break;
}
}
}
if bomb_limit_hit {
diagnostics.push(Diagnostic::with_dynamic_no_offset(
DiagCode::StreamBomb,
format!(
"Decompression bomb limit exceeded: {} bytes",
opts.max_decompress_bytes
),
));
}
DecodeResult {
bytes: current_bytes,
diagnostics,
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use indexmap::IndexMap;
use secrecy::ExposeSecret;
#[test]
fn test_extraction_options_default() {
let opts = ExtractionOptions::default();
assert_eq!(opts.max_decompress_bytes, DEFAULT_MAX_DECOMPRESS_BYTES);
}
#[test]
fn test_memory_source() {
let data = b"Hello, world!".to_vec();
let source = MemorySource::new(data.clone());
assert_eq!(source.len().unwrap(), 13);
assert_eq!(source.read_at(0, 5).unwrap(), b"Hello");
assert_eq!(source.read_at(7, 5).unwrap(), b"world");
}
#[test]
fn test_pdf_stream_filter_parsing() {
// Single filter (name)
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("FlateDecode".into()));
dict.insert("/Length".into(), PdfObject::Integer(100));
let stream = PdfStream::new(dict, 1000, Some(100));
assert_eq!(stream.filter(), Some(vec!["FlateDecode".to_string()]));
assert_eq!(stream.length(), Some(100));
// Multiple filters (array)
let mut dict2 = IndexMap::new();
dict2.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![
PdfObject::Name("ASCII85Decode".into()),
PdfObject::Name("FlateDecode".into()),
])),
);
dict2.insert("/Length".into(), PdfObject::Integer(200));
let stream2 = PdfStream::new(dict2, 2000, Some(200));
assert_eq!(
stream2.filter(),
Some(vec!["ASCII85Decode".to_string(), "FlateDecode".to_string(),])
);
}
#[test]
fn test_decode_stream_no_filter() {
let data = b"raw stream data";
let source = MemorySource::new(data.to_vec());
let mut dict = IndexMap::new();
dict.insert("/Length".into(), PdfObject::Integer(data.len() as i64));
let stream = PdfStream::new(dict, 0, Some(data.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, data);
assert_eq!(counter, data.len() as u64);
}
#[test]
fn test_decode_stream_single_filter() {
// "hello" compressed with flate
let compressed = b"\x78\x9c\xcbH\xcd\xc9\xc9\x07\x00\x06,\x02\x15";
let source = MemorySource::new(compressed.to_vec());
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("FlateDecode".into()));
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(compressed.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, b"hello");
}
#[test]
fn test_decode_stream_filter_array() {
// This is the critical test from the plan:
// Verify that filters are applied in order (left to right).
//
// For this test, we use a known-good fixture:
// Original: "Hello" (5 bytes)
// After Flate compression: 13 bytes
// After ASCII85 encoding of those 13 bytes: ~17 bytes
//
// To create this fixture properly, we'll work backwards:
// Start with a small payload that compresses well, encode it,
// then verify the round-trip works.
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
// Create a highly compressible payload (repeated pattern)
let original = b"AAAAAAAABBBBBBBB"; // 16 bytes
// Compress with Flate
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(original).unwrap();
let compressed = encoder.finish().unwrap();
// Verify compression worked (should be smaller)
assert!(
compressed.len() < original.len(),
"Compressed size {} should be less than original {}",
compressed.len(),
original.len()
);
// Now decode the compressed bytes directly with Flate
let mut counter = 0;
let flate_decoded = FlateDecoder
.decode(
&compressed,
None,
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
)
.unwrap();
assert_eq!(flate_decoded, original);
// Now test the filter array: [/FlateDecode] should work the same
let source = MemorySource::new(compressed.clone());
let mut dict = IndexMap::new();
dict.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![PdfObject::Name("FlateDecode".into())])),
);
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(compressed.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// Should have applied FlateDecode
assert_eq!(decoded, original);
// For the full ASCII85 + Flate pipeline test, we need a pre-encoded fixture.
// This is complex to generate correctly in a test, so we verify the
// individual components work and that the filter array ordering is correct.
// The critical property is: filters are applied left-to-right.
}
#[test]
fn test_decode_stream_with_abbreviation() {
// Test /Fl abbreviation -> FlateDecode
let compressed = b"\x78\x9c\xcbH\xcd\xc9\xc9\x07\x00\x06,\x02\x15";
let source = MemorySource::new(compressed.to_vec());
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("Fl".into())); // Abbreviated
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(compressed.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, b"hello");
}
#[test]
fn test_decode_stream_unknown_filter() {
// Unknown filter should return raw bytes (passthrough)
let data = b"raw data";
let source = MemorySource::new(data.to_vec());
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("CustomDecode".into()));
dict.insert("/Length".into(), PdfObject::Integer(data.len() as i64));
let stream = PdfStream::new(dict, 0, Some(data.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// Should return raw bytes since filter is unknown
assert_eq!(decoded, data);
}
#[test]
fn test_bomb_limit_enforcement() {
// Test that bomb limit is enforced at document level
let data = b"hello world!";
let source = MemorySource::new(data.to_vec());
let mut dict = IndexMap::new();
dict.insert("/Length".into(), PdfObject::Integer(data.len() as i64));
let stream = PdfStream::new(dict, 0, Some(data.len() as u64));
let opts = ExtractionOptions {
max_decompress_bytes: 5, // Very low limit
password: None,
};
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// Should have truncated to 5 bytes
assert_eq!(decoded.len(), 5);
}
/// Test FlateDecode bomb: small compressed input expanding beyond limit.
///
/// This test uses a pre-compressed fixture that would expand to >500 KB
/// if fully decompressed. The decoder MUST stop at the bomb limit (100 KB)
/// WITHOUT materializing the full 500 KB output in memory.
///
/// Per the bead requirement: "Use minimal crafted inputs and assert the
/// byte-budget limit fires early. Never pre-size a Vec to the claimed or
/// decompressed length inside a test."
///
/// This test uses a fixture file to avoid creating large buffers in the test.
/// The fixture file tests/fixtures/malformed/compression-bomb.bin contains
/// a zlib-compressed payload that decodes to ~500 KB using only ~2 KB of
/// compressed data.
///
/// If the fixture doesn't exist, the test uses a minimal inline payload that
/// decodes to a smaller but still > bomb_limit amount.
#[test]
fn test_flate_decode_bomb_limit() {
use std::path::Path;
// Minimal inline bomb for when fixture is not available.
// This is a zlib-compressed payload that decodes to ~1500 bytes
// from only ~50 bytes of compressed data.
//
// The payload uses deflate's RLE encoding to represent repeated
// patterns efficiently. We NEVER create the 1500-byte expanded
// form in the test - only the compressed ~50-byte payload.
//
// Format: zlib header + deflate block with RLE encoding
// The pattern "AB" repeated 750 times = 1500 bytes
let inline_bomb: &[u8] = &[
0x78, 0x9c, // zlib header (default compression, window size 32768)
// Deflate block: compressed, final
// Encoding "AB" repeated 750 times using RLE
0x73, 0x74, 0x72, 0x65, 0x61,
0x6d, // "stream" marker (not actual deflate)
// For a valid test, we use a pre-compressed fixture
];
// Try to load the fixture file
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_path =
Path::new(manifest_dir).join("../../tests/fixtures/malformed/compression-bomb.bin");
let compressed = if fixture_path.exists() {
std::fs::read(&fixture_path).unwrap_or_else(|_| inline_bomb.to_vec())
} else {
// Fall back to inline minimal payload
// Use flate2 to compress a small pattern without creating large buffer
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
// Create a small pattern (200 bytes) and compress it
// This is NOT a large buffer - just 200 bytes
let pattern = b"ABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCD";
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(pattern).unwrap();
encoder.finish().unwrap()
};
let source = MemorySource::new(compressed.clone());
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("FlateDecode".into()));
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(compressed.len() as u64));
// Set bomb limit to 100 bytes (much smaller than decompressed size)
// This forces early abort during decompression
let bomb_limit = 100;
let opts = ExtractionOptions {
max_decompress_bytes: bomb_limit,
password: None,
};
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// CRITICAL: The decoder must stop AT the bomb limit, not exceed it
assert!(
decoded.len() <= bomb_limit as usize,
"Decoded {} bytes, exceeding bomb limit of {}",
decoded.len(),
bomb_limit
);
// The counter must also stay within bounds
assert!(
counter <= bomb_limit as u64,
"Counter {} exceeds bomb limit {}",
counter,
bomb_limit
);
// Verify we actually hit the limit (got partial output, not full)
// If we got the full decompressed payload, the bomb check failed
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_path =
Path::new(manifest_dir).join("../../tests/fixtures/malformed/compression-bomb.bin");
if !fixture_path.exists() {
// For inline test, verify truncation occurred
// The pattern is 200 bytes, bomb limit is 100, so we should get <= 100
assert!(
decoded.len() <= 100,
"Should have truncated at bomb limit, got {} bytes",
decoded.len()
);
}
}
/// Test document-level decompression counter across multiple streams.
///
/// This test verifies that the document-level counter accumulates
/// correctly across multiple stream decodes and enforces the bomb
/// limit at the document level, not per-stream.
///
/// Per the bead requirement: "Use minimal crafted inputs and assert the
/// byte-budget limit fires early. Never pre-size a Vec to the claimed or
/// decompressed length inside a test."
#[test]
fn test_document_level_bomb_limit() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
// Create a SMALL compressed payload (200 bytes of pattern, ~50 bytes compressed)
// We NEVER create a 500KB buffer - only the small 200-byte pattern
let pattern = b"ABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJ";
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(pattern).unwrap();
let compressed = encoder.finish().unwrap();
let source = MemorySource::new(compressed.clone());
// Set bomb limit to 150 bytes (less than 2 * pattern length)
// Each stream decodes to 200 bytes, so two streams would be 400 bytes
// but we limit to 150 bytes total
let bomb_limit = 150;
let opts = ExtractionOptions {
max_decompress_bytes: bomb_limit,
password: None,
};
let mut counter = 0;
// Decode first stream (200 bytes when decompressed)
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("FlateDecode".into()));
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream1 = PdfStream::new(dict, 0, Some(compressed.len() as u64));
let decoded1 = decode_stream(&stream1, &source, &opts, &mut counter);
// First stream should be truncated at bomb limit
assert!(
decoded1.len() <= bomb_limit as usize,
"First stream decoded {} bytes, exceeding bomb limit of {}",
decoded1.len(),
bomb_limit
);
let bytes_used = counter;
// Decode second stream (would be another 200 bytes, but bomb limit is 150 total)
let mut dict2 = IndexMap::new();
dict2.insert("/Filter".into(), PdfObject::Name("FlateDecode".into()));
dict2.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream2 = PdfStream::new(dict2, 0, Some(compressed.len() as u64));
let decoded2 = decode_stream(&stream2, &source, &opts, &mut counter);
// Second stream should be empty or very small since we already hit the limit
assert!(
decoded2.len() <= (bomb_limit as usize - bytes_used as usize),
"Second stream decoded {} bytes, exceeding remaining budget of {}",
decoded2.len(),
bomb_limit as usize - bytes_used as usize
);
// Total should not exceed bomb limit
assert!(
counter <= bomb_limit as u64,
"Total counter {} exceeds bomb limit {}",
counter,
bomb_limit
);
}
/// TH-01 test: Decompression bomb abort fires before materialization.
///
/// Per the plan: "TH-01: Decompression bomb: 10 KB FlateDecode stream
/// expands to multi-GB. Mitigation: ExtractionOptions.max_decompress_bytes
/// (default 512 MB); Phase 1.5 enforces the cap; abort emits STREAM_BOMB
/// diagnostic."
///
/// This test uses the compression-bomb.bin fixture which decodes to ~500 KB
/// from only ~509 bytes of compressed data (982:1 compression ratio).
///
/// CRITICAL: The test verifies that the decoder aborts BEFORE materializing
/// the full 500 KB output. With a bomb limit of 100 KB, the decoder MUST
/// stop early and return partial bytes.
///
/// Per the bead requirement: "Use minimal crafted inputs and assert the
/// byte-budget limit fires early. Never pre-size a Vec to the claimed or
/// decompressed length inside a test."
#[test]
fn test_th01_decompression_bomb_abort() {
use std::path::Path;
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_path =
Path::new(manifest_dir).join("../../tests/fixtures/malformed/compression-bomb.bin");
// Skip test if fixture doesn't exist (e.g., during cargo publish)
if !fixture_path.exists() {
return;
}
// Load the compressed bomb payload
// This is ONLY ~509 bytes - we never load the 500 KB expanded form
let compressed = std::fs::read(&fixture_path).expect("fixture file should be readable");
// Verify the fixture is highly compressed (the bomb property)
assert!(
compressed.len() < 2000,
"Fixture should be highly compressed, got {} bytes",
compressed.len()
);
let source = MemorySource::new(compressed.clone());
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("FlateDecode".into()));
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(compressed.len() as u64));
// Set bomb limit to 100 KB (much less than the 500 KB decoded size)
// This forces early abort during decompression
let bomb_limit = 100 * 1024;
let opts = ExtractionOptions {
max_decompress_bytes: bomb_limit,
password: None,
};
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// CRITICAL ASSERTION: The decoder MUST stop at or before the bomb limit
// It MUST NOT materialize the full 500 KB output
assert!(
decoded.len() <= bomb_limit as usize,
"TH-01 FAILED: Decoder materialized {} bytes, exceeding bomb limit of {} \
- STREAM_BOMB abort did not fire early enough!",
decoded.len(),
bomb_limit
);
// Verify the counter stayed within bounds
assert!(
counter <= bomb_limit,
"TH-01 FAILED: Counter {} exceeded bomb limit {}",
counter,
bomb_limit
);
// Verify we got partial output (truncated), not the full 500 KB
// If decoded.len() == 500000, the bomb check failed completely
assert!(
decoded.len() < 400000,
"TH-01 FAILED: Got full output ({} bytes) - bomb limit was not enforced",
decoded.len()
);
}
/// Critical test: [/ASCII85Decode /FlateDecode] applies filters in correct order.
///
/// This test verifies that filters are applied left-to-right (ASCII85Decode first,
/// then FlateDecode). The fixture is created by:
/// 1. Starting with original data
/// 2. Compressing with Flate
/// 3. Encoding the compressed result with ASCII85
///
/// Decoding must apply filters in order: ASCII85Decode first, then FlateDecode.
#[test]
fn test_decode_stream_ascii85_then_flate() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
// Original payload (exactly 4 bytes for clean ASCII85 encoding)
let original = b"Test";
// Step 1: Compress with Flate
let mut flate_encoder = ZlibEncoder::new(Vec::new(), Compression::default());
flate_encoder.write_all(original).unwrap();
let _compressed = flate_encoder.finish().unwrap();
// Step 2: Manually create ASCII85 encoded data for the compressed bytes
// For simplicity in this test, we'll verify the pipeline works by:
// 1. Testing ASCII85 decoder with known-good data
// 2. Testing Flate decoder with known-good data
// 3. Testing filter array ordering
// Test 1: ASCII85 decoder works correctly
// "Hell" (4 bytes) encodes to "87cUR" (5 chars) in ASCII85
let ascii85_hell = b"<~87cUR~>";
let mut counter = 0;
let decoded = ASCII85Decoder
.decode(
ascii85_hell,
None,
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
)
.unwrap();
assert_eq!(decoded, b"Hell");
// Test 2: Filter array with ASCII85 works
let source = MemorySource::new(ascii85_hell.to_vec());
let mut dict = IndexMap::new();
dict.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![PdfObject::Name("ASCII85Decode".into())])),
);
dict.insert(
"/Length".into(),
PdfObject::Integer(ascii85_hell.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(ascii85_hell.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, b"Hell");
// Test 3: Filter array with Flate works
let compressed_test = b"\x78\x9c\xcbH\xcd\xc9\xc9\x07\x00\x06,\x02\x15"; // "hello"
let source2 = MemorySource::new(compressed_test.to_vec());
let mut dict2 = IndexMap::new();
dict2.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![PdfObject::Name("FlateDecode".into())])),
);
dict2.insert(
"/Length".into(),
PdfObject::Integer(compressed_test.len() as i64),
);
let stream2 = PdfStream::new(dict2, 0, Some(compressed_test.len() as u64));
let mut counter2 = 0;
let decoded2 = decode_stream(&stream2, &source2, &opts, &mut counter2);
assert_eq!(decoded2, b"hello");
// The critical property verified: filters are applied left-to-right.
// Each filter in the array is dispatched correctly and processes the data.
// A full ASCII85+Flate pipeline test would require a pre-encoded fixture file;
// the individual filter tests verify correctness, and the filter array test
// verifies ordering and dispatch logic.
}
/// Test that mismatched /Filter and /DecodeParms array lengths emit diagnostic.
///
/// Per the plan: "Mismatched lengths: apply defaults, log diagnostic."
#[test]
fn test_decode_stream_filter_params_mismatch() {
// Single filter but two decode params (invalid)
let data = b"hello";
let source = MemorySource::new(data.to_vec());
let mut dict = IndexMap::new();
dict.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![PdfObject::Name("FlateDecode".into())])),
);
// Two params for one filter (mismatch)
dict.insert(
"/DecodeParms".into(),
PdfObject::Array(Box::new(vec![
PdfObject::Dict(Box::new(IndexMap::new())),
PdfObject::Dict(Box::new(IndexMap::new())),
])),
);
dict.insert("/Length".into(), PdfObject::Integer(data.len() as i64));
let stream = PdfStream::new(dict, 0, Some(data.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// Should have returned raw bytes due to mismatch
assert_eq!(decoded, data);
}
/// Test that filter abbreviations in arrays are normalized.
/// Test that filter abbreviations in arrays are normalized.
#[test]
fn test_decode_stream_abbreviation_array() {
// Test /A85 (abbreviation for ASCII85Decode) in array
let encoded = b"<~87cUR~>"; // "Hell" in ASCII85
let source = MemorySource::new(encoded.to_vec());
let mut dict = IndexMap::new();
dict.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![
PdfObject::Name("A85".into()), // Abbreviated
])),
);
dict.insert("/Length".into(), PdfObject::Integer(encoded.len() as i64));
let stream = PdfStream::new(dict, 0, Some(encoded.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, b"Hell");
}
}
/// Unit tests for predictor functionality.
#[cfg(test)]
mod predictor_tests {
use super::*;
use indexmap::IndexMap;
use secrecy::ExposeSecret;
#[test]
fn test_predictor_params_default() {
let params = PredictorParams::default();
assert_eq!(params.predictor, 1);
assert_eq!(params.columns, 1);
assert_eq!(params.colors, 1);
assert_eq!(params.bits_per_component, 8);
}
#[test]
fn test_predictor_params_from_none() {
let params = PredictorParams::from_pdf_object(None);
assert!(params.is_none());
}
#[test]
fn test_predictor_params_from_dict() {
let mut dict = IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(2));
dict.insert("/Columns".into(), PdfObject::Integer(100));
dict.insert("/Colors".into(), PdfObject::Integer(3));
dict.insert("/BitsPerComponent".into(), PdfObject::Integer(8));
let params = PredictorParams::from_pdf_object(Some(&PdfObject::Dict(Box::new(dict))));
assert!(params.is_some());
let p = params.unwrap();
assert_eq!(p.predictor, 2);
assert_eq!(p.columns, 100);
assert_eq!(p.colors, 3);
assert_eq!(p.bits_per_component, 8);
}
#[test]
fn test_predictor_params_defaults_for_predictor_1() {
let mut dict = IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(1));
let params = PredictorParams::from_pdf_object(Some(&PdfObject::Dict(Box::new(dict))));
assert!(params.is_some());
let p = params.unwrap();
assert_eq!(p.predictor, 1);
}
#[test]
fn test_predictor_params_invalid_predictor() {
let mut dict = IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(99));
let params = PredictorParams::from_pdf_object(Some(&PdfObject::Dict(Box::new(dict))));
assert!(params.is_some());
let p = params.unwrap();
assert_eq!(p.predictor, 1);
}
#[test]
fn test_predictor_params_invalid_columns() {
let mut dict = IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(2));
dict.insert("/Columns".into(), PdfObject::Integer(-1));
let params = PredictorParams::from_pdf_object(Some(&PdfObject::Dict(Box::new(dict))));
assert!(params.is_some());
let p = params.unwrap();
assert_eq!(p.predictor, 1);
}
#[test]
fn test_bytes_per_pixel() {
let params = PredictorParams {
predictor: 15,
columns: 100,
colors: 3,
bits_per_component: 8,
};
assert_eq!(params.bytes_per_pixel(), 3);
let params_rgba = PredictorParams {
predictor: 15,
columns: 100,
colors: 4,
bits_per_component: 8,
};
assert_eq!(params_rgba.bytes_per_pixel(), 4);
}
#[test]
fn test_bytes_per_row() {
let params = PredictorParams {
predictor: 15,
columns: 100,
colors: 3,
bits_per_component: 8,
};
assert_eq!(params.bytes_per_row(), 300);
assert_eq!(params.bytes_per_row_with_selector(), 301);
}
#[test]
fn test_apply_predictor_no_predictor() {
let data = b"hello world";
let params = PredictorParams::default();
let result = apply_predictor(data, &params, 10000);
assert_eq!(result, data);
}
#[test]
fn test_apply_predictor_empty_data() {
let data = b"";
let params = PredictorParams::default();
let result = apply_predictor(data, &params, 10000);
assert!(result.is_empty());
}
#[test]
fn test_tiff_predictor_2_grayscale() {
let predicted = vec![0u8, 10, 10, 10];
let params = PredictorParams {
predictor: 2,
columns: 4,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&predicted, &params, 10000);
assert_eq!(result, vec![0, 10, 20, 30]);
}
#[test]
fn test_tiff_predictor_2_rgb() {
let predicted = vec![255u8, 0, 0, 1, 255, 0, 0, 1, 255];
let params = PredictorParams {
predictor: 2,
columns: 3,
colors: 3,
bits_per_component: 8,
};
let result = apply_predictor(&predicted, &params, 10000);
assert_eq!(result, vec![255, 0, 0, 0, 255, 0, 0, 0, 255]);
}
#[test]
fn test_png_predictor_10_none() {
let mut data = vec![10u8];
data.extend_from_slice(b"hello");
let params = PredictorParams {
predictor: 10,
columns: 5,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, b"hello");
}
#[test]
fn test_png_predictor_11_sub() {
let mut data = vec![11u8];
data.extend_from_slice(&[10, 10, 10, 10, 10]);
let params = PredictorParams {
predictor: 11,
columns: 5,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![10, 20, 30, 40, 50]);
}
#[test]
fn test_png_predictor_12_up() {
let mut data = Vec::new();
data.push(10);
data.extend_from_slice(&[10, 20, 30]);
data.push(12);
data.extend_from_slice(&[5, 10, 15]);
let params = PredictorParams {
predictor: 12,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![10, 20, 30, 15, 30, 45]);
}
#[test]
fn test_png_predictor_13_average() {
let mut data = vec![13u8];
data.extend_from_slice(&[10, 15, 20]);
let params = PredictorParams {
predictor: 13,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![10, 20, 30]);
}
#[test]
fn test_png_predictor_14_paeth() {
let mut data = vec![14u8];
data.extend_from_slice(&[10, 20, 30]);
let params = PredictorParams {
predictor: 14,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![10, 30, 60]);
}
/// Critical test: PNG predictor 15 (Optimum) with all selector types.
#[test]
fn test_png_predictor_15_optimum_all_selectors() {
let mut data = Vec::new();
data.push(10);
data.extend_from_slice(&[1, 2, 3]);
data.push(11);
data.extend_from_slice(&[10, 10, 10]);
data.push(12);
data.extend_from_slice(&[5, 10, 15]);
data.push(13);
data.extend_from_slice(&[8, 8, 8]);
data.push(14);
data.extend_from_slice(&[0, 0, 0]);
let params = PredictorParams {
predictor: 15,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(
result,
vec![1, 2, 3, 10, 20, 30, 15, 30, 45, 15, 30, 45, 15, 30, 45,]
);
}
#[test]
fn test_png_predictor_rgb_sub() {
let mut data = vec![11u8];
data.extend_from_slice(&[255, 0, 0, 1, 255, 0, 0, 1, 255]);
let params = PredictorParams {
predictor: 11,
columns: 3,
colors: 3,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![255, 0, 0, 0, 255, 0, 0, 0, 255]);
}
#[test]
fn test_png_predictor_rgba_up() {
let mut data = Vec::new();
data.push(10);
data.extend_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80]);
data.push(12);
data.extend_from_slice(&[5, 10, 15, 20, 25, 30, 35, 40]);
let params = PredictorParams {
predictor: 12,
columns: 2,
colors: 4,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(
result,
vec![10, 20, 30, 40, 50, 60, 70, 80, 15, 30, 45, 60, 75, 90, 105, 120,]
);
}
#[test]
fn test_png_predictor_invalid_selector() {
let mut data = vec![99u8];
data.extend_from_slice(&[1, 2, 3]);
let params = PredictorParams {
predictor: 15,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![1, 2, 3]);
}
#[test]
fn test_flate_decode_with_predictor() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
let mut predicted_data = Vec::new();
predicted_data.push(10);
predicted_data.extend_from_slice(&[10, 20, 30]);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&predicted_data).unwrap();
let compressed = encoder.finish().unwrap();
let mut decode_dict = IndexMap::new();
decode_dict.insert("/Predictor".into(), PdfObject::Integer(15));
decode_dict.insert("/Columns".into(), PdfObject::Integer(3));
decode_dict.insert("/Colors".into(), PdfObject::Integer(1));
decode_dict.insert("/BitsPerComponent".into(), PdfObject::Integer(8));
let mut counter = 0;
let result = FlateDecoder.decode(
&compressed,
Some(&PdfObject::Dict(Box::new(decode_dict))),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
let decoded = result.unwrap();
assert_eq!(decoded, vec![10, 20, 30]);
}
#[test]
fn test_flate_decode_truncated_stream() {
let truncated = b"\x78\x9c\xcbH\xcd\xc9";
let mut counter = 0;
let result =
FlateDecoder.decode(truncated, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
assert!(result.is_ok());
let decoded = result.unwrap();
assert!(!decoded.is_empty() || decoded.is_empty());
}
#[test]
fn test_flate_decode_bomb_limit_with_predictor() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
// Create a SMALL pattern (150 bytes) for predictor testing
// We NEVER create a 6000-byte buffer - only the small pattern
let mut predicted_data = Vec::new();
for _ in 0..25 {
// PNG predictor 15 (optimum) selector byte + 5 data bytes
predicted_data.push(10); // selector 10 (None)
predicted_data.extend_from_slice(&[1, 2, 3, 4, 5]);
}
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(&predicted_data).unwrap();
let compressed = encoder.finish().unwrap();
let mut decode_dict = IndexMap::new();
decode_dict.insert("/Predictor".into(), PdfObject::Integer(15));
decode_dict.insert("/Columns".into(), PdfObject::Integer(5));
decode_dict.insert("/Colors".into(), PdfObject::Integer(1));
decode_dict.insert("/BitsPerComponent".into(), PdfObject::Integer(8));
// Set bomb limit to 50 bytes (less than the 150-byte decoded size)
// This forces early abort during decompression
let bomb_limit: u64 = 50;
let mut counter = 0;
let result = FlateDecoder.decode(
&compressed,
Some(&PdfObject::Dict(Box::new(decode_dict))),
&mut counter,
bomb_limit,
);
assert!(result.is_ok());
let decoded = result.unwrap();
// CRITICAL: Must stop at or before bomb limit
assert!(
decoded.len() <= bomb_limit as usize,
"Predictor output {} exceeds bomb limit {}",
decoded.len(),
bomb_limit
);
// Verify truncation occurred
assert!(
decoded.len() < 150,
"Should have truncated at bomb limit, got full output {} bytes",
decoded.len()
);
}
#[test]
fn test_paeth_function() {
assert_eq!(paeth(10, 10, 10), 10);
assert_eq!(paeth(100, 0, 0), 100);
assert_eq!(paeth(0, 100, 0), 100);
assert_eq!(paeth(100, 0, 50), 50);
assert_eq!(paeth(0, 0, 0), 0);
assert_eq!(paeth(255, 255, 255), 255);
}
#[test]
fn test_predictor_with_odd_bits_per_component() {
let params = PredictorParams {
predictor: 2,
columns: 10,
colors: 1,
bits_per_component: 1,
};
assert_eq!(params.bytes_per_row(), 2);
}
#[test]
fn test_predictor_multiple_rows_tiff() {
let mut predicted = Vec::new();
predicted.extend_from_slice(&[0, 10, 10, 10]);
predicted.extend_from_slice(&[5, 5, 5, 5]);
let params = PredictorParams {
predictor: 2,
columns: 4,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&predicted, &params, 10000);
assert_eq!(result, vec![0, 10, 20, 30, 5, 10, 15, 20]);
}
#[test]
fn test_png_predictor_selector_0() {
let mut data = vec![0u8];
data.extend_from_slice(&[1, 2, 3]);
let params = PredictorParams {
predictor: 15,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![1, 2, 3]);
}
#[test]
fn test_png_predictor_selector_1() {
let mut data = vec![1u8];
data.extend_from_slice(&[10, 10, 10]);
let params = PredictorParams {
predictor: 15,
columns: 3,
colors: 1,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
assert_eq!(result, vec![10, 20, 30]);
}
#[cfg(feature = "serde")]
#[test]
fn test_extraction_options_deserialize_password() {
use secrecy::SecretString;
use serde_json;
// Test deserialization with password
let json = r#"{"max_decompress_bytes": 536870912, "password": "test123"}"#;
let opts: ExtractionOptions = serde_json::from_str(json).unwrap();
assert_eq!(opts.max_decompress_bytes, 536870912);
assert!(opts.password.is_some());
// Verify we can access the secret value
assert_eq!(
opts.password.as_ref().map(|p| p.expose_secret().as_ref()),
Some("test123")
);
// Test deserialization without password
let json_no_pwd = r#"{"max_decompress_bytes": 1073741824}"#;
let opts_no_pwd: ExtractionOptions = serde_json::from_str(json_no_pwd).unwrap();
assert_eq!(opts_no_pwd.max_decompress_bytes, 1073741824);
assert!(opts_no_pwd.password.is_none());
// Test deserialization with null password
let json_null_pwd = r#"{"max_decompress_bytes": 536870912, "password": null}"#;
let opts_null_pwd: ExtractionOptions = serde_json::from_str(json_null_pwd).unwrap();
assert_eq!(opts_null_pwd.max_decompress_bytes, 536870912);
assert!(opts_null_pwd.password.is_none());
}
#[cfg(feature = "serde")]
#[test]
fn test_extraction_options_serialize_password_redacted() {
use secrecy::SecretString;
use serde_json;
let mut opts = ExtractionOptions::default();
opts.password = Some(SecretString::new("secret123".to_string().into()));
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("REDACTED"));
assert!(!json.contains("secret123"));
}
/// Test PNG predictor 14 (Paeth) on 8-bit RGBA.
///
/// This test verifies the Paeth predictor works correctly with RGBA data
/// (4 color components per pixel). The Paeth predictor is the most complex
/// PNG filter, using a linear function of three neighboring bytes.
///
/// Expected values computed using the reference Paeth algorithm:
/// For each byte: output = input + paeth(left, up, up_left)
#[test]
fn test_png_predictor_14_rgba_paeth() {
let mut data = Vec::new();
// First row (selector 14, then 8 pixels of RGBA data)
// Row 0: [10,20,30,40, 50,60,70,80]
data.push(14);
data.extend_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80]);
// Second row (selector 14, then 8 pixels of RGBA data)
// Row 1: [5,10,15,20, 25,30,35,40]
// After Paeth with prev row [10,20,30,40, 50,60,70,80]:
// Pixel 0: paeth(0, 10, 0) = 10 -> [5+10, 10+20, 15+30, 20+40] = [15, 30, 45, 60]
// Pixel 1: paeth(15, 50, 10) = 50 (using a=15, b=50, c=10)
// p = 15 + 50 - 10 = 55
// pa = |55 - 15| = 40, pb = |55 - 50| = 5, pc = |55 - 10| = 45
// min is pb (5) -> b (50)
// -> [25+50, 30+60, 35+70, 40+80] = [75, 90, 105, 120]
data.push(14);
data.extend_from_slice(&[5, 10, 15, 20, 25, 30, 35, 40]);
let params = PredictorParams {
predictor: 14,
columns: 2,
colors: 4,
bits_per_component: 8,
};
let result = apply_predictor(&data, &params, 10000);
// First row: no prev row, so up=0, up_left=0
// Pixel 0, R: paeth(0, 0, 0) = 0 -> 10 + 0 = 10
// Pixel 0, G: paeth(0, 0, 0) = 0 -> 20 + 0 = 20
// Pixel 0, B: paeth(0, 0, 0) = 0 -> 30 + 0 = 30
// Pixel 0, A: paeth(0, 0, 0) = 0 -> 40 + 0 = 40
// Pixel 1, R: paeth(10, 0, 0) = 10 -> 50 + 10 = 60
// Pixel 1, G: paeth(20, 0, 0) = 20 -> 60 + 20 = 80
// Pixel 1, B: paeth(30, 0, 0) = 30 -> 70 + 30 = 100
// Pixel 1, A: paeth(40, 0, 0) = 40 -> 80 + 40 = 120
// Second row:
// Pixel 0, R: paeth(0, 10, 0) = 10 -> 5 + 10 = 15
// Pixel 0, G: paeth(0, 20, 0) = 20 -> 10 + 20 = 30
// Pixel 0, B: paeth(0, 30, 0) = 30 -> 15 + 30 = 45
// Pixel 0, A: paeth(0, 40, 0) = 40 -> 20 + 40 = 60
// Pixel 1, R: paeth(15, 60, 10) - compute: p=65, pa=50, pb=5, pc=55 -> min is pb -> b=60 -> 25+60=85
// Pixel 1, G: paeth(30, 80, 20) - compute: p=90, pa=60, pb=10, pc=70 -> min is pb -> b=80 -> 30+80=110
// Pixel 1, B: paeth(45, 100, 30) - compute: p=115, pa=70, pb=15, pc=85 -> min is pb -> b=100 -> 35+100=135
// Pixel 1, A: paeth(60, 120, 40) - compute: p=140, pa=80, pb=20, pc=100 -> min is pb -> b=120 -> 40+120=160
assert_eq!(
result,
vec![10, 20, 30, 40, 60, 80, 100, 120, 15, 30, 45, 60, 85, 110, 135, 160,]
);
}
/// Performance test: FlateDecode of 100 MB completes in < 250 ms (release mode).
///
/// This test creates a 100 MB payload of highly compressible data
/// (repeated zeros), compresses it, then measures decompression time.
///
/// Note: This test is only enforced in release mode. In debug mode,
/// the assertion is skipped but the timing is still reported.
/// Run with: cargo test --release test_flate_decode_performance_100mb
#[test]
fn test_flate_decode_performance_100mb() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
use std::time::Instant;
const ORIGINAL_SIZE: usize = 100 * 1024 * 1024; // 100 MB
const MAX_MS_DEBUG: u128 = 5000; // 5 seconds for debug mode
const MAX_MS_RELEASE: u128 = 250; // 250 ms for release mode
// Skip this test in CI unless explicitly requested
if std::env::var("CI").is_ok() && std::env::var("RUN_PERF_TESTS").is_err() {
return;
}
// Create highly compressible data (all zeros)
let zeros = vec![0u8; ORIGINAL_SIZE];
// Compress with fast compression (maximum speed)
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(&zeros).unwrap();
let compressed = encoder.finish().unwrap();
// Verify compression achieved good ratio
assert!(
compressed.len() < ORIGINAL_SIZE / 100,
"Compression ratio too low: {} -> {}",
compressed.len(),
ORIGINAL_SIZE
);
// Measure decompression time
let start = Instant::now();
let mut counter = 0;
let result = FlateDecoder.decode(
&compressed,
None,
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
let elapsed = start.elapsed();
assert!(result.is_ok(), "FlateDecode failed: {:?}", result.err());
let decoded = result.unwrap();
assert_eq!(decoded.len(), ORIGINAL_SIZE);
// Assert performance meets target (different thresholds for debug/release)
let elapsed_ms = elapsed.as_millis();
let is_release = cfg!(not(debug_assertions));
let max_ms = if is_release {
MAX_MS_RELEASE
} else {
MAX_MS_DEBUG
};
// Only enforce performance in release mode
if is_release {
assert!(
elapsed_ms < max_ms,
"FlateDecode too slow: {} ms for 100 MB (target: < {} ms)",
elapsed_ms,
max_ms
);
}
// Print performance info for manual verification
let mb_per_sec = (ORIGINAL_SIZE as f64 / (1024.0 * 1024.0)) / (elapsed_ms as f64 / 1000.0);
println!(
"FlateDecode performance ({}): {} ms for 100 MB ({} MB/s) - target: < {} ms",
if is_release { "release" } else { "debug" },
elapsed_ms,
mb_per_sec,
max_ms
);
}
/// Critical test: PNG predictor enforces max_output budget with small fixture.
///
/// This test verifies that PNG predictor processing stops at the max_output
/// budget WITHOUT pre-allocating a full copy of the output. Per bf-49wmw,
/// the predictor uses row-by-row processing with peak memory at 2x stride
/// (MAX_ROW_BYTES = 64 KB) regardless of image height.
///
/// The test uses a minimal fixture (200 bytes) that would decode to more
/// than the budget limit, forcing early truncation.
#[test]
fn test_png_predictor_budget_enforcement_small_fixture() {
// Create a small predicted payload: 20 rows × 10 bytes = 200 bytes
// This is well below MAX_ROW_BYTES (64 KB) but large enough to test budget
let mut predicted_data = Vec::new();
for _ in 0..20 {
predicted_data.push(10); // PNG predictor 10 (None)
predicted_data.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
let params = PredictorParams {
predictor: 15,
columns: 9,
colors: 1,
bits_per_component: 8,
};
// Set budget to 100 bytes (less than the 200-byte decoded size)
// This forces early abort during predictor processing
let max_output = 100;
let result = apply_predictor(&predicted_data, &params, max_output);
// CRITICAL: Must stop at or before budget limit
assert!(
result.len() <= max_output as usize,
"PNG predictor output {} exceeds budget limit {}",
result.len(),
max_output
);
// Verify truncation occurred (got partial output, not full)
assert!(
result.len() < 180, // 20 rows × 9 bytes
"Should have truncated at budget limit, got full output {} bytes",
result.len()
);
// Verify row-by-row processing: output should be a multiple of row_size
let row_size = params.bytes_per_row();
assert!(
result.len() % row_size == 0 || result.len() % row_size == row_size - 1,
"Output length {} should be aligned to row boundaries (row_size={})",
result.len(),
row_size
);
}
/// Critical test: TIFF predictor 2 enforces max_output budget with small fixture.
///
/// This test verifies that TIFF predictor 2 processing stops at the max_output
/// budget WITHOUT pre-allocating a full copy of the output. Per bf-49wmw,
/// the predictor uses row-by-row processing with peak memory at 2x stride
/// (MAX_ROW_BYTES = 64 KB) regardless of image height.
///
/// The test uses a minimal fixture (160 bytes) that would decode to more
/// than the budget limit, forcing early truncation.
#[test]
fn test_tiff_predictor_2_budget_enforcement_small_fixture() {
// Create a small predicted payload: 20 rows × 8 bytes = 160 bytes
let mut predicted_data = Vec::new();
for _ in 0..20 {
// Each row: [0, 1, 1, 1, 1, 1, 1, 1] for grayscale
predicted_data.extend_from_slice(&[0, 1, 1, 1, 1, 1, 1, 1]);
}
let params = PredictorParams {
predictor: 2,
columns: 8,
colors: 1,
bits_per_component: 8,
};
// Set budget to 80 bytes (half of the 160-byte decoded size)
// This forces early abort during predictor processing
let max_output = 80;
let result = apply_predictor(&predicted_data, &params, max_output);
// CRITICAL: Must stop at or before budget limit
assert!(
result.len() <= max_output as usize,
"TIFF predictor 2 output {} exceeds budget limit {}",
result.len(),
max_output
);
// Verify truncation occurred (got partial output, not full)
assert!(
result.len() < 160,
"Should have truncated at budget limit, got full output {} bytes",
result.len()
);
// Verify row-by-row processing: output should be a multiple of row_size
let row_size = params.bytes_per_row();
assert!(
result.len() % row_size == 0,
"Output length {} should be aligned to row boundaries (row_size={})",
result.len(),
row_size
);
}
/// Test: PNG predictor with multiple selectors enforces budget per-row.
///
/// This test verifies that PNG predictor processes each selector type
/// (None, Sub, Up, Average, Paeth) with row-by-row budget checking.
/// Per bf-49wmw, budget is checked BEFORE processing each row.
#[test]
fn test_png_predictor_multiple_selectors_budget_per_row() {
let mut data = Vec::new();
// Row 1: PNG predictor 10 (None)
data.push(10);
data.extend_from_slice(&[10, 20, 30]);
// Row 2: PNG predictor 11 (Sub)
data.push(11);
data.extend_from_slice(&[5, 5, 5]);
// Row 3: PNG predictor 12 (Up)
data.push(12);
data.extend_from_slice(&[1, 2, 3]);
// Row 4: PNG predictor 13 (Average)
data.push(13);
data.extend_from_slice(&[2, 2, 2]);
// Row 5: PNG predictor 14 (Paeth)
data.push(14);
data.extend_from_slice(&[0, 0, 0]);
let params = PredictorParams {
predictor: 15,
columns: 3,
colors: 1,
bits_per_component: 8,
};
// Set budget to only allow 2 complete rows (6 bytes)
let max_output = 6;
let result = apply_predictor(&data, &params, max_output);
// Should get exactly 2 rows (6 bytes) before budget is hit
assert_eq!(
result.len(),
6,
"Should have gotten exactly 2 rows before budget, got {} bytes",
result.len()
);
// Verify the first two rows are correct
assert_eq!(result[0..3], [10, 20, 30], "First row (None) incorrect");
assert_eq!(result[3..6], [5, 10, 15], "Second row (Sub) incorrect");
}
/// Test: TIFF predictor 2 with RGB processes row-by-row with budget enforcement.
///
/// This test verifies that TIFF predictor 2 handles multi-byte pixels (RGB)
/// with row-by-row processing and per-row budget checking.
#[test]
fn test_tiff_predictor_2_rgb_budget_enforcement() {
// Create 5 rows of RGB data (3 bytes per pixel, 3 columns = 9 bytes per row)
let mut predicted_data = Vec::new();
for i in 0..5 {
// Each row starts with a base value, then differences
let base = (i * 10) as u8;
predicted_data.extend_from_slice(&[base, 1, 1, base, 2, 2, base, 3, 3]);
}
let params = PredictorParams {
predictor: 2,
columns: 3,
colors: 3, // RGB
bits_per_component: 8,
};
// Set budget to only allow 2 complete rows (18 bytes)
let max_output = 18;
let result = apply_predictor(&predicted_data, &params, max_output);
// Should get exactly 2 rows (18 bytes) before budget is hit
assert_eq!(
result.len(),
18,
"Should have gotten exactly 2 rows before budget, got {} bytes",
result.len()
);
// Verify row-by-row processing with RGB
// Row 0: [0, 1, 1] + [0, 2, 2] + [0, 3, 3] -> [0, 1, 1, 0, 3, 3, 0, 6, 6]
assert_eq!(
result[0..9],
[0, 1, 1, 0, 3, 3, 0, 6, 6],
"First row incorrect"
);
}
}
/// Unit tests for Crypt filter functionality.
#[cfg(test)]
mod crypt_tests {
use super::*;
use indexmap::IndexMap;
/// Test: /Crypt with /Name /Identity passes input through unchanged.
///
/// Per acceptance criteria: "/Crypt with /Name /Identity: input passes through unchanged"
#[test]
fn test_crypt_decode_identity() {
let input = b"test data that should pass through";
let source = MemorySource::new(input.to_vec());
let mut decode_parms = IndexMap::new();
decode_parms.insert(
"/Type".into(),
PdfObject::Name("CryptFilterDecodeParms".into()),
);
decode_parms.insert("/Name".into(), PdfObject::Name("Identity".into()));
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("Crypt".into()));
dict.insert(
"/DecodeParms".into(),
PdfObject::Dict(Box::new(decode_parms)),
);
dict.insert("/Length".into(), PdfObject::Integer(input.len() as i64));
let stream = PdfStream::new(dict, 0, Some(input.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, input);
}
/// Test: /Crypt with /Name /MyCustom returns EncryptionUnsupported error.
///
/// Per acceptance criteria: "/Crypt with /Name /MyCustom: ENCRYPTION_UNSUPPORTED diagnostic;
/// FilterError::EncryptionUnsupported returned; orchestrator marks stream as empty"
#[test]
fn test_crypt_decode_custom_rejected() {
let input = b"encrypted data";
let source = MemorySource::new(input.to_vec());
let mut decode_parms = IndexMap::new();
decode_parms.insert(
"/Type".into(),
PdfObject::Name("CryptFilterDecodeParms".into()),
);
decode_parms.insert("/Name".into(), PdfObject::Name("MyCustom".into()));
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("Crypt".into()));
dict.insert(
"/DecodeParms".into(),
PdfObject::Dict(Box::new(decode_parms)),
);
dict.insert("/Length".into(), PdfObject::Integer(input.len() as i64));
let stream = PdfStream::new(dict, 0, Some(input.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// Stream should be empty when EncryptionUnsupported is returned
assert!(decoded.is_empty());
assert_eq!(counter, 0); // No bytes counted
}
/// Test: /Crypt with no /DecodeParms defaults to /Identity.
///
/// Per acceptance criteria: "/Crypt with no /DecodeParms (missing /Name): treat as /Identity per spec default"
#[test]
fn test_crypt_decode_no_params() {
let input = b"no decode params means identity";
let source = MemorySource::new(input.to_vec());
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("Crypt".into()));
dict.insert("/Length".into(), PdfObject::Integer(input.len() as i64));
let stream = PdfStream::new(dict, 0, Some(input.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, input);
}
/// Test: /Crypt with /Name missing defaults to /Identity.
///
/// Per acceptance criteria: "/Crypt with no /DecodeParms (missing /Name): treat as /Identity per spec default"
#[test]
fn test_crypt_decode_missing_name() {
let input = b"missing name means identity";
let source = MemorySource::new(input.to_vec());
let mut decode_parms = IndexMap::new();
decode_parms.insert(
"/Type".into(),
PdfObject::Name("CryptFilterDecodeParms".into()),
);
// /Name is intentionally missing
let mut dict = IndexMap::new();
dict.insert("/Filter".into(), PdfObject::Name("Crypt".into()));
dict.insert(
"/DecodeParms".into(),
PdfObject::Dict(Box::new(decode_parms)),
);
dict.insert("/Length".into(), PdfObject::Integer(input.len() as i64));
let stream = PdfStream::new(dict, 0, Some(input.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
assert_eq!(decoded, input);
}
/// Test: /Crypt with /Identity followed by /FlateDecode processes correctly.
///
/// Per acceptance criteria: "Fixture test: a PDF with /Filter [/Crypt /FlateDecode] and
/// /Identity crypt -> falls through to FlateDecode normally"
#[test]
fn test_crypt_identity_then_flate() {
// "hello" compressed with flate
let original = b"hello";
let compressed = b"\x78\x9c\xcbH\xcd\xc9\xc9\x07\x00\x06,\x02\x15";
let source = MemorySource::new(compressed.to_vec());
let mut decode_parms = IndexMap::new();
decode_parms.insert(
"/Type".into(),
PdfObject::Name("CryptFilterDecodeParms".into()),
);
decode_parms.insert("/Name".into(), PdfObject::Name("Identity".into()));
let mut dict = IndexMap::new();
dict.insert(
"/Filter".into(),
PdfObject::Array(Box::new(vec![
PdfObject::Name("Crypt".into()),
PdfObject::Name("FlateDecode".into()),
])),
);
dict.insert(
"/DecodeParms".into(),
PdfObject::Array(Box::new(vec![PdfObject::Dict(Box::new(decode_parms))])),
);
dict.insert(
"/Length".into(),
PdfObject::Integer(compressed.len() as i64),
);
let stream = PdfStream::new(dict, 0, Some(compressed.len() as u64));
let opts = ExtractionOptions::default();
let mut counter = 0;
let decoded = decode_stream(&stream, &source, &opts, &mut counter);
// Crypt /Identity is a no-op, FlateDecode should decompress
assert_eq!(decoded, original);
}
/// Test: Crypt decoder directly with various parameter types.
#[test]
fn test_crypt_decoder_invalid_params() {
let input = b"test data";
// Invalid /DecodeParms type (not a dict) - should treat as /Identity
let mut counter = 0;
let result = CryptDecoder.decode(
input,
Some(&PdfObject::Integer(42)),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), input);
// /Name not a Name object - should treat as /Identity
let mut decode_parms = IndexMap::new();
decode_parms.insert("/Name".into(), PdfObject::Integer(42));
let mut counter2 = 0;
let result2 = CryptDecoder.decode(
input,
Some(&PdfObject::Dict(Box::new(decode_parms))),
&mut counter2,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result2.is_ok());
assert_eq!(result2.unwrap(), input);
// Wrong /Type - should treat as /Identity
let mut decode_parms3 = IndexMap::new();
decode_parms3.insert("/Type".into(), PdfObject::Name("WrongType".into()));
decode_parms3.insert("/Name".into(), PdfObject::Name("Identity".into()));
let mut counter3 = 0;
let result3 = CryptDecoder.decode(
input,
Some(&PdfObject::Dict(Box::new(decode_parms3))),
&mut counter3,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(result3.is_ok());
assert_eq!(result3.unwrap(), input);
}
/// Test: Crypt decoder enforces bomb limit.
#[test]
fn test_crypt_decode_bomb_limit() {
let input = b"test data that exceeds limit";
let bomb_limit: u64 = 5;
let mut decode_parms = IndexMap::new();
decode_parms.insert("/Name".into(), PdfObject::Name("Identity".into()));
let mut counter = 0;
let result = CryptDecoder.decode(
input,
Some(&PdfObject::Dict(Box::new(decode_parms))),
&mut counter,
bomb_limit,
);
assert!(result.is_ok());
let decoded = result.unwrap();
// Should truncate to bomb limit
assert!(decoded.len() <= bomb_limit as usize);
}
/// Test: Crypt decoder name method.
#[test]
fn test_crypt_decoder_name() {
assert_eq!(CryptDecoder.name(), "Crypt");
}
/// Test: Custom crypt filter names are rejected.
#[test]
fn test_crypt_custom_names_rejected() {
let input = b"encrypted data";
// Test various custom filter names that should all be rejected
let custom_names = vec!["V2", "AESV2", "AESV3", "MyCrypt", "Unknown"];
for name in custom_names {
let mut decode_parms = IndexMap::new();
decode_parms.insert("/Name".into(), PdfObject::Name(name.to_string().into()));
let mut counter = 0;
let result = CryptDecoder.decode(
input,
Some(&PdfObject::Dict(Box::new(decode_parms))),
&mut counter,
DEFAULT_MAX_DECOMPRESS_BYTES,
);
assert!(
matches!(result, Err(FilterError::EncryptionUnsupported)),
"Custom filter '{}' should return EncryptionUnsupported",
name
);
}
}
}
/// proptest property tests for FlateDecode.
///
/// Per acceptance criteria: "proptest: random byte sequences fed to
/// FlateDecode never panic"
#[cfg(test)]
mod proptest_tests {
use super::*;
use proptest::prelude::*;
proptest! {
/// Random byte sequences never panic FlateDecode.
///
/// This test generates random byte sequences and feeds them to
/// FlateDecode. The decoder must never panic, even for invalid
/// zlib data (truncated, corrupt, etc.).
#[test]
fn proptest_flate_decode_no_panic(data in any::<Vec<u8>>()) {
let mut counter = 0;
// This should never panic, even for invalid zlib data
let _ = FlateDecoder.decode(&data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random byte sequences with various predictor settings never panic.
///
/// This test combines random data with random predictor parameters
/// to ensure the predictor application never panics.
#[test]
fn proptest_flate_decode_with_predictor_no_panic(
data in any::<Vec<u8>>(),
predictor in 1i32..16,
columns in 1i32..100,
colors in 1i32..5,
bits_per_component in 1i32..17
) {
let mut dict = indexmap::IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(predictor as i64));
dict.insert("/Columns".into(), PdfObject::Integer(columns as i64));
dict.insert("/Colors".into(), PdfObject::Integer(colors as i64));
dict.insert("/BitsPerComponent".into(), PdfObject::Integer(bits_per_component as i64));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
// This should never panic
let _ = FlateDecoder.decode(&data, params.as_ref(), &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random compressed data with bomb limits never panic.
///
/// This test verifies that hitting the bomb limit doesn't cause
/// a panic, just returns partial bytes.
#[test]
fn proptest_flate_decode_bomb_limit_no_panic(data in any::<Vec<u8>>()) {
let mut counter = 0;
// Very low bomb limit - most data should trigger it
let bomb_limit: u64 = 100;
// This should never panic, even when hitting bomb limit
let _ = FlateDecoder.decode(&data, None, &mut counter, bomb_limit);
}
/// Random byte sequences with Crypt filter never panic.
///
/// Per acceptance criteria: "proptest: random bytes / params combinations never panic"
///
/// This test generates random byte sequences and feeds them to
/// CryptDecoder. The decoder must never panic, even for invalid
/// parameters or data.
#[test]
fn proptest_crypt_decode_no_panic(data in any::<Vec<u8>>()) {
let mut counter = 0;
// No params (defaults to /Identity) - should never panic
let _ = CryptDecoder.decode(&data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random byte sequences with random Crypt filter parameters never panic.
///
/// Per acceptance criteria: "proptest: random bytes / params combinations never panic"
///
/// This test combines random data with random crypt filter parameters
/// to ensure the decoder never panics.
#[test]
fn proptest_crypt_decode_with_params_no_panic(
data in any::<Vec<u8>>(),
name_filter in 0u8..4 // 0=None, 1=Identity, 2=Custom, 3=Invalid type
) {
let mut decode_parms = indexmap::IndexMap::new();
decode_parms.insert("/Type".into(), PdfObject::Name("CryptFilterDecodeParms".into()));
let params = match name_filter {
0 => None, // No /Name -> defaults to /Identity
1 => {
decode_parms.insert("/Name".into(), PdfObject::Name("Identity".into()));
Some(PdfObject::Dict(Box::new(decode_parms)))
}
2 => {
decode_parms.insert("/Name".into(), PdfObject::Name("CustomCrypt".into()));
Some(PdfObject::Dict(Box::new(decode_parms)))
}
_ => {
// /Name is not a Name object -> defaults to /Identity
decode_parms.insert("/Name".into(), PdfObject::Integer(42));
Some(PdfObject::Dict(Box::new(decode_parms)))
}
};
let mut counter = 0;
// This should never panic
let _ = CryptDecoder.decode(&data, params.as_ref(), &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random byte sequences with Crypt filter bomb limits never panic.
///
/// This test verifies that hitting the bomb limit doesn't cause
/// a panic with the Crypt filter.
#[test]
fn proptest_crypt_decode_bomb_limit_no_panic(data in any::<Vec<u8>>()) {
let mut counter = 0;
// Very low bomb limit - most data should trigger it
let bomb_limit: u64 = 100;
let mut decode_parms = indexmap::IndexMap::new();
decode_parms.insert("/Name".into(), PdfObject::Name("Identity".into()));
let params = Some(PdfObject::Dict(Box::new(decode_parms)));
// This should never panic, even when hitting bomb limit
let _ = CryptDecoder.decode(&data, params.as_ref(), &mut counter, bomb_limit);
}
/// Random byte sequences never panic LZWDecode.
///
/// Per acceptance criteria: "proptest: random byte sequences fed to
/// LZWDecode never panic"
///
/// This test generates random byte sequences and feeds them to
/// LZWDecode. The decoder must never panic, even for invalid
/// LZW data (truncated, corrupt, etc.).
#[test]
fn proptest_lzw_decode_no_panic(data in any::<Vec<u8>>()) {
let mut counter = 0;
// This should never panic, even for invalid LZW data
let _ = LZWDecoder.decode(&data, None, &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random byte sequences with various predictor settings never panic LZWDecode.
///
/// This test combines random data with random predictor parameters
/// to ensure the predictor application never panics with LZW.
#[test]
fn proptest_lzw_decode_with_predictor_no_panic(
data in any::<Vec<u8>>(),
predictor in 1i32..16,
columns in 1i32..100,
colors in 1i32..5,
bits_per_component in 1i32..17
) {
let mut dict = indexmap::IndexMap::new();
dict.insert("/Predictor".into(), PdfObject::Integer(predictor as i64));
dict.insert("/Columns".into(), PdfObject::Integer(columns as i64));
dict.insert("/Colors".into(), PdfObject::Integer(colors as i64));
dict.insert("/BitsPerComponent".into(), PdfObject::Integer(bits_per_component as i64));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
// This should never panic
let _ = LZWDecoder.decode(&data, params.as_ref(), &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random byte sequences with EarlyChange parameter never panic LZWDecode.
///
/// This test verifies that both early and late change variants
/// never panic on random input.
#[test]
fn proptest_lzw_decode_with_early_change_no_panic(
data in any::<Vec<u8>>(),
early_change in 0i32..2
) {
let mut dict = indexmap::IndexMap::new();
dict.insert("/EarlyChange".into(), PdfObject::Integer(early_change as i64));
let params = Some(PdfObject::Dict(Box::new(dict)));
let mut counter = 0;
// This should never panic for either early_change value
let _ = LZWDecoder.decode(&data, params.as_ref(), &mut counter, DEFAULT_MAX_DECOMPRESS_BYTES);
}
/// Random LZW-encoded data with bomb limits never panic.
///
/// This test verifies that hitting the bomb limit doesn't cause
/// a panic with LZWDecode.
#[test]
fn proptest_lzw_decode_bomb_limit_no_panic(data in any::<Vec<u8>>()) {
let mut counter = 0;
// Very low bomb limit - most data should trigger it
let bomb_limit: u64 = 100;
// This should never panic, even when hitting bomb limit
let _ = LZWDecoder.decode(&data, None, &mut counter, bomb_limit);
}
}
}
#[cfg(test)]
mod source_tests {
use super::*;
use std::io::Write;
/// FileSource::open successfully memory-maps a valid file.
#[test]
fn test_filesource_open() {
let pdf_content = b"%PDF-1.4
1 0 obj
<<
/Type /Catalog
>>
endobj
%%EOF";
let mut temp_file = tempfile::NamedTempFile::new().expect("failed to create temp file");
temp_file
.write_all(pdf_content)
.expect("failed to write content");
temp_file.flush().expect("failed to flush");
let path = temp_file.path().to_path_buf();
let source = FileSource::open(&path);
assert!(
source.is_ok(),
"FileSource::open should succeed for valid file"
);
let source = source.unwrap();
let len = source.len().expect("failed to get length");
assert_eq!(len, pdf_content.len() as u64);
// Keep temp_file alive until here
drop(temp_file);
}
/// FileSource::read_at reads correct bytes from memory-mapped region.
#[test]
fn test_filesource_read_at() {
let pdf_content = b"%PDF-1.4
1 0 obj
<<
/Type /Catalog
>>
endobj
%%EOF";
let mut temp_file = tempfile::NamedTempFile::new().expect("failed to create temp file");
temp_file
.write_all(pdf_content)
.expect("failed to write content");
temp_file.flush().expect("failed to flush");
let path = temp_file.path().to_path_buf();
let source = FileSource::open(&path).expect("failed to open FileSource");
// Read from the beginning
let bytes = source.read_at(0, 9).expect("failed to read at offset 0");
assert_eq!(
bytes,
b"%PDF-1.4
"
);
// Read from middle
let bytes = source.read_at(10, 10).expect("failed to read at offset 10");
assert_eq!(bytes, b" 0 obj\n<<\n");
// Read past end should return empty
let bytes = source.read_at(1000, 10).expect("failed to read past end");
assert!(bytes.is_empty());
}
/// FileSource rejects non-existent files.
#[test]
fn test_filesource_not_found() {
let result = FileSource::open("/nonexistent/path/to/file.pdf");
assert!(
result.is_err(),
"FileSource::open should fail for non-existent file"
);
}
/// FileSource zero-copy read_at slices mmap region correctly.
#[test]
fn test_filesource_zero_copy() {
let large_content = vec![b'A'; 1024 * 1024]; // 1 MB
let mut temp_file = tempfile::NamedTempFile::new().expect("failed to create temp file");
temp_file
.write_all(&large_content)
.expect("failed to write content");
temp_file.flush().expect("failed to flush");
let path = temp_file.path().to_path_buf();
let source = FileSource::open(&path).expect("failed to open FileSource");
// Read multiple regions - these should be zero-copy slices from the mmap
let bytes1 = source.read_at(0, 1024).expect("failed to read first 1KB");
let bytes2 = source
.read_at(1024 * 512, 1024)
.expect("failed to read middle 1KB");
assert_eq!(bytes1.len(), 1024);
assert_eq!(bytes2.len(), 1024);
assert!(bytes1.iter().all(|&b| b == b'A'));
assert!(bytes2.iter().all(|&b| b == b'A'));
}
/// MemorySource provides in-memory fallback for tests.
#[test]
fn test_memorysource() {
let data = b"test data for memory source";
let source = MemorySource::new(data.to_vec());
assert_eq!(source.len().expect("failed to get len"), data.len() as u64);
let bytes = source
.read_at(5, 4)
.expect("failed to read from MemorySource");
assert_eq!(bytes, b"data");
}
}