mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 02:29:31 -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>
217 lines
7.6 KiB
Rust
217 lines
7.6 KiB
Rust
#[cfg(not(feature = "async"))]
|
|
mod sync_tests {
|
|
use lopdf::{Document, Error, LoadOptions, Object, ParseError};
|
|
use std::assert_matches;
|
|
use std::fs::File;
|
|
|
|
#[test]
|
|
fn load_options_default() {
|
|
let opts = LoadOptions::default();
|
|
assert!(opts.password.is_none());
|
|
assert!(opts.filter.is_none());
|
|
assert!(!opts.strict);
|
|
}
|
|
|
|
#[test]
|
|
fn load_options_with_password() {
|
|
let opts = LoadOptions::with_password("secret");
|
|
assert_eq!(opts.password.as_deref(), Some("secret"));
|
|
assert!(opts.filter.is_none());
|
|
assert!(!opts.strict);
|
|
}
|
|
|
|
#[test]
|
|
fn load_options_with_filter() {
|
|
fn my_filter(id: (u32, u16), obj: &mut Object) -> Option<((u32, u16), Object)> {
|
|
Some((id, obj.clone()))
|
|
}
|
|
let opts = LoadOptions::with_filter(my_filter);
|
|
assert!(opts.password.is_none());
|
|
assert!(opts.filter.is_some());
|
|
assert!(!opts.strict);
|
|
}
|
|
|
|
#[test]
|
|
fn load_options_debug_masks_password() {
|
|
let opts = LoadOptions::with_password("supersecret");
|
|
let debug = format!("{:?}", opts);
|
|
assert!(
|
|
!debug.contains("supersecret"),
|
|
"password should be masked in Debug output"
|
|
);
|
|
assert!(debug.contains("***"), "masked password should show ***");
|
|
}
|
|
|
|
#[test]
|
|
fn load_options_debug_no_password() {
|
|
let opts = LoadOptions::default();
|
|
let debug = format!("{:?}", opts);
|
|
assert!(debug.contains("None"));
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_default_matches_load() {
|
|
let doc1 = Document::load("assets/example.pdf").unwrap();
|
|
let doc2 = Document::load_with_options("assets/example.pdf", LoadOptions::default()).unwrap();
|
|
assert_eq!(doc1.version, doc2.version);
|
|
assert_eq!(doc1.objects.len(), doc2.objects.len());
|
|
}
|
|
|
|
#[test]
|
|
fn load_from_with_options_default_matches_load_from() {
|
|
let doc1 = Document::load_from(File::open("assets/example.pdf").unwrap()).unwrap();
|
|
let doc2 = Document::load_from_with_options(File::open("assets/example.pdf").unwrap(), LoadOptions::default())
|
|
.unwrap();
|
|
assert_eq!(doc1.version, doc2.version);
|
|
assert_eq!(doc1.objects.len(), doc2.objects.len());
|
|
}
|
|
|
|
#[test]
|
|
fn load_mem_with_options_default_matches_load_mem() {
|
|
let buf = std::fs::read("assets/example.pdf").unwrap();
|
|
let doc1 = Document::load_mem(&buf).unwrap();
|
|
let doc2 = Document::load_mem_with_options(&buf, LoadOptions::default()).unwrap();
|
|
assert_eq!(doc1.version, doc2.version);
|
|
assert_eq!(doc1.objects.len(), doc2.objects.len());
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_filter_removes_objects() {
|
|
// Filter that drops all stream objects
|
|
fn drop_streams(id: (u32, u16), obj: &mut Object) -> Option<((u32, u16), Object)> {
|
|
if obj.as_stream().is_ok() {
|
|
None
|
|
} else {
|
|
Some((id, obj.clone()))
|
|
}
|
|
}
|
|
|
|
let full = Document::load("assets/example.pdf").unwrap();
|
|
let filtered =
|
|
Document::load_with_options("assets/example.pdf", LoadOptions::with_filter(drop_streams)).unwrap();
|
|
|
|
let full_streams = full.objects.values().filter(|o| o.as_stream().is_ok()).count();
|
|
let filtered_streams = filtered.objects.values().filter(|o| o.as_stream().is_ok()).count();
|
|
|
|
assert!(full_streams > 0, "example.pdf should have streams");
|
|
assert_eq!(filtered_streams, 0, "filter should have removed all streams");
|
|
assert!(
|
|
filtered.objects.len() < full.objects.len(),
|
|
"filtered doc should have fewer objects"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_mem_with_options_filter() {
|
|
// Filter that only keeps dictionary objects
|
|
fn only_dicts(id: (u32, u16), obj: &mut Object) -> Option<((u32, u16), Object)> {
|
|
if obj.as_dict().is_ok() {
|
|
Some((id, obj.clone()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
let buf = std::fs::read("assets/example.pdf").unwrap();
|
|
let filtered = Document::load_mem_with_options(&buf, LoadOptions::with_filter(only_dicts)).unwrap();
|
|
|
|
// All remaining objects should be dictionaries
|
|
for obj in filtered.objects.values() {
|
|
assert!(obj.as_dict().is_ok(), "all objects should be dictionaries after filter");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_strict_false_loads_normally() {
|
|
let doc = Document::load_with_options(
|
|
"assets/example.pdf",
|
|
LoadOptions {
|
|
strict: false,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(doc.version, "1.5");
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_strict_true_loads_valid_pdf() {
|
|
// strict=true should still load a valid, conforming PDF
|
|
let doc = Document::load_with_options(
|
|
"assets/example.pdf",
|
|
LoadOptions {
|
|
strict: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(doc.version, "1.5");
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_password_on_encrypted_pdf() {
|
|
let doc = Document::load_with_options("assets/encrypted.pdf", LoadOptions::with_password("")).unwrap();
|
|
assert!(!doc.is_encrypted());
|
|
assert!(doc.encryption_state.is_some());
|
|
let pages = doc.get_pages();
|
|
assert_eq!(pages.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn load_mem_with_options_password_on_encrypted_pdf() {
|
|
let buf = std::fs::read("assets/encrypted.pdf").unwrap();
|
|
let doc = Document::load_mem_with_options(&buf, LoadOptions::with_password("")).unwrap();
|
|
assert!(!doc.is_encrypted());
|
|
assert!(doc.encryption_state.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_combined_password_and_strict() {
|
|
let opts = LoadOptions {
|
|
password: Some(String::new()),
|
|
strict: true,
|
|
..Default::default()
|
|
};
|
|
let doc = Document::load_with_options("assets/encrypted.pdf", opts).unwrap();
|
|
assert!(!doc.is_encrypted());
|
|
assert!(doc.encryption_state.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn load_with_options_nonexistent_file() {
|
|
let result = Document::load_with_options("nonexistent.pdf", LoadOptions::default());
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn strict_rejects_binary_bytes_on_header_line() {
|
|
// Minimal PDF-like buffer with binary bytes on the header line.
|
|
// Strict mode should reject this with InvalidFileHeader.
|
|
let buf = b"%PDF-1.3 \xb0\x9f\x92\x9c\r%%EOF\r";
|
|
let result = Document::load_mem_with_options(
|
|
buf,
|
|
LoadOptions {
|
|
strict: true,
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert_matches!(result.unwrap_err(), Error::Parse(ParseError::InvalidFileHeader));
|
|
}
|
|
|
|
#[test]
|
|
fn lenient_accepts_binary_bytes_on_header_line() {
|
|
// Same buffer, but lenient (default) mode should parse the header
|
|
// successfully (it will fail later because the rest isn't a real PDF,
|
|
// but the header itself should be accepted).
|
|
let buf = b"%PDF-1.3 \xb0\x9f\x92\x9c\r%%EOF\r";
|
|
let result = Document::load_mem_with_options(buf, LoadOptions::default());
|
|
// The error should NOT be InvalidFileHeader — the header parsed fine.
|
|
if let Err(e) = &result {
|
|
assert!(
|
|
!e.to_string().contains("invalid file header"),
|
|
"lenient mode should accept binary bytes on header line, got: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|