diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index a4ae54090..b70cf23ab 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -215,6 +215,17 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { /// reference keeps the annotation, its URL or text included, in a full rewrite after the page /// no longer lists it. Returns how many references were dropped. pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result { + drop_object_refs_within(doc, gone, MAX_STRUCT_REFS) +} + +/// The most structure-tree references [`drop_object_refs`] visits. +const MAX_STRUCT_REFS: usize = 2_000_000; + +/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full +/// could keep a reference the walk never reached, so then the whole structure tree goes instead +/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of +/// the document's tags. +pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result { if gone.is_empty() { return Ok(0); } @@ -232,9 +243,17 @@ pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Re match k { Object::Array(a) => todo.extend(a), Object::Ref(r) => { - if !seen.insert(r) || seen.len() > 2_000_000 { + if !seen.insert(r) { continue; } + if seen.len() > limit { + let Some(catalog) = doc.root() else { return Ok(0) }; + doc.update_dict(catalog, |c| { + c.remove(b"StructTreeRoot"); + c.remove(b"MarkInfo"); + })?; + return Ok(0); + } let object = doc.get(r); if let Object::Array(a) = &*object { todo.extend(a.iter().cloned()); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c3c4f8922..ed076d38e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -732,6 +732,39 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); } +/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a +/// reference the walk never reached could keep a removed annotation. Then the whole tree goes +/// (with `/MarkInfo`), rather than reporting success with references left behind; under the +/// limit only the reference goes. +#[test] +fn a_structure_tree_too_large_to_walk_is_removed_whole() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(), + // 4: the removed link + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5 root, 6 document, 7 section, 8 link element with the OBJR + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into(); + let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1); + assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays"); + + let mut doc = pdf(objs); + crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap(); + assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole"); + assert!(!catalog(&doc).contains(b"MarkInfo")); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test]