mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
Fuzzed inputs drove one check-one process to 2-6 GB (the 16 GB CI runner running two of them was killed every night). Three causes, each with a test: - lopdf (bootstrap inspector) sized PNG predictor rows from /Columns before reading data: /Columns 4294967295 allocated two 4 GiB rows. Vendored lopdf 0.45.0 refuses rows longer than the data (and uses saturating row widths); its load-time stream decoding also gets a 256 MiB limit (it had none). - hayro sized a tiling pattern's cell pixmap from /XStep x scale, bounded only by u16: a step far beyond /BBox allocated 2 GB. The vendored patch keeps the cell within 3000 px a side, lowering the resolution instead. - cos decoded cross-reference streams up to the general 1 GiB limit; they now stop at what 8,388,607 objects' rows can hold (~200 MB at most), and the section is reconstructed (logged) instead. On the nightly's seed (41) the worst captured input fell from 6.2 GB to 6 MB, and a 300 s fuzz run had no process above 300 MB (before: many at 2-5 GB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
361 lines
11 KiB
Rust
361 lines
11 KiB
Rust
use lopdf::{Document, Object, ObjectStream, dictionary};
|
|
|
|
#[test]
|
|
fn test_multiple_trailer_references_all_compressible() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
// Create multiple objects that will be referenced in trailer
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => Object::Reference((10, 0))
|
|
});
|
|
|
|
let info_id = doc.add_object(dictionary! {
|
|
"Title" => "Test",
|
|
"Author" => "Test Author"
|
|
});
|
|
|
|
let metadata_id = doc.add_object(dictionary! {
|
|
"Type" => "Metadata",
|
|
"Subtype" => "XML"
|
|
});
|
|
|
|
let outlines_id = doc.add_object(dictionary! {
|
|
"Type" => "Outlines",
|
|
"Count" => 0
|
|
});
|
|
|
|
// Add all to trailer
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Info", info_id);
|
|
doc.trailer.set("Metadata", metadata_id);
|
|
doc.trailer.set("Outlines", outlines_id);
|
|
|
|
// All should be compressible
|
|
assert!(
|
|
ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Catalog should be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(info_id, doc.objects.get(&info_id).unwrap(), &doc),
|
|
"Info should be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(metadata_id, doc.objects.get(&metadata_id).unwrap(), &doc),
|
|
"Metadata should be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(outlines_id, doc.objects.get(&outlines_id).unwrap(), &doc),
|
|
"Outlines should be compressible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_encryption_dict_with_other_trailer_refs() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
// Create catalog and encryption dictionary
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => Object::Reference((10, 0))
|
|
});
|
|
|
|
let encrypt_id = doc.add_object(dictionary! {
|
|
"Filter" => "Standard",
|
|
"V" => 2,
|
|
"R" => 3,
|
|
"Length" => 128
|
|
});
|
|
|
|
let info_id = doc.add_object(dictionary! {
|
|
"Title" => "Encrypted PDF"
|
|
});
|
|
|
|
// Add to trailer
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Encrypt", encrypt_id);
|
|
doc.trailer.set("Info", info_id);
|
|
|
|
// Catalog and Info should be compressible, Encrypt should not
|
|
assert!(
|
|
ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Catalog should be compressible even with encryption"
|
|
);
|
|
assert!(
|
|
!ObjectStream::can_be_compressed(encrypt_id, doc.objects.get(&encrypt_id).unwrap(), &doc),
|
|
"Encryption dictionary should NOT be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(info_id, doc.objects.get(&info_id).unwrap(), &doc),
|
|
"Info should be compressible even with encryption"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trailer_with_non_reference_values() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog"
|
|
});
|
|
|
|
// Add various types to trailer
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Size", Object::Integer(100));
|
|
doc.trailer.set("Prev", Object::Integer(1234));
|
|
doc.trailer.set(
|
|
"ID",
|
|
Object::Array(vec![
|
|
Object::String(vec![1, 2, 3, 4], lopdf::StringFormat::Hexadecimal),
|
|
Object::String(vec![5, 6, 7, 8], lopdf::StringFormat::Hexadecimal),
|
|
]),
|
|
);
|
|
|
|
// Catalog should still be compressible
|
|
assert!(
|
|
ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Catalog should be compressible with non-reference trailer entries"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_same_object_referenced_multiple_times() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let shared_dict_id = doc.add_object(dictionary! {
|
|
"Shared" => "Dictionary"
|
|
});
|
|
|
|
// Reference the same object from multiple trailer keys
|
|
doc.trailer.set("Custom1", shared_dict_id);
|
|
doc.trailer.set("Custom2", shared_dict_id);
|
|
doc.trailer.set("Custom3", shared_dict_id);
|
|
|
|
// Should still be compressible (not encryption dict)
|
|
assert!(
|
|
ObjectStream::can_be_compressed(shared_dict_id, doc.objects.get(&shared_dict_id).unwrap(), &doc),
|
|
"Object referenced multiple times in trailer should be compressible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_encrypt_key_with_non_reference_value() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog"
|
|
});
|
|
|
|
// Set Encrypt to non-reference value (invalid but should not crash)
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Encrypt", Object::Null);
|
|
|
|
// Catalog should be compressible
|
|
assert!(
|
|
ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Catalog should be compressible when Encrypt is not a reference"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_encrypt_key() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog"
|
|
});
|
|
|
|
let some_dict_id = doc.add_object(dictionary! {
|
|
"Filter" => "Standard" // Looks like encryption dict but not referenced as one
|
|
});
|
|
|
|
doc.trailer.set("Root", catalog_id);
|
|
// No Encrypt key in trailer
|
|
|
|
// Both should be compressible
|
|
assert!(
|
|
ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Catalog should be compressible without Encrypt in trailer"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(some_dict_id, doc.objects.get(&some_dict_id).unwrap(), &doc),
|
|
"Dictionary that looks like encryption should be compressible if not referenced as Encrypt"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_linearized_with_trailer_references() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
// Add linearization dictionary
|
|
let _lin_id = doc.add_object(dictionary! {
|
|
"Linearized" => 1
|
|
});
|
|
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog"
|
|
});
|
|
|
|
let info_id = doc.add_object(dictionary! {
|
|
"Title" => "Linearized PDF"
|
|
});
|
|
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Info", info_id);
|
|
|
|
// In linearized PDF, catalog cannot be compressed but info can
|
|
assert!(
|
|
!ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Catalog should NOT be compressible in linearized PDF"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(info_id, doc.objects.get(&info_id).unwrap(), &doc),
|
|
"Info should be compressible even in linearized PDF"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_indirect_object_chain() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
// Create a chain of objects
|
|
let obj3_id = doc.add_object(dictionary! {
|
|
"Level" => 3
|
|
});
|
|
|
|
let obj2_id = doc.add_object(dictionary! {
|
|
"Level" => 2,
|
|
"Next" => obj3_id
|
|
});
|
|
|
|
let obj1_id = doc.add_object(dictionary! {
|
|
"Level" => 1,
|
|
"Next" => obj2_id
|
|
});
|
|
|
|
// Only reference the first in trailer
|
|
doc.trailer.set("Chain", obj1_id);
|
|
|
|
// All should be compressible (trailer only directly references obj1)
|
|
assert!(
|
|
ObjectStream::can_be_compressed(obj1_id, doc.objects.get(&obj1_id).unwrap(), &doc),
|
|
"Object directly referenced in trailer should be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(obj2_id, doc.objects.get(&obj2_id).unwrap(), &doc),
|
|
"Object indirectly referenced should be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(obj3_id, doc.objects.get(&obj3_id).unwrap(), &doc),
|
|
"Object indirectly referenced should be compressible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_real_world_trailer_structure() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
// Simulate a real PDF trailer structure
|
|
let pages_id = doc.new_object_id();
|
|
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => pages_id,
|
|
"Version" => "/1.5"
|
|
});
|
|
|
|
let info_id = doc.add_object(dictionary! {
|
|
"Title" => "Real World PDF",
|
|
"Author" => "Test Suite",
|
|
"Subject" => "Testing trailer compression",
|
|
"Creator" => "lopdf",
|
|
"Producer" => "lopdf test",
|
|
"CreationDate" => "D:20250101120000Z",
|
|
"ModDate" => "D:20250807120000Z"
|
|
});
|
|
|
|
// Typical trailer entries
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Info", info_id);
|
|
doc.trailer.set("Size", Object::Integer(50));
|
|
doc.trailer.set(
|
|
"ID",
|
|
Object::Array(vec![
|
|
Object::String(b"<4E4F204944454153>".to_vec(), lopdf::StringFormat::Hexadecimal),
|
|
Object::String(b"<4E4F204944454153>".to_vec(), lopdf::StringFormat::Hexadecimal),
|
|
]),
|
|
);
|
|
|
|
// Both catalog and info should be compressible
|
|
assert!(
|
|
ObjectStream::can_be_compressed(catalog_id, doc.objects.get(&catalog_id).unwrap(), &doc),
|
|
"Real-world catalog should be compressible"
|
|
);
|
|
assert!(
|
|
ObjectStream::can_be_compressed(info_id, doc.objects.get(&info_id).unwrap(), &doc),
|
|
"Real-world info dictionary should be compressible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compression_with_save_integration() {
|
|
use lopdf::SaveOptions;
|
|
|
|
let mut doc = Document::with_version("1.5");
|
|
let pages_id = doc.new_object_id();
|
|
|
|
let page_id = doc.add_object(dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => pages_id,
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()]
|
|
});
|
|
|
|
doc.objects.insert(
|
|
pages_id,
|
|
Object::Dictionary(dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![page_id.into()],
|
|
"Count" => 1
|
|
}),
|
|
);
|
|
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => pages_id
|
|
});
|
|
|
|
let info_id = doc.add_object(dictionary! {
|
|
"Title" => "Compression Test",
|
|
"Keywords" => "test, compression, object streams"
|
|
});
|
|
|
|
doc.trailer.set("Root", catalog_id);
|
|
doc.trailer.set("Info", info_id);
|
|
|
|
// Save with object streams
|
|
let options = SaveOptions::builder().use_object_streams(true).build();
|
|
|
|
let mut output = Vec::new();
|
|
doc.save_with_options(&mut output, options).unwrap();
|
|
|
|
let content = String::from_utf8_lossy(&output);
|
|
|
|
// Verify object streams exist
|
|
assert!(content.contains("/ObjStm"), "Object streams should be created");
|
|
|
|
// Verify trailer-referenced objects are compressed
|
|
assert!(
|
|
!content.contains(&format!("{} 0 obj\n<</Type/Catalog", catalog_id.0)),
|
|
"Catalog should be in object stream"
|
|
);
|
|
assert!(
|
|
!content.contains(&format!("{} 0 obj\n<</Title", info_id.0)),
|
|
"Info should be in object stream"
|
|
);
|
|
|
|
// Load and verify the PDF is valid
|
|
let loaded = Document::load_mem(&output).unwrap();
|
|
assert!(loaded.trailer.get(b"Root").is_ok(), "Loaded PDF should have valid Root");
|
|
assert!(loaded.trailer.get(b"Info").is_ok(), "Loaded PDF should have valid Info");
|
|
}
|