From 057e453a16c0750ff1c6a784003587f7d2247baa Mon Sep 17 00:00:00 2001 From: Jules Kreuer <25013642+not-a-feature@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:49:57 +0200 Subject: [PATCH] Retire inherited resources (#1348) * M8: Retire inherited redaction resources * Redaction: pin inherited resources only on redacted pages, shared by reference localize_resources rewrote every page in the document on every apply and copied a direct inherited /Resources dictionary into each one, so a large document grew by N x the dict. Now only redacted pages that inherit resources are pinned; a direct inherited dictionary is made one indirect object and shared by reference. The parent's /Resources is retired when no other page still inherits it (the leak fix), and kept (through the shared reference) when an unredacted page still draws with it. A /Parent chain that can't be followed or disagrees with the page tree retires nothing. Tests: an unredacted page is left untouched and the rewrite is bounded; redacting every inheriting page retires the parent's resources. ROADMAP entry moved into the revision-history table. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/redact/README.md | 5 ++ crates/redact/src/lib.rs | 94 +++++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 96 ++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- 5 files changed, 197 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 44a26604d..1ff5c9af7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -403,6 +403,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. | diff --git a/crates/redact/README.md b/crates/redact/README.md index c156ad0fb..4a569eaa1 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -36,6 +36,11 @@ leave it unredacted. Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. +A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; +a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's +`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed +XObject reachable. Pages that aren't redacted are left untouched. + ## Remove hidden information / Sanitize ```rust diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 7ce4cfd16..15f839691 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; @@ -272,6 +274,97 @@ fn overlay_content(marks: &[&Mark]) -> Vec { c } +/// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them): +/// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or +/// a tree deeper than any real one), so callers can stay conservative. +fn resource_source(doc: &Document, page: ObjRef) -> Option> { + let mut seen = HashSet::new(); + let mut parent = doc.get(page).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); + while let Some(r) = parent { + if !seen.insert(r) || seen.len() > 4096 { + return None; + } + let node = doc.get(r); + let d = node.as_dict()?; + if d.contains(b"Resources") { + return Some(Some(r)); + } + parent = d.get(b"Parent").and_then(Object::as_ref); + } + Some(None) +} + +/// Before rewriting `selected` pages, pin the resources they inherit onto them, and retire the +/// ancestor `/Resources` they came from once no other page still inherits it, so the parent can't +/// keep a removed XObject reachable. Pages that aren't redacted are left untouched; an inherited +/// dictionary that is direct becomes one shared indirect object, never a copy per page. +fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfcraft_cos::CosError> { + let pages = pdfcraft_model::pages(doc); + let inherits = |doc: &Document, p: &pdfcraft_model::Page| doc.get(p.obj).as_dict().is_some_and(|d| !d.contains(b"Resources")); + // Ancestor → the reference its resources are shared through, and whether that reference is new. + let mut shared: HashMap = HashMap::new(); + for page in selected.iter().filter_map(|&i| pages.get(i)) { + // What the page tree gives the page; nothing to pin if it gets nothing. + let Some(effective) = page.dict.get(b"Resources").cloned() else { continue }; + if !inherits(doc, page) { + continue; + } + let source = resource_source(doc, page.obj).flatten(); + let original = source.and_then(|s| doc.get(s).as_dict().and_then(|d| d.get(b"Resources").cloned())); + let value = match source.filter(|_| original.as_ref() == Some(&effective)) { + // `/Parent` agrees with the page tree: share one reference per ancestor. + Some(source) => match shared.get(&source) { + Some((v, _)) => v.clone(), + None => { + // A direct dictionary is made indirect once, so every pinned page shares it. + let (v, new) = match effective { + Object::Ref(_) => (effective, false), + direct => (Object::Ref(doc.add(direct)), true), + }; + shared.insert(source, (v.clone(), new)); + v + } + }, + // A malformed tree whose `/Parent` disagrees: pin this page alone and retire nothing. + None => match effective { + Object::Ref(_) => effective, + direct => Object::Ref(doc.add(direct)), + }, + }; + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), value))?; + } + if shared.is_empty() { + return Ok(()); + } + // Ancestors some page still inherits from keep their resources (that page draws them), through + // the shared reference; the others are retired. A page that inherits resources through a chain + // that can't be followed might inherit from any of them, so then none is retired. + let mut still_used: HashSet = HashSet::new(); + for page in pages.iter().filter(|p| p.dict.contains(b"Resources") && inherits(doc, p)) { + match resource_source(doc, page.obj) { + Some(Some(source)) => { + if shared.contains_key(&source) { + still_used.insert(source); + } + } + _ => still_used.extend(shared.keys().copied()), + } + } + for (source, (value, new)) in shared { + if still_used.contains(&source) { + if !new { + continue; + } + doc.update_dict(source, |d| d.set(b"Resources".to_vec(), value))?; + } else { + doc.update_dict(source, |d| { + d.remove(b"Resources"); + })?; + } + } + Ok(()) +} + /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { @@ -293,6 +386,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 997e6f45a..81b3b328c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -726,3 +726,99 @@ fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() { assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public")); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText")); } + +#[test] +fn inherited_resources_do_not_keep_the_original_form() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone(); + doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); +} + +#[test] +fn unselected_pages_keep_inherited_forms() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. +fn inheriting_pages(n: usize) -> (Document, Vec) { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let mut kids = vec![ObjRef::new(3, 0)]; + kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone())))); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())); + d.set(b"Count".to_vec(), Object::Int(n as i64)); + }) + .unwrap(); + let saved = reopen(&doc); + (saved, kids) +} + +#[test] +fn redacting_one_page_leaves_the_other_pages_untouched() { + let (mut doc, kids) = inheriting_pages(50); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + let before = doc.modified_objects(); + apply(&mut doc, Some(&[0])).unwrap(); + let changed: Vec = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect(); + for kid in &kids[1..] { + assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten"); + } + // The page, its new content and resources, the cleaned form, the parent and one shared + // resources object: not one object per page. + assert!(changed.len() < 10, "{changed:?}"); + // The parent keeps the resources the other pages draw with, through a single shared object. + let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone(); + assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some()); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +#[test] +fn redacting_every_inheriting_page_retires_the_parent_resources() { + let (mut doc, kids) = inheriting_pages(20); + for page in 0..kids.len() { + mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], ""); + } + apply(&mut doc, None).unwrap(); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page in 0..kids.len() { + assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources")); + assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c6d39c9b..b483b336d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -93,7 +93,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |