From b97b38323272d44e506acf4966a7a8ea8fe9cb0f Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:04:06 +0300 Subject: [PATCH] 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). --- crates/redact/src/sanitize.rs | 29 ++++++++++++++++++++--------- crates/redact/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index a56118da6..ef1a44c91 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -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) -> 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, + 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 diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index ee76cc086..feb21c166 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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.