mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
+233
-18
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user