fix(redact): unlink removed XObjects from surviving forms' resources

After applying redactions, removed images and forms were unlinked from
page-tree resource dictionaries but not from the resources of forms the
page still draws. A form sharing the page's original resource dictionary
(a common producer layout) kept the removed image reachable, so a full
save wrote it. The walk over surviving forms now drops XObject entries
for removed objects that the form's own content does not draw, and fails
closed when that cannot be determined.
This commit is contained in:
aiongg
2026-10-22 15:17:44 -04:00
committed by Dylan M. Taylor
parent 22aa6d5f9a
commit be64a60c87
2 changed files with 69 additions and 5 deletions
+29 -5
View File
@@ -529,8 +529,9 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope
}
}
// A surviving form can own the same old resource dictionary as a rewritten page. Its
// unused descriptions remain reachable too, even after every page holder is cleaned.
unlink_form_properties(doc, &holder_nodes, retired_properties)?;
// unused descriptions and doomed XObjects remain reachable too, even after every page
// holder is cleaned.
unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?;
Ok(())
}
@@ -567,8 +568,11 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec<u8>], c
Ok(pruned)
}
fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> {
if retired.is_empty() {
/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from
/// the page-tree `holders`, each unless the form's own content (or a form inheriting its
/// resources) still uses the name. Fails closed when that cannot be determined.
fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet<ObjRef>, retired: &[Object]) -> Result<(), CosError> {
if retired.is_empty() && doomed.is_empty() {
return Ok(());
}
let mut pending: Vec<Dict> = holders
@@ -593,7 +597,27 @@ fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Obj
let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
let names: Vec<Vec<u8>> =
res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect();
if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? {
let mut xo = res_dict(doc, &res, b"XObject");
let doomed_names: Vec<Vec<u8>> =
xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect();
if names.is_empty() && doomed_names.is_empty() {
pending.push(res);
continue;
}
let data = form.decoded()?;
let mut pruned = false;
for name in doomed_names {
match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) {
Some(true) => {}
Some(false) => pruned |= xo.remove(&name).is_some(),
None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }),
}
}
if pruned {
res.set(b"XObject".to_vec(), Object::Dict(xo));
}
pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?;
if pruned {
doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?;
}
pending.push(res);
@@ -26,3 +26,43 @@ fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() {
assert_eq!(apply(&mut doc, None).unwrap().fields, 1);
assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable");
}
/// A surviving form shares the page's resource dictionary, which lists the image the mark
/// removes; the form never draws it.
#[test]
fn a_redacted_image_does_not_survive_in_a_surviving_forms_resources() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 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] /Contents 4 0 R /Resources 7 0 R >>".to_vec(),
stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"BT /F1 10 Tf 10 200 Td (Public) Tj ET"),
]);
mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1);
assert!(content(&doc, 0).contains("Do"), "the form is still drawn");
assert!(!full_save_holds(&doc, b"IMAGESECRET"), "the removed image is still reachable");
}
/// The same form drawing the image itself keeps it: the page still shows it there.
#[test]
fn an_image_a_surviving_form_draws_is_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 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] /Contents 4 0 R /Resources 7 0 R >>".to_vec(),
stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"q 50 0 0 50 200 200 cm /Im Do Q"),
]);
mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1);
assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark");
}