redact: count inline outline items against the total budget

The total budget of the outline count (100,000 items) was checked against the
number of indirect items the walk had reached. Items written inline, as a
direct dictionary, were counted in the result but never entered that set, so
an outline of 60,000 indirect siblings, each with one inline child, counted
120,000 items.

The walk now keeps a separate running total of every item it counts, inline
items included, and stops when that total reaches the budget. The set of
indirect items is still used to count shared items once. Ordinary outlines
count as before.

The doc comment of the walk now states the depth limit in one sentence: an
item first reached through a deeper path is not visited again from a shallower
one, so items nested past depth 64 may stay uncounted. The depth behaviour is
unchanged.

Test added in crates/redact/src/tests.rs:
inline_outline_children_count_towards_the_total_budget. It reads 120000 on the
previous head and 100000 now. Reverting the budget check to the indirect-only
count fails it; reverting the increment fails it and the existing total budget
test. The other outline tests pass unchanged.

Checked with cargo fmt --all -- --check, clippy with -D warnings on
pdfcraft-redact, and cargo test for pdfcraft-redact (85 passed, 0 failed).
This commit is contained in:
eliets2
2026-10-20 21:04:06 +03:00
parent c2c4db7eca
commit b97b383232
2 changed files with 36 additions and 9 deletions
+20 -9
View File
@@ -101,21 +101,31 @@ fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize {
const OUTLINE_ITEM_BUDGET: usize = 100_000;
fn outline_len(doc: &Document, first: Option<&Object>, depth: usize) -> usize {
outline_len_seen(doc, first, depth, &mut std::collections::HashSet::new())
let (mut seen, mut counted) = (std::collections::HashSet::new(), 0);
outline_len_seen(doc, first, depth, &mut seen, &mut counted)
}
/// Counts an outline level and everything below it, each item once. `seen` holds the items already
/// counted. An item that two others name (as `/First` or `/Next`) is counted where the walk first
/// reaches it, and a level stops at an item already seen, because the walk that reached that item
/// counts the rest of its level. A hostile outline therefore costs one step per item, not 2^n steps
/// for n levels of shared children.
fn outline_len_seen(doc: &Document, first: Option<&Object>, depth: usize, seen: &mut std::collections::HashSet<ObjRef>) -> usize {
/// Counts an outline level and everything below it, each item once. `seen` holds the indirect items
/// already counted, and `counted` every item counted so far, inline ones included, which the total
/// budget applies to. An item that two others name (as `/First` or `/Next`) is counted where the walk
/// first reaches it, and a level stops at an item already seen, because the walk that reached that
/// item counts the rest of its level. A hostile outline therefore costs one step per item, not 2^n
/// steps for n levels of shared children. The depth limit of 64 only matters for outlines nested that
/// deep: an item first reached through a deeper path is not visited again from a shallower one, so
/// items nested beyond the limit may stay uncounted.
fn outline_len_seen(
doc: &Document,
first: Option<&Object>,
depth: usize,
seen: &mut std::collections::HashSet<ObjRef>,
counted: &mut usize,
) -> usize {
let mut n = 0;
let mut cur = first.cloned();
let mut guard = 0;
while let Some(o) = cur {
guard += 1;
if guard > 100_000 || depth > 64 || seen.len() >= OUTLINE_ITEM_BUDGET {
if guard > 100_000 || depth > 64 || *counted >= OUTLINE_ITEM_BUDGET {
break;
}
if let Object::Ref(r) = &o
@@ -124,7 +134,8 @@ fn outline_len_seen(doc: &Document, first: Option<&Object>, depth: usize, seen:
break;
}
let Some(d) = doc.resolve(&o).as_dict().cloned() else { break };
n += 1 + outline_len_seen(doc, d.get(b"First"), depth + 1, seen);
*counted += 1;
n += 1 + outline_len_seen(doc, d.get(b"First"), depth + 1, seen, counted);
cur = d.get(b"Next").cloned();
}
n
+16
View File
@@ -835,6 +835,22 @@ fn an_outline_counts_no_more_items_than_its_total_budget() {
assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000)));
}
/// Items written inline (not as indirect objects) count towards the total budget too. Each of
/// 60,000 indirect siblings has one inline child, so 120,000 items are reached, and the total must
/// still stop at 100,000.
#[test]
fn inline_outline_children_count_towards_the_total_budget() {
use crate::sanitize::{Hidden, scan};
let siblings = 60_000;
let mut items = Vec::with_capacity(siblings);
for i in 0..siblings {
let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() };
items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes());
}
let doc = outlined_document(items);
assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000)));
}
/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags
/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the
/// annotations, the URL and the comment text included, while the page no longer lists them.