mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 13:54:09 +00:00
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:
1 parent
2b65ab000b
commit
c73cccfb33
4 files changed
+114
-10
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user