fix(redact): evaluate OCMD visibility during sanitization (#3522)

* fix(redact): evaluate OCMD visibility during sanitization

Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively.

* fix(redact): preserve OCMD visibility during sanitization

* fix(redact): count hidden XObjects during verification

* test(redact): cover hidden form depth limit
This commit is contained in:
skylar taylor-barrick
2026-10-20 18:17:34 -06:00
committed by GitHub
parent 22bedbf2af
commit d695bc5654
3 changed files with 667 additions and 30 deletions
+233 -18
View File
@@ -84,9 +84,19 @@ pub(crate) struct Scope<'a> {
pub report: &'a mut Report,
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
pub hidden_text: Option<[f64; 4]>,
/// Sanitize: optional content groups (and membership dictionaries) that are off; content
/// marked with them is removed.
pub hidden_layers: Vec<ObjRef>,
/// Sanitize: optional content groups that are off; content marked with them is removed.
pub hidden_layers: HashSet<ObjRef>,
/// Whether this pass is evaluating optional-content rules. An empty off set is meaningful:
/// policies such as `/AllOff` can still hide content when every group is on.
pub layers_enabled: bool,
/// Off groups referenced by optional content that remains visible and therefore still needs
/// its original default state after sanitization.
pub retained_off_layers: HashSet<ObjRef>,
/// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions.
ocmd_cache: HashMap<ObjRef, bool>,
/// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them
/// by the containing XObject so repeated `Do` calls share the same bounded evaluation.
xobject_oc_cache: HashMap<ObjRef, bool>,
/// Content removed from hidden layers (blocks and XObjects).
pub layer_blocks: usize,
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
@@ -101,7 +111,11 @@ impl<'a> Scope<'a> {
mode,
report,
hidden_text: None,
hidden_layers: Vec::new(),
hidden_layers: HashSet::new(),
layers_enabled: false,
retained_off_layers: HashSet::new(),
ocmd_cache: HashMap::new(),
xobject_oc_cache: HashMap::new(),
layer_blocks: 0,
fonts: HashMap::new(),
used_names: Vec::new(),
@@ -111,20 +125,26 @@ impl<'a> Scope<'a> {
/// Sanitizing visits every form XObject (not only those under a region).
fn everywhere(&self) -> bool {
self.hidden_text.is_some() || !self.hidden_layers.is_empty()
self.hidden_text.is_some() || self.layers_enabled
}
/// Is this optional-content reference (an OCG or OCMD) hidden?
fn layer_hidden(&self, doc: &Document, o: &Object) -> bool {
if self.hidden_layers.is_empty() {
fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool {
if !self.layers_enabled {
return false;
}
if let Some(r) = o.as_ref()
&& self.hidden_layers.contains(&r)
{
return true;
let reference = o.as_ref();
if let Some(r) = reference {
if let Some(hidden) = self.ocmd_cache.get(&r) {
return *hidden;
}
if self.hidden_layers.contains(&r) {
self.ocmd_cache.insert(r, true);
return true;
}
}
// A membership dictionary (default policy AnyOn): hidden when all its groups are.
// Evaluate the policy against the same default configuration used to select hidden
// 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") {
@@ -135,7 +155,188 @@ impl<'a> Scope<'a> {
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
!groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
let hidden = if let Some(expression) = d.get(b"VE") {
let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
match visible {
Some(visible) => !visible,
None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096),
}
} else if groups.is_empty() {
false
} else {
let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec()));
match (d.get(b"P"), policy.as_deref()) {
(None, _) | (Some(_), Some(b"AnyOn")) => !any_on,
(Some(_), Some(b"AllOn")) => !all_on,
(Some(_), Some(b"AnyOff")) => all_on,
(Some(_), Some(b"AllOff")) => any_on,
// An invalid policy must not make a candidate hidden block survive sanitization.
_ => fail_closed(),
}
};
if !hidden {
self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied());
if let Some(expression) = d.get(b"VE") {
let mut referenced = HashSet::new();
if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) {
self.retained_off_layers.extend(referenced);
} else {
// If an expression cannot be fully walked, preserve every off group so that
// the content we kept does not change visibility after configuration cleanup.
self.retained_off_layers.extend(self.hidden_layers.iter().copied());
}
}
}
if let Some(r) = reference {
self.ocmd_cache.insert(r, hidden);
}
hidden
}
fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool {
if oc.as_ref().is_some() {
return self.layer_hidden(doc, oc);
}
if let Some(hidden) = self.xobject_oc_cache.get(&xobject) {
return *hidden;
}
let hidden = self.layer_hidden(doc, oc);
self.xobject_oc_cache.insert(xobject, hidden);
hidden
}
fn collect_hidden_expression_groups(
doc: &Document,
expression: &Object,
hidden_layers: &HashSet<ObjRef>,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
found: &mut HashSet<ObjRef>,
) -> bool {
if depth > 64 || *budget == 0 {
return false;
}
*budget -= 1;
let reference = expression.as_ref();
if let Some(r) = reference
&& !path.insert(r)
{
return false;
}
let resolved = doc.resolve(expression);
let complete = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
if let Some(r) = reference
&& hidden_layers.contains(&r)
{
found.insert(r);
}
reference.is_some()
}
Object::Array(items) => {
let Some(Object::Name(operator)) = items.first() else { return false };
let operands = match operator.as_slice() {
b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..),
b"Not" if items.len() == 2 => items.get(1..),
_ => None,
};
operands.is_some_and(|operands| {
operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found))
})
}
_ => false,
};
if let Some(r) = reference {
path.remove(&r);
}
complete
}
/// Evaluate an optional-content visibility expression (`/VE`) against the default config.
/// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
fn visibility_expression(
&self,
doc: &Document,
expression: &Object,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
) -> Option<bool> {
if depth > 64 || *budget == 0 {
return None;
}
*budget -= 1;
let reference = expression.as_ref();
if let Some(r) = reference
&& !path.insert(r)
{
return None;
}
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::Array(items) if (2..=4097).contains(&items.len()) => {
let operator = match items.first() {
Some(Object::Name(n)) => n.as_slice(),
_ => return None,
};
match operator {
b"And" if items.len() >= 2 => {
let mut visible = true;
for item in items.iter().skip(1) {
visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Or" if items.len() >= 2 => {
let mut visible = false;
for item in items.iter().skip(1) {
visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
_ => None,
}
}
_ => None,
};
if let Some(r) = reference {
path.remove(&r);
}
result
}
/// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
/// syntax, cycles, and exhausted budgets count as a possible hidden reference.
fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet<ObjRef>, budget: &mut usize) -> bool {
if depth > 64 || *budget == 0 {
return true;
}
*budget -= 1;
if let Some(r) = expression.as_ref() {
if self.hidden_layers.contains(&r) {
return true;
}
if !path.insert(r) {
return true;
}
}
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(_) => true,
_ => false,
};
if let Some(r) = expression.as_ref() {
path.remove(&r);
}
found
}
fn hits(&self, b: [f64; 4]) -> bool {
@@ -534,7 +735,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// Layer removal also changes enclosing replacement spans, so remember the operators before
// any cleanup, not just the survivors of the layer pass.
let original = parsed.ops.clone();
if !scope.hidden_layers.is_empty() {
if scope.layers_enabled {
let props = res_dict(doc, resources, b"Properties");
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
parsed.ops = kept;
@@ -928,11 +1129,13 @@ fn xobject(
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { return None };
match s.dict.name(b"Subtype") {
_ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => {
_ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => {
// Verify mode must count hidden XObjects too: scan uses that count to decide
// whether the HiddenLayers pass is necessary.
scope.layer_blocks += 1;
if scope.mode == Mode::Verify {
return None;
}
scope.layer_blocks += 1;
out.replaced.push(name);
out.doomed.push(r);
Some(None)
@@ -983,6 +1186,11 @@ fn xobject(
}
if scope.depth >= MAX_DEPTH {
if scope.mode == Mode::Verify {
// Sanitization removes a Form it cannot enter, so the verification scan
// must count that conservative removal or it may decide to skip the pass.
if scope.everywhere() {
scope.layer_blocks += 1;
}
return None;
}
scope.report.forms_removed += 1;
@@ -998,7 +1206,14 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
Err(_) => return None,
Err(_) => {
// Apply drops an undecodable Form while sanitizing. Count it during Verify
// so a zero-count preflight cannot leave its potentially hidden bytes behind.
if scope.everywhere() {
scope.layer_blocks += 1;
}
return None;
}
};
let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned());
let res = own.clone().unwrap_or_else(|| resources.clone());
@@ -1073,7 +1288,7 @@ fn xobject(
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
/// included). Returns the remaining operators and how many blocks went.
fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec<bool> = Vec::new();
+20 -12
View File
@@ -138,12 +138,13 @@ fn hidden_layers(doc: &Document) -> Vec<ObjRef> {
}
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<usize, RedactError> {
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet<ObjRef>), RedactError> {
let off = if layers { hidden_layers(doc) } else { Vec::new() };
if !text && off.is_empty() {
return Ok(0);
if !text && !layers {
return Ok((0, std::collections::HashSet::new()));
}
let mut total = 0;
let mut retained_off_layers = std::collections::HashSet::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed = Vec::new();
let mut retired_properties = Vec::new();
@@ -164,13 +165,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
}
scope.hidden_layers = off.clone();
scope.hidden_layers = off.iter().copied().collect();
scope.layers_enabled = layers;
let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY);
if let Some(error) = out.error {
return Err(error.into());
}
let blocks = scope.layer_blocks;
total += out.residue + blocks + report.glyphs;
let glyphs = scope.report.glyphs;
let retained_for_page = std::mem::take(&mut scope.retained_off_layers);
drop(scope);
total += out.residue + blocks + glyphs;
retained_off_layers.extend(retained_for_page);
if !write {
continue;
}
@@ -216,7 +222,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
Ok(total)
Ok((total, retained_off_layers))
}
/// How many items each category would remove (categories with nothing are included as 0).
@@ -239,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"),
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0),
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
Hidden::HiddenLayers => {
let off = hidden_layers(doc).len();
if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) }
off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0)
}
Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)),
Hidden::LinksActionsScripts => {
@@ -274,9 +280,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
let mut done = Vec::new();
// Content first (it needs the layers and the form still in place).
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
content_pass(doc, text, layers, true)?;
}
let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
content_pass(doc, text, layers, true)?.1
} else {
std::collections::HashSet::new()
};
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed = |doc: &Document| -> std::collections::HashSet<ObjRef> {
@@ -394,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
Hidden::HiddenLayers if !off.is_empty() => {
// The off layers leave the configuration (their content is gone already).
if let Some(mut oc) = sub(doc, &cat, b"OCProperties") {
let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r));
let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r));
if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
}
+414
View File
@@ -492,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
}
#[test]
fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(pdfcraft_cos::Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(pdfcraft_cos::Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
let before = pdfcraft_edit::text_lines(&doc, 0).unwrap();
assert!(before.iter().any(|line| line.text.contains("PUBLIC")));
assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap();
assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}");
assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}");
assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET"));
}
#[test]
fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"/Fm Do",
"/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>",
b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC",
),
],
);
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)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
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 hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Unused) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(),
],
);
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 hidden_ve_not_is_sanitized_when_its_group_is_on() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()],
);
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")));
}
fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document {
let membership = match policy {
"AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
"Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>",
_ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
};
let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") };
let xobject = match subtype {
"Image" => stream(
&format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"),
b"PRIVATE_HIDDEN_XOBJECT_IMAGE",
),
_ => stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"),
b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET",
),
};
let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()];
if object_number == 9 {
extras.push(membership.as_bytes().to_vec());
}
extras.push(xobject);
let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras);
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(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))),
])),
);
})
.unwrap();
doc
}
#[test]
fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut failures = Vec::new();
for subtype in ["Image", "Form"] {
for policy in ["AllOff", "Not"] {
let mut doc = all_on_ocmd_xobject(subtype, policy);
let before = scan(&doc);
let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n);
if hidden_count == 0 {
failures.push(format!("{subtype} /{policy} was not counted: {before:?}"));
}
match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) {
Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {}
Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")),
Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")),
}
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") {
failures.push(format!("{subtype} /{policy} image payload remains in full save"));
}
if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") {
failures.push(format!("{subtype} /{policy} Form payload remains in full save"));
}
let saved = Document::open(Arc::new(bytes)).unwrap();
if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) {
failures.push(format!("{subtype} /{policy} Form text remains extractable"));
}
if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() {
failures.push(format!("{subtype} /{policy} image remains on the page"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() {
use crate::sanitize::{Hidden, scan};
let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff");
let page = pdfcraft_model::pages(&doc)[0].obj;
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do")));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap();
let original = write_full(&doc, &SaveOptions::default()).unwrap();
let counts = scan(&doc);
assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}");
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document");
}
#[test]
fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = one_page(
b"/Bad Do",
"/XObject << /Bad 7 0 R >>",
vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")],
);
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM"));
}
#[test]
fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() {
use crate::sanitize::{Hidden, remove_hidden, scan};
const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH";
// MAX_DEPTH is 12. The final Form is encountered at that depth and is removed
// conservatively without being entered. Verify must count that removal or sanitize
// can skip the HiddenLayers pass entirely.
let mut forms = Vec::new();
for depth in 0..=12 {
let content = if depth == 12 {
b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec()
} else {
format!("/F{} Do", depth + 1).into_bytes()
};
forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content));
}
let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::<Vec<_>>().join(" ");
let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms);
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"fixture lacked nested payload before sanitization"
);
let counts = scan(&doc);
assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}");
let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
assert!(
removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0),
"depth-limited Form was not reported removed: {removed:?}"
);
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save");
}
#[test]
fn hidden_ocmd_resolves_an_indirect_policy_name() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(),
],
);
doc.set(ObjRef::new(12, 0), Object::name("AnyOn"));
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)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
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("VISIBLE")));
}
#[test]
fn surviving_anyoff_and_not_content_keeps_its_off_group_state() {
use crate::sanitize::{Hidden, remove_hidden};
for use_not in [false, true] {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() },
],
);
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)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let root = doc.root().unwrap();
let catalog = doc.get(root);
let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o));
let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o));
let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o));
assert!(
off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))),
"off group lost for /VE Not={use_not}"
);
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("VISIBLE")));
}
}
#[test]
fn hidden_ocmd_policy_matches_the_default_configuration() {
use crate::sanitize::{Hidden, remove_hidden};
let cases = [
(None, false, None, false),
(Some("AnyOn"), false, None, false),
(Some("AllOn"), false, None, true),
(Some("AnyOff"), false, None, false),
(Some("AllOff"), false, None, true),
(Some("UnknownPolicy"), false, None, true),
(None, true, None, true),
(None, false, Some("and"), true),
(None, false, Some("and_single_not"), true),
(None, false, Some("and_single_off"), true),
(None, false, Some("or_single_on"), false),
(None, false, Some("or_single_off"), true),
(Some("AllOn"), false, Some("not_off"), false),
(None, false, Some("malformed_not"), true),
(None, false, Some("malformed_without_ocgs"), true),
];
for (policy, malformed_policy, visibility, should_remove) in cases {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let mut membership = Dict::new();
membership.set(b"Type".to_vec(), Object::name("OCMD"));
if visibility != Some("malformed_without_ocgs") {
membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))]));
}
if let Some(policy) = policy {
membership.set(b"P".to_vec(), Object::name(policy));
} else if malformed_policy {
membership.set(b"P".to_vec(), Object::Int(17));
}
match visibility {
Some("and") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("and_single_not") => membership.set(
b"VE".to_vec(),
Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]),
),
Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])),
Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])),
Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])),
Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_not") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_without_ocgs") => {
membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
}
_ => {}
}
doc.set(ObjRef::new(9, 0), Object::Dict(membership));
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)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"));
assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}");
}
}
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA