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 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 23:49:57 -04:00
1 parent 0a09864833
commit 057e453a16
5 files changed
+197 -1

No files matched your search

+1
View File
@@ -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. |
+5
View File
@@ -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
+94
View File
@@ -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<u8> {
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<Option<ObjRef>> {
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<ObjRef, (Object, bool)> = 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<ObjRef> = 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<Report, RedactError> {
@@ -293,6 +386,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
by_page.sort_unstable();
by_page.dedup();
report.pages = by_page.len();
localize_resources(doc, &by_page)?;
let mut doomed_fields: Vec<String> = 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::<Vec<_>>()).collect();
+96
View File
@@ -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<ObjRef>) {
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<u32> = 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);
}
}
+1 -1
View File
@@ -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.<br>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<br>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.<br>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<br>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.<br>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.<br>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.<br>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).<br>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 |