Files
pdfcraft/vendor/lopdf/tests/annotation.rs
Brandon ThomasandClaude Opus 5.5 1449b71a19 Never crash: bound the allocations behind the nightly fuzz runner's out-of-memory deaths
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>
2026-10-04 17:59:25 -04:00

101 lines
3.6 KiB
Rust

use lopdf::{Document, Object, Result, dictionary};
mod utils;
/// One page whose `/Annots` mixes the two legal entry forms: a reference to
/// an annotation dictionary, and the dictionary written directly into the
/// array. Returns the document and the page's id.
fn page_with_mixed_annots(annots_indirect: bool) -> (Document, lopdf::ObjectId) {
let mut doc = Document::with_version("1.7");
let referenced = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Contents" => Object::string_literal("referenced"),
});
let entries = vec![
Object::Reference(referenced),
Object::Dictionary(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Contents" => Object::string_literal("direct"),
}),
];
// `/Annots` is itself allowed to be either an array or a reference to one.
let annots = if annots_indirect {
Object::Reference(doc.add_object(Object::Array(entries)))
} else {
Object::Array(entries)
};
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Annots" => annots,
});
(doc, page_id)
}
fn contents_of(doc: &Document, page_id: lopdf::ObjectId) -> Vec<String> {
doc.get_page_annotations(page_id)
.unwrap()
.iter()
.map(|a| String::from_utf8_lossy(a.get(b"Contents").unwrap().as_str().unwrap()).into_owned())
.collect()
}
/// An entry of `/Annots` may be the annotation dictionary itself rather than
/// a reference to one — ISO 32000-1, 12.5.2 requires an indirect object only
/// for an annotation that carries a `/Popup` or is an `/IRT` target. Direct
/// dictionaries used to be dropped without a word, so a page could report
/// fewer annotations than it has.
#[test]
fn page_annotations_include_direct_dictionaries() {
let (doc, page_id) = page_with_mixed_annots(false);
assert_eq!(contents_of(&doc, page_id), vec!["referenced", "direct"]);
}
/// The same, with `/Annots` written as a reference to the array.
#[test]
fn page_annotations_include_direct_dictionaries_behind_an_indirect_annots() {
let (doc, page_id) = page_with_mixed_annots(true);
assert_eq!(contents_of(&doc, page_id), vec!["referenced", "direct"]);
}
/// A reference that does not resolve costs its own annotation, not the page:
/// the surviving entries still come back.
#[test]
fn page_annotations_skip_a_dangling_reference() {
let mut doc = Document::with_version("1.7");
let good = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Contents" => Object::string_literal("kept"),
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Annots" => vec![
Object::Reference((9999, 0)),
Object::Reference(good),
Object::Null,
],
});
assert_eq!(contents_of(&doc, page_id), vec!["kept"]);
}
/// A page without `/Annots` has no annotations, and that is not an error.
#[test]
fn page_annotations_without_annots_is_empty() {
let mut doc = Document::with_version("1.7");
let page_id = doc.add_object(dictionary! { "Type" => "Page" });
assert!(doc.get_page_annotations(page_id).unwrap().is_empty());
}
#[test]
fn annotation_count() -> Result<()> {
// This test file from the pdfcpu repository,
// https://github.com/pdfcpu/pdfcpu/blob/master/pkg/samples/basic/AnnotationDemo.pdf
let doc = utils::load_document("assets/AnnotationDemo.pdf")?;
assert_eq!(doc.version, "1.7".to_string());
assert_eq!(doc.page_iter().count(), 1);
assert_eq!(doc.get_page_annotations(doc.page_iter().next().unwrap())?.len(), 33);
Ok(())
}