fix(redact): hidden-layer removal reads the default state as viewers do (#5867)

* fix(redact): hidden-layer removal reads the default state as viewers do

hidden_layers ignored an indirect /BaseState and, with /BaseState /OFF,
let /ON override /OFF, so a group both lists named survived as visible.
The OCMD and OCG classifier also read /Type directly, so a membership
dictionary with an indirect /Type was never evaluated and its content kept.

The default state is now /BaseState (resolved), then /ON, then /OFF, and
/Type is resolved before classifying a group or membership dictionary.

* address review: follow chained /Type and /BaseState references, hash-set ON/OFF lists

* address review: a visibility expression naming a group through an alias reference fails closed
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 15:37:44 -05:00
1 parent 2b65ab000b
commit c73cccfb33
4 files changed
+114 -10

No files matched your search

+18 -4
View File
@@ -20,7 +20,7 @@ impl<'a> Scope<'a> {
// groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
if !has_type(doc, d, b"OCMD") {
return false;
}
let groups: Vec<ObjRef> = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
@@ -102,7 +102,7 @@ impl<'a> Scope<'a> {
}
let resolved = doc.resolve(expression);
let complete = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
Object::Dict(d) if has_type(doc, d, b"OCG") => {
if let Some(r) = reference
&& hidden_layers.contains(&r)
{
@@ -144,6 +144,11 @@ impl<'a> Scope<'a> {
}
*budget -= 1;
let reference = expression.as_ref();
// A reference to a reference is no listed group: the renderer rejects the alias, so it
// is not taken as visible because the object it names isn't hidden.
if reference.is_some_and(|r| matches!(*doc.get(r), Object::Ref(_))) {
return None;
}
if let Some(r) = reference
&& !path.insert(r)
{
@@ -151,7 +156,7 @@ impl<'a> Scope<'a> {
}
let resolved = doc.resolve(expression);
let result = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Dict(d) if has_type(doc, d, b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Array(items) if (2..=4097).contains(&items.len()) => {
let operator = match items.first() {
Some(Object::Name(n)) => n.as_slice(),
@@ -195,6 +200,9 @@ impl<'a> Scope<'a> {
if self.hidden_layers.contains(&r) {
return true;
}
if matches!(*doc.get(r), Object::Ref(_)) {
return true;
}
if !path.insert(r) {
return true;
}
@@ -202,7 +210,7 @@ impl<'a> Scope<'a> {
let resolved = doc.resolve(expression);
let found = match &*resolved {
Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(d) if has_type(doc, d, b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(_) => true,
_ => false,
};
@@ -256,3 +264,9 @@ pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Ve
}
(out, removed)
}
/// Is this dictionary's `/Type` the given name? An indirect `/Type` is followed, as a viewer
/// follows it: a group or membership dictionary must not escape classification through one.
fn has_type(doc: &Document, d: &Dict, name: &[u8]) -> bool {
d.get(b"Type").is_some_and(|t| crate::resolve_chain(doc, t).as_name() == Some(name))
}
+13
View File
@@ -1137,6 +1137,19 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
Ok(targets.len())
}
/// `o` with a chain of references followed, as a viewer follows it, to at most 16 links (so a
/// reference cycle ends): hidden-layer state must not escape classification through one.
pub(crate) fn resolve_chain(doc: &pdfcraft_cos::Document, o: &pdfcraft_cos::Object) -> std::sync::Arc<pdfcraft_cos::Object> {
let mut o = doc.resolve(o);
for _ in 0..16 {
if !matches!(*o, pdfcraft_cos::Object::Ref(_)) {
break;
}
o = doc.resolve(&o);
}
o
}
/// The name to save a document under after [`apply`]: `_Redacted` before the extension, as
/// Acrobat appends it ("contract.pdf" gives "contract_Redacted.pdf"). The `.pdf` extension is
/// matched in any letter case. A name that already ends in `_Redacted` keeps it once.
+7 -6
View File
@@ -479,12 +479,13 @@ fn hidden_layers(doc: &Document) -> Vec<ObjRef> {
o.and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default()
};
let all = refs(oc.get(b"OCGs"));
let (on, off) = (refs(d.get(b"ON")), refs(d.get(b"OFF")));
let mut hidden: Vec<ObjRef> = if d.name(b"BaseState") == Some(b"OFF") {
all.iter().copied().filter(|g| !on.contains(g)).collect()
} else {
off.into_iter().filter(|g| all.contains(g)).collect()
};
// sets: the lists are the document's, and a filter over every group would scan them each time
let (on, off): (std::collections::HashSet<ObjRef>, std::collections::HashSet<ObjRef>) =
(refs(d.get(b"ON")).into_iter().collect(), refs(d.get(b"OFF")).into_iter().collect());
// The default state is /BaseState, then /ON turns groups on and /OFF turns them off, so a
// group both lists name ends up off (§8.11.4.3, Table 101) — the viewer reads it the same way.
let base_off = d.get(b"BaseState").map(|b| crate::resolve_chain(doc, b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice()));
let mut hidden: Vec<ObjRef> = all.iter().copied().filter(|g| off.contains(g) || (base_off && !on.contains(g))).collect();
// Each rule, layer list and layer is read once, however often the rules name them: many rules
// can share one long list.
let known: std::collections::HashSet<ObjRef> = all.iter().copied().collect();
+76
View File
@@ -4151,6 +4151,8 @@ fn hidden_ocmd_policy_matches_the_default_configuration() {
(Some("AllOn"), false, Some("not_off"), false),
(None, false, Some("malformed_not"), true),
(None, false, Some("malformed_without_ocgs"), true),
// `/VE` names the hidden group through an alias object (10 0 R is a reference to 8 0 R).
(None, false, Some("alias"), true),
];
for (policy, malformed_policy, visibility, should_remove) in cases {
let mut doc = one_page(
@@ -4188,9 +4190,11 @@ fn hidden_ocmd_policy_matches_the_default_configuration() {
Some("malformed_without_ocgs") => {
membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
}
Some("alias") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(10, 0))])),
_ => {}
}
doc.set(ObjRef::new(9, 0), Object::Dict(membership));
doc.set(ObjRef::new(10, 0), Object::Ref(ObjRef::new(8, 0)));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
@@ -6670,6 +6674,78 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
}
}
/// One page drawing `SECRET` in the `/OC /L` block, with layer 7 0 R in the default
/// configuration `config` (objects 8 and up are `extra`).
fn layer_state_page(config: Vec<(&str, Object)>, resource: &str, extra: Vec<Vec<u8>>) -> Document {
let mut objects = vec![b"<< /Type /OCG /Name (Secret) >>".to_vec()];
objects.extend(extra);
let mut doc = one_page(b"/OC /L BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", &format!("/Properties << /L {resource} >>"), objects);
let config = Object::Dict(Dict::from_iter(config.into_iter().map(|(k, v)| (k.as_bytes().to_vec(), v))));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), (b"D".to_vec(), config)])),
);
})
.unwrap();
doc
}
fn assert_layer_secret_removed(mut doc: Document) {
use crate::sanitize::{Hidden, remove_hidden, scan};
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
#[test]
fn an_indirect_base_state_off_hides_the_layer() {
assert_layer_secret_removed(layer_state_page(vec![("BaseState", Object::Ref(ObjRef::new(8, 0)))], "7 0 R", vec![b"/OFF".to_vec()]));
}
#[test]
fn a_chained_base_state_off_hides_the_layer() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::Ref(ObjRef::new(8, 0)))],
"7 0 R",
vec![b"9 0 R".to_vec(), b"/OFF".to_vec()],
));
}
#[test]
fn a_chained_membership_type_is_evaluated() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::name("ON"))],
"8 0 R",
vec![b"<< /Type 9 0 R /OCGs [7 0 R] /P /AllOff >>".to_vec(), b"10 0 R".to_vec(), b"/OCMD".to_vec()],
));
}
#[test]
fn off_wins_over_on_when_the_base_state_is_off() {
assert_layer_secret_removed(layer_state_page(
vec![
("BaseState", Object::name("OFF")),
("ON", Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
("OFF", Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
],
"7 0 R",
Vec::new(),
));
}
#[test]
fn a_membership_dictionary_with_an_indirect_type_is_evaluated() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::name("ON"))],
"8 0 R",
vec![b"<< /Type 9 0 R /OCGs [7 0 R] /P /AllOff >>".to_vec(), b"/OCMD".to_vec()],
));
}
/// An invisible OCR-style text layer drawn in a Type3 font (what the OCR output writes for
/// characters beyond WinAnsi) is redacted like any other text: its glyphs are hit-tested by
/// their real advances, removed, and the verifier passes.