Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528829cb6c | ||
|
|
0a47a57147 | ||
|
|
311ea9d9fa | ||
|
|
92ee85687f | ||
|
|
fdf617409c | ||
|
|
8accfd2b5c | ||
|
|
60c73399fc | ||
|
|
913d41091b | ||
|
|
c191f11007 | ||
|
|
5b0a85b57a | ||
|
|
1ee5d3c2e7 | ||
|
|
2ad08a4e28 | ||
|
|
493fed498e |
+311
-5
@@ -2,11 +2,51 @@
|
||||
|
||||
use crate::types::{ItemType, TextItem};
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::fonts::{resolve_array, resolve_dict};
|
||||
use super::get_number;
|
||||
|
||||
/// Upper bound on the number of form-field nodes visited during a single
|
||||
/// `extract_form_fields` pass. A crafted PDF can chain thousands of distinct
|
||||
/// `/Kids` fields to blow the stack even without an outright reference cycle,
|
||||
/// so we cap total traversal work in addition to detecting cycles.
|
||||
const MAX_FORM_FIELD_NODES: usize = 100_000;
|
||||
|
||||
/// Upper bound on `/Kids` recursion depth. Real AcroForm hierarchies are only
|
||||
/// a few levels deep (fields → child fields → widgets); a crafted PDF can chain
|
||||
/// tens of thousands of distinct fields into a linear `/Kids` list that would
|
||||
/// overflow the stack via depth-first recursion long before the node budget is
|
||||
/// reached. This depth cap bounds the stack independently of total node count.
|
||||
const MAX_FORM_FIELD_DEPTH: usize = 100;
|
||||
|
||||
/// Traversal budget for the AcroForm field walk. Bounds both the number of
|
||||
/// distinct nodes visited *and* the total number of `/Fields`/`/Kids` entries
|
||||
/// examined.
|
||||
///
|
||||
/// Counting `visited` alone is not enough: invalid entries (non-references) and
|
||||
/// duplicate references never grow `visited`, so an oversized array full of them
|
||||
/// would iterate to completion no matter how large. Charging every examined
|
||||
/// entry against the same budget makes it a real cap on traversal work.
|
||||
pub(crate) struct FieldWalkBudget {
|
||||
visited: HashSet<ObjectId>,
|
||||
examined: usize,
|
||||
}
|
||||
|
||||
impl FieldWalkBudget {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
visited: HashSet::new(),
|
||||
examined: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True once the budget is spent; callers must stop iterating and recursing.
|
||||
fn exhausted(&self) -> bool {
|
||||
self.visited.len() >= MAX_FORM_FIELD_NODES || self.examined >= MAX_FORM_FIELD_NODES
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_page_links(doc: &Document, page_id: ObjectId, page_num: u32) -> Vec<TextItem> {
|
||||
let mut links = Vec::new();
|
||||
|
||||
@@ -146,9 +186,12 @@ pub(crate) fn extract_form_fields(
|
||||
Err(_) => return items,
|
||||
};
|
||||
|
||||
// Borrow the array rather than cloning it: a crafted `/Fields` can be huge,
|
||||
// and cloning would pay an O(n) allocation/copy before the budget check
|
||||
// below can stop the work.
|
||||
let fields = match acroform.get(b"Fields") {
|
||||
Ok(obj) => match resolve_array(doc, obj) {
|
||||
Some(arr) => arr.clone(),
|
||||
Some(arr) => arr,
|
||||
None => return items,
|
||||
},
|
||||
Err(_) => return items,
|
||||
@@ -158,7 +201,19 @@ pub(crate) fn extract_form_fields(
|
||||
}
|
||||
let annotation_pages = annotation_page_map(doc, page_map);
|
||||
|
||||
for field_obj in &fields {
|
||||
// Bound the walk so a crafted PDF cannot send us into unbounded recursion
|
||||
// via a `/Kids` cycle, a deep chain, or an oversized array of invalid or
|
||||
// duplicate entries.
|
||||
let mut budget = FieldWalkBudget::new();
|
||||
|
||||
for field_obj in fields {
|
||||
// Stop once the budget is spent so a `/Fields` array wider than the
|
||||
// budget can't burn CPU iterating entries whose walk would no-op. Charge
|
||||
// every entry (including invalid ones) against the budget.
|
||||
if budget.exhausted() {
|
||||
break;
|
||||
}
|
||||
budget.examined += 1;
|
||||
if let Ok(field_ref) = field_obj.as_reference() {
|
||||
walk_form_fields(
|
||||
doc,
|
||||
@@ -168,6 +223,8 @@ pub(crate) fn extract_form_fields(
|
||||
page_map,
|
||||
&annotation_pages,
|
||||
&mut items,
|
||||
&mut budget,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -202,6 +259,7 @@ fn annotation_page_map(
|
||||
}
|
||||
|
||||
/// Recursively walk the form field tree, extracting leaf field values.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn walk_form_fields(
|
||||
doc: &Document,
|
||||
field_id: ObjectId,
|
||||
@@ -210,7 +268,22 @@ pub(crate) fn walk_form_fields(
|
||||
page_map: &HashMap<ObjectId, u32>,
|
||||
annotation_pages: &HashMap<ObjectId, u32>,
|
||||
items: &mut Vec<TextItem>,
|
||||
budget: &mut FieldWalkBudget,
|
||||
depth: usize,
|
||||
) {
|
||||
// Guard against `/Kids` cycles and pathologically large field trees.
|
||||
// Exceeding the depth cap means the chain is too deep to be a legitimate
|
||||
// form (and would overflow the stack); an exhausted budget means the tree is
|
||||
// too large. Both checks run *before* inserting so the visited set can never
|
||||
// grow past the budget.
|
||||
if depth > MAX_FORM_FIELD_DEPTH || budget.exhausted() {
|
||||
return;
|
||||
}
|
||||
// Revisiting an object ID means we hit a `/Kids` cycle.
|
||||
if !budget.visited.insert(field_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let field_dict = match doc.get_dictionary(field_id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
@@ -241,9 +314,19 @@ pub(crate) fn walk_form_fields(
|
||||
|
||||
// Check for /Kids — if present, recurse into children
|
||||
if let Ok(kids_obj) = field_dict.get(b"Kids") {
|
||||
// Iterate the borrowed array directly — cloning a crafted, oversized
|
||||
// `/Kids` would allocate and copy every entry before the budget check
|
||||
// below could stop the work.
|
||||
if let Some(kids) = resolve_array(doc, kids_obj) {
|
||||
let kids = kids.clone();
|
||||
for kid in &kids {
|
||||
for kid in kids {
|
||||
// Stop once the budget is spent so a `/Kids` array wider than the
|
||||
// budget can't burn CPU iterating entries whose walk would no-op.
|
||||
// Charge every entry (including invalid/duplicate ones) against
|
||||
// the budget so this is a true traversal-work cap.
|
||||
if budget.exhausted() {
|
||||
break;
|
||||
}
|
||||
budget.examined += 1;
|
||||
if let Ok(kid_ref) = kid.as_reference() {
|
||||
walk_form_fields(
|
||||
doc,
|
||||
@@ -253,6 +336,8 @@ pub(crate) fn walk_form_fields(
|
||||
page_map,
|
||||
annotation_pages,
|
||||
items,
|
||||
budget,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -411,4 +496,225 @@ mod tests {
|
||||
assert_eq!(items[0].page, 2);
|
||||
assert_eq!(items[0].text, "customer: Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kids_self_cycle_does_not_overflow_stack() {
|
||||
// A crafted AcroForm field that lists itself in `/Kids` must not send
|
||||
// the traversal into unbounded recursion.
|
||||
let mut doc = Document::new();
|
||||
let field_id = doc.new_object_id();
|
||||
doc.set_object(
|
||||
field_id,
|
||||
dictionary! {
|
||||
"FT" => "Tx",
|
||||
"T" => Object::string_literal("loop"),
|
||||
"Kids" => vec![Object::Reference(field_id)],
|
||||
},
|
||||
);
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"AcroForm" => dictionary! {
|
||||
"Fields" => vec![Object::Reference(field_id)],
|
||||
},
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let page_map = HashMap::new();
|
||||
// Completes (rather than overflowing the stack) and yields no items.
|
||||
let items = extract_form_fields(&doc, &page_map);
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kids_mutual_cycle_terminates() {
|
||||
// Two fields that reference each other via `/Kids` form a cycle that
|
||||
// must also terminate.
|
||||
let mut doc = Document::new();
|
||||
let field_a = doc.new_object_id();
|
||||
let field_b = doc.new_object_id();
|
||||
doc.set_object(
|
||||
field_a,
|
||||
dictionary! {
|
||||
"T" => Object::string_literal("a"),
|
||||
"Kids" => vec![Object::Reference(field_b)],
|
||||
},
|
||||
);
|
||||
doc.set_object(
|
||||
field_b,
|
||||
dictionary! {
|
||||
"T" => Object::string_literal("b"),
|
||||
"Kids" => vec![Object::Reference(field_a)],
|
||||
},
|
||||
);
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"AcroForm" => dictionary! {
|
||||
"Fields" => vec![Object::Reference(field_a)],
|
||||
},
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let page_map = HashMap::new();
|
||||
let items = extract_form_fields(&doc, &page_map);
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_acyclic_kids_chain_does_not_overflow_stack() {
|
||||
// A long chain of *distinct* fields (no cycle) must also terminate:
|
||||
// the visited set alone would still recurse to the chain length, so
|
||||
// the depth cap is what prevents a stack overflow here.
|
||||
let mut doc = Document::new();
|
||||
let n = MAX_FORM_FIELD_DEPTH * 500;
|
||||
let ids: Vec<ObjectId> = (0..=n).map(|_| doc.new_object_id()).collect();
|
||||
for i in 0..n {
|
||||
doc.set_object(
|
||||
ids[i],
|
||||
dictionary! {
|
||||
"FT" => "Tx",
|
||||
"Kids" => vec![Object::Reference(ids[i + 1])],
|
||||
},
|
||||
);
|
||||
}
|
||||
// Leaf carries a value; it sits far below the depth cap so it is never
|
||||
// reached, proving traversal stops early rather than crashing.
|
||||
doc.set_object(
|
||||
ids[n],
|
||||
dictionary! {
|
||||
"FT" => "Tx",
|
||||
"T" => Object::string_literal("leaf"),
|
||||
"V" => Object::string_literal("x"),
|
||||
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
|
||||
},
|
||||
);
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"AcroForm" => dictionary! {
|
||||
"Fields" => vec![Object::Reference(ids[0])],
|
||||
},
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let page_map = HashMap::new();
|
||||
let items = extract_form_fields(&doc, &page_map);
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_tree_traversal_stops_at_node_budget() {
|
||||
// A single field with a `/Kids` array wider than the node budget must
|
||||
// stop traversal at the cap rather than growing `visited` (and the work)
|
||||
// without bound. Each processed leaf emits one item, so the item count
|
||||
// is bounded by the budget and reaches right up to it (a couple of
|
||||
// slots go to the root and the boundary node charged against the cap).
|
||||
let mut doc = Document::new();
|
||||
let fanout = MAX_FORM_FIELD_NODES + 50;
|
||||
let leaf_ids: Vec<ObjectId> = (0..fanout).map(|_| doc.new_object_id()).collect();
|
||||
for &leaf in &leaf_ids {
|
||||
doc.set_object(
|
||||
leaf,
|
||||
dictionary! {
|
||||
"FT" => "Tx",
|
||||
"V" => Object::string_literal("v"),
|
||||
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
|
||||
},
|
||||
);
|
||||
}
|
||||
let kids: Vec<Object> = leaf_ids.iter().map(|&id| Object::Reference(id)).collect();
|
||||
let root_id = doc.add_object(dictionary! {
|
||||
"T" => Object::string_literal("root"),
|
||||
"Kids" => kids,
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"AcroForm" => dictionary! {
|
||||
"Fields" => vec![Object::Reference(root_id)],
|
||||
},
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let page_map = HashMap::new();
|
||||
let items = extract_form_fields(&doc, &page_map);
|
||||
// Extraction stops at the budget: bounded above by the cap, and it gets
|
||||
// right up to it (allowing a small delta for the root/boundary nodes
|
||||
// charged against the budget).
|
||||
assert!(items.len() <= MAX_FORM_FIELD_NODES);
|
||||
assert!(items.len() >= MAX_FORM_FIELD_NODES - 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_top_level_fields_stop_at_node_budget() {
|
||||
// A top-level `/Fields` array wider than the budget must also stop at
|
||||
// the cap: the item count is bounded by the budget and reaches right up
|
||||
// to it.
|
||||
let mut doc = Document::new();
|
||||
let fanout = MAX_FORM_FIELD_NODES + 50;
|
||||
let leaf_ids: Vec<ObjectId> = (0..fanout).map(|_| doc.new_object_id()).collect();
|
||||
for &leaf in &leaf_ids {
|
||||
doc.set_object(
|
||||
leaf,
|
||||
dictionary! {
|
||||
"FT" => "Tx",
|
||||
"V" => Object::string_literal("v"),
|
||||
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
|
||||
},
|
||||
);
|
||||
}
|
||||
let fields: Vec<Object> = leaf_ids.iter().map(|&id| Object::Reference(id)).collect();
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"AcroForm" => dictionary! {
|
||||
"Fields" => fields,
|
||||
},
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let page_map = HashMap::new();
|
||||
let items = extract_form_fields(&doc, &page_map);
|
||||
assert!(items.len() <= MAX_FORM_FIELD_NODES);
|
||||
assert!(items.len() >= MAX_FORM_FIELD_NODES - 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_invalid_kids_entries_stop_at_budget() {
|
||||
// Duplicate references and non-reference junk never grow `visited`, so
|
||||
// without charging examined entries against the budget an oversized
|
||||
// array of them would iterate to completion. The walk must still
|
||||
// terminate and extract the single real leaf exactly once.
|
||||
let mut doc = Document::new();
|
||||
let leaf_id = doc.new_object_id();
|
||||
doc.set_object(
|
||||
leaf_id,
|
||||
dictionary! {
|
||||
"FT" => "Tx",
|
||||
"V" => Object::string_literal("v"),
|
||||
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
|
||||
},
|
||||
);
|
||||
// A `/Kids` array far wider than the budget: half duplicate references
|
||||
// to the same leaf, half invalid (null) entries.
|
||||
let mut kids: Vec<Object> = Vec::new();
|
||||
for i in 0..(MAX_FORM_FIELD_NODES * 2) {
|
||||
if i % 2 == 0 {
|
||||
kids.push(Object::Reference(leaf_id));
|
||||
} else {
|
||||
kids.push(Object::Null);
|
||||
}
|
||||
}
|
||||
let root_id = doc.add_object(dictionary! {
|
||||
"T" => Object::string_literal("root"),
|
||||
"Kids" => kids,
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"AcroForm" => dictionary! {
|
||||
"Fields" => vec![Object::Reference(root_id)],
|
||||
},
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let page_map = HashMap::new();
|
||||
let items = extract_form_fields(&doc, &page_map);
|
||||
assert_eq!(items.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+700
-38
@@ -9,7 +9,7 @@
|
||||
use log::debug;
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// ─── Standard structure types ────────────────────────────────────────
|
||||
|
||||
@@ -252,10 +252,28 @@ impl StructTree {
|
||||
let role_map = parse_role_map(doc, struct_root);
|
||||
debug!("structure tree: {} role map entries", role_map.len());
|
||||
|
||||
// Seed the cycle guard with the struct-root's own object id so a `/K`
|
||||
// that points back at the root is treated as a cycle, and bound total
|
||||
// node materialization with a global budget.
|
||||
let mut walk = StructWalk::new();
|
||||
if let Ok(root_id) = struct_root_obj.as_reference() {
|
||||
walk.active.insert(root_id);
|
||||
}
|
||||
|
||||
// Parse child elements from /K
|
||||
let children = parse_kids(doc, struct_root, &role_map, None, 0);
|
||||
let children = parse_kids(doc, struct_root, &role_map, None, 0, &mut walk);
|
||||
debug!("structure tree: {} top-level elements", children.len());
|
||||
|
||||
if walk.truncated {
|
||||
log::warn!(
|
||||
"structure tree parsing was truncated (node budget of \
|
||||
{MAX_STRUCT_NODES} or traversal budget of {MAX_STRUCT_WORK} \
|
||||
reached, a `/K` reference cycle, or the max nesting depth of \
|
||||
{MAX_DEPTH}); tagged roles/tables may be incomplete (likely a \
|
||||
very large or malformed tagged PDF)"
|
||||
);
|
||||
}
|
||||
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -479,6 +497,123 @@ fn parse_role_map(doc: &Document, struct_root: &lopdf::Dictionary) -> HashMap<St
|
||||
/// malformed PDFs).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Global cap on the number of structure-tree nodes materialized in a single
|
||||
/// parse. Real tagged trees are far smaller; a crafted PDF can alias one struct
|
||||
/// element into its own `/K` (e.g. `/K [n 0 R n 0 R]`) so the tree branches
|
||||
/// exponentially (2^depth) before the depth cap is reached, exhausting memory.
|
||||
/// This budget bounds total work and allocation regardless of tree shape.
|
||||
const MAX_STRUCT_NODES: usize = 500_000;
|
||||
|
||||
/// Cap on the number of `/K` items *examined* during a single parse, regardless
|
||||
/// of whether they materialize anything. Bounds CPU for crafted wide `/K` arrays
|
||||
/// of non-materializing entries (unsupported value types, `/OBJR` dicts, cycle
|
||||
/// back-edges) that would otherwise be scanned in full without ever touching the
|
||||
/// node budget. Kept well above the node budget so it never truncates content
|
||||
/// that already fits within `MAX_STRUCT_NODES`.
|
||||
const MAX_STRUCT_WORK: usize = 2_000_000;
|
||||
|
||||
/// Traversal state shared across the recursive structure-tree parse.
|
||||
///
|
||||
/// `budget` is a global allowance charged once per materialized item — each
|
||||
/// struct-element node and each marked-content reference — so total work is
|
||||
/// bounded even for aliased/DAG-shaped `/K` graphs of distinct objects or a
|
||||
/// single element with a very wide `/K` array. `active` holds the object IDs
|
||||
/// currently on the depth-first path so a struct element that references itself
|
||||
/// (or an ancestor) is not expanded into an unbounded/exponential subtree.
|
||||
/// `budget` bounds *materialization* (nodes + content refs). `work` separately
|
||||
/// bounds *traversal* — every `/K` item examined is charged against it, even
|
||||
/// ones that materialize nothing (unsupported values, `/OBJR`, cycle back-edges)
|
||||
/// — so a wide malformed array cannot force an unbounded scan, and those skipped
|
||||
/// items don't drain the materialization budget and truncate real content.
|
||||
/// `truncated` records whether any parse work was skipped — the budget was
|
||||
/// exhausted, a `/K` reference cycle was broken, or the depth cap was hit — so
|
||||
/// the caller can log it once rather than per skipped item. `stalled` is set
|
||||
/// when an atomic multi-unit reservation could not fit in the remaining budget;
|
||||
/// it makes [`exhausted`](Self::exhausted) report done so a wide `/K` array is
|
||||
/// not scanned to the end once no further leaf can be materialized.
|
||||
struct StructWalk {
|
||||
budget: usize,
|
||||
work: usize,
|
||||
active: HashSet<ObjectId>,
|
||||
truncated: bool,
|
||||
stalled: bool,
|
||||
}
|
||||
|
||||
impl StructWalk {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
budget: MAX_STRUCT_NODES,
|
||||
work: MAX_STRUCT_WORK,
|
||||
active: HashSet::new(),
|
||||
truncated: false,
|
||||
stalled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge one unit of traversal work for an examined `/K` item, whether or
|
||||
/// not it materializes anything. Returns `false` (flagging truncation) once
|
||||
/// the traversal budget is spent, so an enclosing loop stops instead of
|
||||
/// scanning the rest of a wide array of non-materializing entries.
|
||||
fn spend_work(&mut self) -> bool {
|
||||
if self.work == 0 {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
self.work -= 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Record that some parse work was skipped for a non-budget reason (a `/K`
|
||||
/// reference cycle or the depth cap), so the one-shot truncation warning
|
||||
/// also covers malformed/over-deep trees, not just budget exhaustion.
|
||||
fn note_skipped(&mut self) {
|
||||
self.truncated = true;
|
||||
}
|
||||
|
||||
/// Charge one unit against the budget for a materialized item (a struct
|
||||
/// element node or a marked-content reference). Returns `false` — without
|
||||
/// underflowing — once the budget is exhausted, so callers skip the item.
|
||||
fn charge(&mut self) -> bool {
|
||||
if self.budget == 0 {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
self.budget -= 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Atomically charge `n` units for a single item that materializes several
|
||||
/// budget-counted parts at once (a leaf wrapper node *plus* its content
|
||||
/// reference). Charges nothing when fewer than `n` units remain — so a
|
||||
/// partial reservation never wastes capacity — and marks the walk `stalled`
|
||||
/// so the enclosing loop stops instead of scanning the rest of a wide `/K`
|
||||
/// array that can no longer fit any leaf.
|
||||
fn charge_n(&mut self, n: usize) -> bool {
|
||||
if self.budget < n {
|
||||
self.truncated = true;
|
||||
self.stalled = true;
|
||||
return false;
|
||||
}
|
||||
self.budget -= n;
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether traversal should stop: the budget is spent, or a multi-unit
|
||||
/// reservation could not fit (`stalled`) so no further leaf will materialize.
|
||||
/// Use this at the guards that break/return to skip remaining items; it
|
||||
/// records that truncation occurred (a guard only fires while an item is
|
||||
/// still pending), so callers that drop work without going through
|
||||
/// [`charge`](Self::charge) still flag the truncation for logging.
|
||||
fn exhausted(&mut self) -> bool {
|
||||
if self.budget == 0 || self.stalled {
|
||||
self.truncated = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse child elements from a `/K` entry.
|
||||
fn parse_kids(
|
||||
doc: &Document,
|
||||
@@ -486,8 +621,13 @@ fn parse_kids(
|
||||
role_map: &HashMap<String, String>,
|
||||
inherited_page: Option<ObjectId>,
|
||||
depth: usize,
|
||||
walk: &mut StructWalk,
|
||||
) -> Vec<StructElement> {
|
||||
if depth >= MAX_DEPTH {
|
||||
walk.note_skipped();
|
||||
return Vec::new();
|
||||
}
|
||||
if walk.exhausted() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -498,22 +638,59 @@ fn parse_kids(
|
||||
// /Pg on this element (inherited by children)
|
||||
let page_id = get_page_ref(doc, dict).or(inherited_page);
|
||||
|
||||
let mut children = Vec::new();
|
||||
match k_obj {
|
||||
Object::Array(arr) => {
|
||||
let mut children = Vec::new();
|
||||
for item in arr {
|
||||
let resolved = resolve_obj(doc, item);
|
||||
parse_kid(doc, resolved, role_map, page_id, depth, &mut children);
|
||||
if walk.exhausted() || !walk.spend_work() {
|
||||
break;
|
||||
}
|
||||
process_kid_item(doc, item, role_map, page_id, depth, &mut children, walk);
|
||||
}
|
||||
children
|
||||
}
|
||||
other => {
|
||||
let resolved = resolve_obj(doc, other);
|
||||
let mut children = Vec::new();
|
||||
parse_kid(doc, resolved, role_map, page_id, depth, &mut children);
|
||||
children
|
||||
process_kid_item(doc, other, role_map, page_id, depth, &mut children, walk);
|
||||
}
|
||||
}
|
||||
children
|
||||
}
|
||||
|
||||
/// Resolve one `/K` array item (following at most one level of indirection),
|
||||
/// guarding against reference cycles and the global node budget, then dispatch
|
||||
/// it via [`parse_kid`].
|
||||
fn process_kid_item(
|
||||
doc: &Document,
|
||||
item: &Object,
|
||||
role_map: &HashMap<String, String>,
|
||||
inherited_page: Option<ObjectId>,
|
||||
depth: usize,
|
||||
out: &mut Vec<StructElement>,
|
||||
walk: &mut StructWalk,
|
||||
) {
|
||||
if walk.exhausted() {
|
||||
return;
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
walk.note_skipped();
|
||||
return;
|
||||
}
|
||||
// If this child is an indirect reference, track its id on the active path so
|
||||
// a self/ancestor reference is not expanded into an exponential subtree.
|
||||
let ref_id = match item {
|
||||
Object::Reference(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = ref_id {
|
||||
if !walk.active.insert(id) {
|
||||
walk.note_skipped();
|
||||
return; // cycle: this object is already on the current path
|
||||
}
|
||||
}
|
||||
let resolved = resolve_obj(doc, item);
|
||||
parse_kid(doc, resolved, role_map, inherited_page, depth, out, walk);
|
||||
if let Some(id) = ref_id {
|
||||
walk.active.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single child (either a struct element dict or an MCID integer).
|
||||
@@ -524,10 +701,16 @@ fn parse_kid(
|
||||
inherited_page: Option<ObjectId>,
|
||||
depth: usize,
|
||||
out: &mut Vec<StructElement>,
|
||||
walk: &mut StructWalk,
|
||||
) {
|
||||
match obj {
|
||||
// Direct MCID integer — create a leaf wrapper
|
||||
Object::Integer(mcid) => {
|
||||
// A wrapper node plus its content reference — two items — reserved
|
||||
// atomically so we never consume one unit without emitting both.
|
||||
if !walk.charge_n(2) {
|
||||
return;
|
||||
}
|
||||
// This is a bare MCID at the struct-element level.
|
||||
// We attach it to the parent element, so we create a wrapper struct element.
|
||||
// Actually, bare MCIDs inside /K are content refs for the parent,
|
||||
@@ -546,11 +729,11 @@ fn parse_kid(
|
||||
});
|
||||
}
|
||||
Object::Dictionary(d) => {
|
||||
parse_struct_element_dict(doc, d, role_map, inherited_page, depth, out);
|
||||
parse_struct_element_dict(doc, d, role_map, inherited_page, depth, out, walk);
|
||||
}
|
||||
Object::Stream(s) => {
|
||||
// Some PDFs wrap struct elements in streams (rare)
|
||||
parse_struct_element_dict(doc, &s.dict, role_map, inherited_page, depth, out);
|
||||
parse_struct_element_dict(doc, &s.dict, role_map, inherited_page, depth, out, walk);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -565,13 +748,23 @@ fn parse_struct_element_dict(
|
||||
inherited_page: Option<ObjectId>,
|
||||
depth: usize,
|
||||
out: &mut Vec<StructElement>,
|
||||
walk: &mut StructWalk,
|
||||
) {
|
||||
if depth >= MAX_DEPTH {
|
||||
walk.note_skipped();
|
||||
return;
|
||||
}
|
||||
// Check if this is a marked-content reference dict (has /Type /MCR)
|
||||
|
||||
// A marked-content reference dict materializes a wrapper node + one content
|
||||
// reference (two items). Reserve both atomically *before* the node charge so
|
||||
// we never consume a unit without emitting the reference — which would also
|
||||
// deny that unit to a later element that would have fit. This matches the
|
||||
// bare-MCID path.
|
||||
if is_mcr_dict(dict) {
|
||||
if let Ok(Object::Integer(mcid)) = dict.get(b"MCID") {
|
||||
if !walk.charge_n(2) {
|
||||
return;
|
||||
}
|
||||
let page_id = get_page_ref(doc, dict).or(inherited_page);
|
||||
out.push(StructElement {
|
||||
role: StructRole::Span,
|
||||
@@ -588,12 +781,15 @@ fn parse_struct_element_dict(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an object reference dict (has /Type /OBJR) — skip these
|
||||
// Skip object-reference dicts (`/Type /OBJR`) — they materialize no node, so
|
||||
// recognize and return *before* charging the budget (otherwise a document
|
||||
// full of OBJRs would drain the shared budget and truncate real content).
|
||||
if is_objr_dict(dict) {
|
||||
return;
|
||||
}
|
||||
|
||||
// It's a struct element — parse its /S (structure type)
|
||||
// It's a struct element — parse its /S (structure type). A dict without a
|
||||
// valid /S also materializes nothing, so validate before charging.
|
||||
let role_name = match dict.get(b"S") {
|
||||
Ok(s_obj) => {
|
||||
let resolved = resolve_obj(doc, s_obj);
|
||||
@@ -605,6 +801,12 @@ fn parse_struct_element_dict(
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Charge the node only now that we know it will materialize (bounds
|
||||
// aliased/DAG-shaped `/K` graphs the per-path cycle guard alone cannot stop).
|
||||
if !walk.charge() {
|
||||
return;
|
||||
}
|
||||
|
||||
let role = StructRole::from_name_with_role_map(&role_name, role_map);
|
||||
let page_id = get_page_ref(doc, dict).or(inherited_page);
|
||||
|
||||
@@ -621,51 +823,73 @@ fn parse_struct_element_dict(
|
||||
let k_resolved = resolve_obj(doc, k_obj);
|
||||
match k_resolved {
|
||||
Object::Integer(mcid) => {
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id,
|
||||
});
|
||||
if walk.charge() {
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Object::Array(arr) => {
|
||||
for item in arr {
|
||||
if walk.exhausted() || !walk.spend_work() {
|
||||
break;
|
||||
}
|
||||
// Only content-ref items (bare MCIDs / MCR dicts) are charged
|
||||
// here — those are the unbounded allocations. Structural
|
||||
// children are charged once at their own node entry in the
|
||||
// recursive call, so charging them here too would double-count
|
||||
// and drain the budget ~2× faster than the per-node semantics.
|
||||
let ref_id = match item {
|
||||
Object::Reference(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let resolved = resolve_obj(doc, item);
|
||||
match resolved {
|
||||
Object::Integer(mcid) => {
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id,
|
||||
});
|
||||
if walk.charge() {
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Object::Dictionary(d) => {
|
||||
if is_mcr_dict(d) {
|
||||
if let Ok(Object::Integer(mcid)) = d.get(b"MCID") {
|
||||
let pg = get_page_ref(doc, d).or(page_id);
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id: pg,
|
||||
});
|
||||
if walk.charge() {
|
||||
let pg = get_page_ref(doc, d).or(page_id);
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id: pg,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if is_objr_dict(d) {
|
||||
// Skip object references
|
||||
} else {
|
||||
parse_struct_element_dict(
|
||||
recurse_struct_child(
|
||||
doc,
|
||||
ref_id,
|
||||
d,
|
||||
role_map,
|
||||
page_id,
|
||||
depth + 1,
|
||||
depth,
|
||||
&mut children,
|
||||
walk,
|
||||
);
|
||||
}
|
||||
}
|
||||
Object::Stream(s) => {
|
||||
parse_struct_element_dict(
|
||||
recurse_struct_child(
|
||||
doc,
|
||||
ref_id,
|
||||
&s.dict,
|
||||
role_map,
|
||||
page_id,
|
||||
depth + 1,
|
||||
depth,
|
||||
&mut children,
|
||||
walk,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
@@ -675,14 +899,29 @@ fn parse_struct_element_dict(
|
||||
Object::Dictionary(d) => {
|
||||
if is_mcr_dict(d) {
|
||||
if let Ok(Object::Integer(mcid)) = d.get(b"MCID") {
|
||||
let pg = get_page_ref(doc, d).or(page_id);
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id: pg,
|
||||
});
|
||||
if walk.charge() {
|
||||
let pg = get_page_ref(doc, d).or(page_id);
|
||||
content_refs.push(MarkedContentRef {
|
||||
mcid: *mcid,
|
||||
page_id: pg,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parse_struct_element_dict(doc, d, role_map, page_id, depth + 1, &mut children);
|
||||
let ref_id = match k_obj {
|
||||
Object::Reference(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
recurse_struct_child(
|
||||
doc,
|
||||
ref_id,
|
||||
d,
|
||||
role_map,
|
||||
page_id,
|
||||
depth,
|
||||
&mut children,
|
||||
walk,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -699,6 +938,37 @@ fn parse_struct_element_dict(
|
||||
});
|
||||
}
|
||||
|
||||
/// Recurse into a child struct-element dictionary, guarding against reference
|
||||
/// cycles (via the active-path object-id set) and the global node budget.
|
||||
///
|
||||
/// `ref_id` is the object id of the child when it was reached through an
|
||||
/// indirect reference (`None` for an inline dictionary, which cannot alias).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn recurse_struct_child(
|
||||
doc: &Document,
|
||||
ref_id: Option<ObjectId>,
|
||||
dict: &lopdf::Dictionary,
|
||||
role_map: &HashMap<String, String>,
|
||||
inherited_page: Option<ObjectId>,
|
||||
depth: usize,
|
||||
out: &mut Vec<StructElement>,
|
||||
walk: &mut StructWalk,
|
||||
) {
|
||||
if walk.exhausted() {
|
||||
return;
|
||||
}
|
||||
if let Some(id) = ref_id {
|
||||
if !walk.active.insert(id) {
|
||||
walk.note_skipped();
|
||||
return; // cycle: this object is already on the current path
|
||||
}
|
||||
}
|
||||
parse_struct_element_dict(doc, dict, role_map, inherited_page, depth + 1, out, walk);
|
||||
if let Some(id) = ref_id {
|
||||
walk.active.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if dict has `/Type /MCR`.
|
||||
fn is_mcr_dict(dict: &lopdf::Dictionary) -> bool {
|
||||
dict.get(b"Type")
|
||||
@@ -901,6 +1171,7 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lopdf::dictionary;
|
||||
|
||||
#[test]
|
||||
fn non_heading_content_roles() {
|
||||
@@ -1231,4 +1502,395 @@ mod tests {
|
||||
let role_map = tree.mcid_to_roles(&page_ids);
|
||||
assert!(!role_map.is_empty(), "Should have MCID→role mappings");
|
||||
}
|
||||
|
||||
fn count_nodes(elems: &[StructElement]) -> usize {
|
||||
elems.iter().map(|e| 1 + count_nodes(&e.children)).sum()
|
||||
}
|
||||
|
||||
/// Wrap already-created struct elements under a `/StructTreeRoot` and
|
||||
/// `/Catalog`, returning a document ready for [`StructTree::from_doc`].
|
||||
/// `root_kid` is the top-level element the root's `/K` points at.
|
||||
fn finalize_tagged_doc(mut doc: Document, root_kid: ObjectId) -> Document {
|
||||
let root_id = doc.add_object(dictionary! {
|
||||
"Type" => "StructTreeRoot",
|
||||
"K" => vec![Object::Reference(root_kid)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"StructTreeRoot" => Object::Reference(root_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_tree_self_alias_kids_terminates() {
|
||||
// A struct element that lists itself twice in `/K` (`/K [n 0 R n 0 R]`)
|
||||
// must not expand into an exponential tree.
|
||||
let mut doc = Document::new();
|
||||
let elem = doc.new_object_id();
|
||||
doc.set_object(
|
||||
elem,
|
||||
dictionary! {
|
||||
"Type" => "StructElem",
|
||||
"S" => "Div",
|
||||
"K" => vec![Object::Reference(elem), Object::Reference(elem)],
|
||||
},
|
||||
);
|
||||
let doc = finalize_tagged_doc(doc, elem);
|
||||
|
||||
let tree = StructTree::from_doc(&doc).expect("tree should parse");
|
||||
let n = count_nodes(&tree.children);
|
||||
assert!(
|
||||
n < 10,
|
||||
"self-alias must not explode; materialized {n} nodes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_tree_mutual_alias_kids_terminates() {
|
||||
// A → B → A cycle via `/K` must terminate.
|
||||
let mut doc = Document::new();
|
||||
let a = doc.new_object_id();
|
||||
let b = doc.new_object_id();
|
||||
doc.set_object(
|
||||
a,
|
||||
dictionary! {
|
||||
"Type" => "StructElem",
|
||||
"S" => "Div",
|
||||
"K" => vec![Object::Reference(b), Object::Reference(b)],
|
||||
},
|
||||
);
|
||||
doc.set_object(
|
||||
b,
|
||||
dictionary! {
|
||||
"Type" => "StructElem",
|
||||
"S" => "Div",
|
||||
"K" => vec![Object::Reference(a), Object::Reference(a)],
|
||||
},
|
||||
);
|
||||
let doc = finalize_tagged_doc(doc, a);
|
||||
|
||||
let tree = StructTree::from_doc(&doc).expect("tree should parse");
|
||||
let n = count_nodes(&tree.children);
|
||||
assert!(
|
||||
n < 100,
|
||||
"mutual alias must terminate small; materialized {n} nodes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_tree_aliased_dag_respects_node_budget() {
|
||||
// Distinct elements, each aliased twice in the next level's `/K`, form a
|
||||
// DAG that would expand to 2^depth nodes (the per-path cycle guard does
|
||||
// not catch this since every id is on the path only once). The global
|
||||
// node budget must cap total materialization.
|
||||
let mut doc = Document::new();
|
||||
let levels = 22; // 2^22 ≈ 4.2M unbounded, well past the budget
|
||||
let ids: Vec<ObjectId> = (0..=levels).map(|_| doc.new_object_id()).collect();
|
||||
for i in 0..levels {
|
||||
doc.set_object(
|
||||
ids[i],
|
||||
dictionary! {
|
||||
"Type" => "StructElem",
|
||||
"S" => "Div",
|
||||
"K" => vec![Object::Reference(ids[i + 1]), Object::Reference(ids[i + 1])],
|
||||
},
|
||||
);
|
||||
}
|
||||
doc.set_object(
|
||||
ids[levels],
|
||||
dictionary! { "Type" => "StructElem", "S" => "P" },
|
||||
);
|
||||
let root_id = doc.add_object(dictionary! {
|
||||
"Type" => "StructTreeRoot",
|
||||
"K" => vec![Object::Reference(ids[0])],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"StructTreeRoot" => Object::Reference(root_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let tree = StructTree::from_doc(&doc).expect("tree should parse");
|
||||
let n = count_nodes(&tree.children);
|
||||
assert!(
|
||||
n <= MAX_STRUCT_NODES,
|
||||
"node count {n} exceeded budget {MAX_STRUCT_NODES}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_tree_wide_mcid_array_respects_budget() {
|
||||
// A single struct element with a `/K` array of bare MCIDs wider than the
|
||||
// budget must not allocate `content_refs` without bound — each array item
|
||||
// is charged, so materialized marked-content refs stay within the budget.
|
||||
let mut doc = Document::new();
|
||||
let elem = doc.new_object_id();
|
||||
let kids: Vec<Object> = (0..(MAX_STRUCT_NODES as i64 + 100))
|
||||
.map(Object::Integer)
|
||||
.collect();
|
||||
doc.set_object(
|
||||
elem,
|
||||
dictionary! {
|
||||
"Type" => "StructElem",
|
||||
"S" => "P",
|
||||
"K" => kids,
|
||||
},
|
||||
);
|
||||
let doc = finalize_tagged_doc(doc, elem);
|
||||
|
||||
let tree = StructTree::from_doc(&doc).expect("tree should parse");
|
||||
assert!(
|
||||
tree.mcid_count() <= MAX_STRUCT_NODES,
|
||||
"content_refs unbounded: {} > {MAX_STRUCT_NODES}",
|
||||
tree.mcid_count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_charge_flags_truncation_once_exhausted() {
|
||||
let mut walk = StructWalk::new();
|
||||
walk.budget = 1;
|
||||
assert!(walk.charge(), "should spend the last unit");
|
||||
assert!(!walk.truncated, "not truncated while budget remained");
|
||||
assert!(!walk.charge(), "budget exhausted");
|
||||
assert!(walk.truncated, "exhaustion must set the truncation flag");
|
||||
// Stays exhausted/flagged on subsequent calls.
|
||||
assert!(!walk.charge());
|
||||
assert!(walk.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_flags_truncation_after_budget_spent_by_charge() {
|
||||
// The dominant truncation path: the budget is driven to 0 by a
|
||||
// successful `charge()` (which does not set the flag), and remaining
|
||||
// items are then dropped by an `exhausted()` guard — which must flag it.
|
||||
let mut walk = StructWalk::new();
|
||||
walk.budget = 1;
|
||||
assert!(walk.charge());
|
||||
assert!(
|
||||
!walk.truncated,
|
||||
"spending the last unit is not truncation yet"
|
||||
);
|
||||
assert!(walk.exhausted(), "budget is now spent");
|
||||
assert!(
|
||||
walk.truncated,
|
||||
"the guard that skips work must flag truncation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_kids_array_flags_truncation_via_parser() {
|
||||
// Reproduce the reviewer's scenario through the real parser: a `/K`
|
||||
// array wider than the budget drives the budget to 0 via `charge()`,
|
||||
// then the loop guard drops the rest — the truncation flag must be set
|
||||
// (so `from_doc` logs it) rather than staying silently false.
|
||||
let mut doc = Document::new();
|
||||
let elem = doc.new_object_id();
|
||||
let kids: Vec<Object> = (0..20i64).map(Object::Integer).collect();
|
||||
doc.set_object(
|
||||
elem,
|
||||
dictionary! { "Type" => "StructElem", "S" => "P", "K" => kids },
|
||||
);
|
||||
let dict = doc.get_dictionary(elem).unwrap().clone();
|
||||
|
||||
let mut walk = StructWalk::new();
|
||||
walk.budget = 5; // smaller than the 20-item `/K` array
|
||||
let role_map = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
parse_struct_element_dict(&doc, &dict, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert!(
|
||||
walk.truncated,
|
||||
"a `/K` array wider than the budget must flag truncation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_skip_flags_truncation() {
|
||||
// A `/K` reference cycle is dropped rather than expanded; that skip must
|
||||
// still flag truncation so the one-shot warning fires for malformed
|
||||
// trees, not only for budget exhaustion.
|
||||
let mut doc = Document::new();
|
||||
let elem = doc.new_object_id();
|
||||
doc.set_object(
|
||||
elem,
|
||||
dictionary! {
|
||||
"Type" => "StructElem",
|
||||
"S" => "Div",
|
||||
"K" => vec![Object::Reference(elem), Object::Reference(elem)],
|
||||
},
|
||||
);
|
||||
let dict = doc.get_dictionary(elem).unwrap().clone();
|
||||
|
||||
let mut walk = StructWalk::new();
|
||||
walk.active.insert(elem); // simulate `elem` already on the DFS path
|
||||
let role_map = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
parse_struct_element_dict(&doc, &dict, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert!(
|
||||
walk.truncated,
|
||||
"a cycle-skipped `/K` child must flag truncation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_mcid_charges_node_and_reference() {
|
||||
// A bare MCID `/K` child becomes a wrapper node carrying one content
|
||||
// reference — two materialized items — so it must charge two budget
|
||||
// units, not one.
|
||||
let doc = Document::new();
|
||||
let obj = Object::Integer(7);
|
||||
let role_map = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
let mut walk = StructWalk::new();
|
||||
let before = walk.budget;
|
||||
parse_kid(&doc, &obj, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"bare MCID should materialize one wrapper node"
|
||||
);
|
||||
assert_eq!(
|
||||
before - walk.budget,
|
||||
2,
|
||||
"bare MCID must charge for both the node and its content reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcr_dict_charges_node_and_reference() {
|
||||
// A top-level MCR `/K` dict materializes the same wrapper node + content
|
||||
// reference as a bare MCID, so it must charge the same two budget units
|
||||
// (not one), keeping the per-item budgeting uniform.
|
||||
let doc = Document::new();
|
||||
let obj = Object::Dictionary(dictionary! { "Type" => "MCR", "MCID" => 3 });
|
||||
let role_map = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
let mut walk = StructWalk::new();
|
||||
let before = walk.budget;
|
||||
parse_kid(&doc, &obj, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert_eq!(out.len(), 1, "MCR dict should materialize one wrapper node");
|
||||
assert_eq!(
|
||||
before - walk.budget,
|
||||
2,
|
||||
"MCR dict must charge for both the node and its content reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_wrappers_reserve_both_units_atomically() {
|
||||
// With only one unit left, a two-item leaf wrapper (bare MCID or MCR
|
||||
// dict) must consume nothing and flag truncation, leaving the unit for a
|
||||
// later single-item element instead of half-charging.
|
||||
let doc = Document::new();
|
||||
let role_map = HashMap::new();
|
||||
|
||||
// Bare MCID via parse_kid.
|
||||
let mut walk = StructWalk::new();
|
||||
walk.budget = 1;
|
||||
let mut out = Vec::new();
|
||||
parse_kid(
|
||||
&doc,
|
||||
&Object::Integer(5),
|
||||
&role_map,
|
||||
None,
|
||||
0,
|
||||
&mut out,
|
||||
&mut walk,
|
||||
);
|
||||
assert!(out.is_empty(), "bare MCID must not partially materialize");
|
||||
assert_eq!(walk.budget, 1, "the leftover unit must be preserved");
|
||||
assert!(walk.truncated);
|
||||
|
||||
// MCR dict via parse_struct_element_dict.
|
||||
let mcr = dictionary! { "Type" => "MCR", "MCID" => 1 };
|
||||
let mut walk = StructWalk::new();
|
||||
walk.budget = 1;
|
||||
let mut out = Vec::new();
|
||||
parse_struct_element_dict(&doc, &mcr, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert!(out.is_empty(), "MCR dict must not partially materialize");
|
||||
assert_eq!(walk.budget, 1, "the leftover unit must be preserved");
|
||||
assert!(walk.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insufficient_reservation_stops_the_scan() {
|
||||
// A one-unit budget is not "exhausted" for a one-unit item, but once a
|
||||
// two-unit leaf reservation fails, the walk is stalled so enclosing `/K`
|
||||
// loops stop instead of scanning the rest of a wide array.
|
||||
let mut walk = StructWalk::new();
|
||||
walk.budget = 1;
|
||||
assert!(
|
||||
!walk.exhausted(),
|
||||
"one unit left must still allow a one-unit item"
|
||||
);
|
||||
assert!(!walk.charge_n(2), "cannot reserve two units from one");
|
||||
assert!(
|
||||
walk.exhausted(),
|
||||
"an insufficient reservation must stop the loop"
|
||||
);
|
||||
assert!(walk.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_budget_bounds_examined_items() {
|
||||
let mut walk = StructWalk::new();
|
||||
walk.work = 2;
|
||||
assert!(walk.spend_work());
|
||||
assert!(walk.spend_work());
|
||||
assert!(!walk.spend_work(), "traversal budget exhausted");
|
||||
assert!(walk.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_unsupported_kids_stop_at_work_budget() {
|
||||
// A wide `/K` array of unsupported values (nulls) materializes nothing;
|
||||
// it must stop at the traversal budget instead of scanning every entry.
|
||||
let mut doc = Document::new();
|
||||
let elem = doc.new_object_id();
|
||||
let kids: Vec<Object> = (0..1000).map(|_| Object::Null).collect();
|
||||
doc.set_object(
|
||||
elem,
|
||||
dictionary! { "Type" => "StructElem", "S" => "P", "K" => kids },
|
||||
);
|
||||
let dict = doc.get_dictionary(elem).unwrap().clone();
|
||||
|
||||
let mut walk = StructWalk::new();
|
||||
walk.work = 10; // far smaller than the 1000-entry array
|
||||
let role_map = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
parse_struct_element_dict(&doc, &dict, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert!(
|
||||
walk.truncated,
|
||||
"a wide unsupported `/K` array must hit the work budget"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_materializing_dicts_do_not_charge_node_budget() {
|
||||
let doc = Document::new();
|
||||
let role_map = HashMap::new();
|
||||
|
||||
// OBJR dict: materializes no node, so it must not spend the node budget.
|
||||
let objr = dictionary! { "Type" => "OBJR" };
|
||||
let mut walk = StructWalk::new();
|
||||
let before = walk.budget;
|
||||
let mut out = Vec::new();
|
||||
parse_struct_element_dict(&doc, &objr, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert!(out.is_empty());
|
||||
assert_eq!(walk.budget, before, "OBJR must not spend the node budget");
|
||||
|
||||
// A struct dict without a valid /S also materializes nothing.
|
||||
let no_s = dictionary! { "Type" => "StructElem" };
|
||||
let mut walk = StructWalk::new();
|
||||
let before = walk.budget;
|
||||
let mut out = Vec::new();
|
||||
parse_struct_element_dict(&doc, &no_s, &role_map, None, 0, &mut out, &mut walk);
|
||||
assert!(out.is_empty());
|
||||
assert_eq!(
|
||||
walk.budget, before,
|
||||
"a dict without /S must not spend the node budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user