mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M8: metadata check fails closed and finds letter-spaced and short XMP values (#2448)
Review follow-up on #2539: - the glyph end includes character and word spacing, so letter-spaced redacted text is one word and still matches /Title (Bluefin); - an XMP value wholly part of a removed run goes, as an /Info value does (J. Li), compared as whole words (True stays with "untrue"); - when the removed text was cut short (too many runs, a run too long, too many terms) every /Info string and XMP stream goes instead of searching what was kept; - XMP that is not valid UTF-8 or UTF-16 (also UTF-16 without a leading mark) is not searched lossily but removed; - namespace declarations and the xpacket wrapper are not searched; comments and other processing instructions are; - the raw-byte assertion saves without object streams so it can see /Info. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
459cf4af5d
commit
c405d280c6
6 files changed
+259
-85
No files matched your search
+11
-6
@@ -31,12 +31,17 @@ clear_marks(&mut doc, None)?; // remove marks without app
|
||||
- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
|
||||
page as a `/PCMark /Redaction` stream;
|
||||
- **metadata** that repeats the removed text (#2448): `/Info` entries containing it or wholly
|
||||
part of it (three letters or digits at least), and the
|
||||
catalog's and pages' XMP streams whose text (element content and attribute values, entities
|
||||
decoded) contains it, removed whole; unreadable XMP goes too. The removed glyphs' text is
|
||||
collected in runs by position, and each run (two or more letters or digits) and each of its
|
||||
words of three or more are matched without regard to case or spacing. `Report::metadata`
|
||||
counts what went.
|
||||
part of it as whole words (three letters or digits at least), and the catalog's and pages'
|
||||
XMP streams whose text (element content, attribute values other than `xmlns` declarations,
|
||||
comments and processing instructions, entities decoded) contains it or has a value wholly
|
||||
part of it, removed whole, together with any PDF/A identification in that packet. XMP that
|
||||
isn't valid UTF-8 or UTF-16 goes too, and when the removed text was too much to collect
|
||||
every `/Info` string and every XMP stream goes (fail-closed). The removed glyphs' text is
|
||||
collected in runs by position (character and word spacing included, so letter-spaced text
|
||||
stays one word), and each run (two or more letters or digits) and each of its words of three
|
||||
or more are matched without regard to case or spacing. A value that shares only words of one
|
||||
or two letters with the removed text, and isn't wholly part of it, stays: Sanitize's
|
||||
metadata category removes the rest. `Report::metadata` counts what went.
|
||||
|
||||
A verification pass re-reads each redacted page and fails the operation if any glyph or inline
|
||||
image is still under an area. It checks every content stream the page had, whatever its
|
||||
|
||||
@@ -129,6 +129,9 @@ pub(crate) struct Scope<'a> {
|
||||
/// glyphs (#2448), so the document's metadata can be checked for it. Call [`Scope::flush`]
|
||||
/// after processing to close the last run.
|
||||
pub removed_text: Vec<String>,
|
||||
/// Some removed text was not kept (too many runs, or a run too long): the metadata check
|
||||
/// can't search for it and must fail closed.
|
||||
pub removed_cut: bool,
|
||||
/// The run being collected, and where its last glyph ended (device space) with its height.
|
||||
run: String,
|
||||
run_end: Option<((f64, f64), f64)>,
|
||||
@@ -157,6 +160,7 @@ impl<'a> Scope<'a> {
|
||||
xobject_oc_cache: HashMap::new(),
|
||||
layer_blocks: 0,
|
||||
removed_text: Vec::new(),
|
||||
removed_cut: false,
|
||||
run: String::new(),
|
||||
run_end: None,
|
||||
fonts: HashMap::new(),
|
||||
@@ -170,8 +174,12 @@ impl<'a> Scope<'a> {
|
||||
pub fn flush(&mut self) {
|
||||
self.run_end = None;
|
||||
let run = std::mem::take(&mut self.run);
|
||||
if !run.trim().is_empty() && self.removed_text.len() < MAX_RUNS {
|
||||
self.removed_text.push(run);
|
||||
if !run.trim().is_empty() {
|
||||
if self.removed_text.len() < MAX_RUNS {
|
||||
self.removed_text.push(run);
|
||||
} else {
|
||||
self.removed_cut = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +199,8 @@ impl<'a> Scope<'a> {
|
||||
}
|
||||
if self.run.len().saturating_add(text.len()) <= MAX_RUN_BYTES {
|
||||
self.run.push_str(text);
|
||||
} else {
|
||||
self.removed_cut = true;
|
||||
}
|
||||
self.run_end = Some((end, height));
|
||||
}
|
||||
@@ -723,7 +733,10 @@ fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (V
|
||||
};
|
||||
if scope.mode == Mode::Apply && scope.hidden_text.is_none() {
|
||||
if hit {
|
||||
let end = trm.apply(w0, 0.0);
|
||||
// The glyph ends after its character and word spacing, in `trm`'s
|
||||
// units (the size is in `trm`), so letter-spaced text stays one word.
|
||||
let spaced = if size != 0.0 { w0 + spacing / size } else { w0 };
|
||||
let end = trm.apply(spaced, 0.0);
|
||||
let top = trm.apply(0.0, 1.0);
|
||||
let height = (top.0 - origin.0).hypot(top.1 - origin.1);
|
||||
scope.removed(f.text_of(code).as_deref().unwrap_or(""), origin, end, height);
|
||||
|
||||
@@ -423,6 +423,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
retired_properties: Vec::new(),
|
||||
widget_owner,
|
||||
removed_text: Vec::new(),
|
||||
removed_cut: false,
|
||||
removed_everywhere: HashSet::new(),
|
||||
};
|
||||
for &(pi, page_marks) in &groups {
|
||||
@@ -431,7 +432,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
redact_page_content(doc, &mut applying, pi, &page, &rects)?;
|
||||
remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?;
|
||||
}
|
||||
let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, removed_text, .. } = applying;
|
||||
let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, removed_text, removed_cut, .. } = applying;
|
||||
|
||||
// 2b. Replies and pop-ups of a removed annotation can be listed on another page than their
|
||||
// parent: their `/IRT` or `/Parent` would keep the removed comment reachable.
|
||||
@@ -534,7 +535,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
return Err(RedactError::Residue(residue));
|
||||
}
|
||||
// 6. The document's metadata must not keep what the pages no longer show (#2448).
|
||||
report.metadata = metadata::scrub(doc, &removed_text)?;
|
||||
report.metadata = metadata::scrub(doc, &removed_text, !removed_cut)?;
|
||||
// The previous revision still holds the removed content: the next save must rewrite.
|
||||
doc.require_full_save();
|
||||
Ok(report)
|
||||
@@ -551,6 +552,8 @@ struct Applying {
|
||||
removed_everywhere: HashSet<ObjRef>,
|
||||
/// The text of the removed glyphs, for the metadata check at the end.
|
||||
removed_text: Vec<String>,
|
||||
/// Some removed text was too long to keep whole for that check.
|
||||
removed_cut: bool,
|
||||
}
|
||||
|
||||
/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows.
|
||||
@@ -561,7 +564,7 @@ fn redact_page_content(
|
||||
page: &pdfcraft_model::Page,
|
||||
rects: &[[f64; 4]],
|
||||
) -> Result<(), RedactError> {
|
||||
let Applying { report, doomed_xobjects, retired_properties, removed_text, .. } = applying;
|
||||
let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, .. } = applying;
|
||||
// 1. Content.
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::page();
|
||||
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
|
||||
@@ -571,6 +574,7 @@ fn redact_page_content(
|
||||
let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY);
|
||||
scope.flush();
|
||||
removed_text.append(&mut scope.removed_text);
|
||||
*removed_cut |= scope.removed_cut;
|
||||
out
|
||||
};
|
||||
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
|
||||
|
||||
+113
-64
@@ -7,10 +7,15 @@
|
||||
//! An `/Info` entry whose text contains a redacted term is removed, and so is one whose whole
|
||||
//! value is part of a removed run (an author "J. Li" redacted from a sentence, whose words are too
|
||||
//! short to be terms; the reverse check #2598 also proposed). An XMP stream whose text
|
||||
//! (element content and attribute values, entities decoded) contains one is removed whole:
|
||||
//! editing the XML in place could leave it malformed or keep the term in another spelling.
|
||||
//! Unreadable XMP goes too, and so does text too large to search against every term
|
||||
//! (fail-closed). Terms are matched without regard to case or spacing.
|
||||
//! (element content, attribute values other than namespace declarations, comments and
|
||||
//! processing instructions, entities decoded) contains one, or has a value that is part of a
|
||||
//! removed run, is removed whole: editing the XML in place could leave it malformed or keep the
|
||||
//! term in another spelling. A PDF/A identification in that packet goes with it.
|
||||
//!
|
||||
//! Fail-closed: XMP that isn't UTF-8 or UTF-16 goes, text too large to search against every term
|
||||
//! counts as matching, and when the removed text itself was cut short (too many runs or terms,
|
||||
//! or a run too long to keep) every `/Info` string and every XMP stream goes, since what was
|
||||
//! never collected can't be searched for. Terms are matched without regard to case or spacing.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -36,23 +41,28 @@ fn significant(s: &str, min: usize) -> bool {
|
||||
|
||||
/// The terms to look for: each removed run (at least two letters or digits), and each of its
|
||||
/// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was
|
||||
/// redacted. Shorter pieces would match nearly any text.
|
||||
pub(crate) fn terms(removed: &[String]) -> Vec<String> {
|
||||
/// redacted. Shorter pieces would match nearly any text. `None` when a run is longer than
|
||||
/// [`MAX_TERM_CHARS`] or there are more than [`MAX_TERMS`] terms: the caller then can't search
|
||||
/// for all of it and must clear the metadata instead.
|
||||
pub(crate) fn terms(removed: &[String]) -> Option<Vec<String>> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for run in removed {
|
||||
let run: String = normalize(run).chars().take(MAX_TERM_CHARS).collect();
|
||||
let run = normalize(run);
|
||||
if run.chars().nth(MAX_TERM_CHARS).is_some() {
|
||||
return None;
|
||||
}
|
||||
let words = run.split(|c: char| !c.is_alphanumeric()).filter(|w| significant(w, 3)).map(str::to_string).collect::<Vec<_>>();
|
||||
for t in std::iter::once(run.clone()).filter(|r| significant(r, 2)).chain(words) {
|
||||
if out.len() >= MAX_TERMS {
|
||||
return out;
|
||||
}
|
||||
if seen.insert(t.clone()) {
|
||||
if out.len() >= MAX_TERMS {
|
||||
return None;
|
||||
}
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn mentions(text: &str, terms: &[String]) -> bool {
|
||||
@@ -60,8 +70,9 @@ fn mentions(text: &str, terms: &[String]) -> bool {
|
||||
text.len().saturating_mul(terms.len()) > MAX_WORK || terms.iter().any(|t| text.contains(t.as_str()))
|
||||
}
|
||||
|
||||
/// Is `value` itself part of one of the removed `runs` (normalized)? It needs three letters or
|
||||
/// digits, so an entry such as "by" doesn't go with every sentence that contains it. Too much
|
||||
/// Is `value` itself part of one of the removed `runs` (normalized), as whole words? It needs
|
||||
/// three letters or digits, so an entry such as "by" doesn't go with every sentence that
|
||||
/// contains it, and word boundaries, so an XMP value "True" doesn't go with "untrue". Too much
|
||||
/// text to search counts as part (fail-closed).
|
||||
fn within(value: &str, runs: &[String]) -> bool {
|
||||
let value = normalize(value);
|
||||
@@ -69,7 +80,15 @@ fn within(value: &str, runs: &[String]) -> bool {
|
||||
return false;
|
||||
}
|
||||
let total = runs.iter().map(String::len).fold(0usize, usize::saturating_add);
|
||||
value.len().saturating_mul(total) > MAX_WORK || runs.iter().any(|r| r.contains(value.as_str()))
|
||||
let word = |c: Option<char>| c.is_some_and(char::is_alphanumeric);
|
||||
let whole = |r: &String| {
|
||||
r.match_indices(value.as_str()).any(|(at, m)| {
|
||||
let before = r.get(..at).and_then(|s| s.chars().next_back());
|
||||
let after = r.get(at.saturating_add(m.len())..).and_then(|s| s.chars().next());
|
||||
!(word(before) && value.starts_with(char::is_alphanumeric)) && !(word(after) && value.ends_with(char::is_alphanumeric))
|
||||
})
|
||||
};
|
||||
value.len().saturating_mul(total) > MAX_WORK || runs.iter().any(whole)
|
||||
}
|
||||
|
||||
/// Decode the five predefined XML entities and character references; anything else stays.
|
||||
@@ -110,34 +129,42 @@ fn unescape(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The text an XML document carries: element content and attribute values (XMP stores simple
|
||||
/// properties as attributes), each followed by a space, entities decoded. Names, comments and
|
||||
/// markup are left out, so a term can't match a tag such as `dc:title`.
|
||||
pub(crate) fn xml_text(xml: &str) -> String {
|
||||
let mut out = String::new();
|
||||
/// The text values an XML document carries, entities decoded: element content, attribute values
|
||||
/// (XMP stores simple properties as attributes), CDATA, and the text of comments and processing
|
||||
/// instructions other than the `xpacket` wrapper, which a reader can still see. Names and markup are left out, so a term can't
|
||||
/// match a tag such as `dc:title`, and so are namespace declarations (`xmlns`), whose URIs
|
||||
/// would match words such as "adobe" or "application" in every packet.
|
||||
pub(crate) fn xml_values(xml: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: String| {
|
||||
if !s.trim().is_empty() {
|
||||
out.push(s);
|
||||
}
|
||||
};
|
||||
let mut rest = xml;
|
||||
while !rest.is_empty() {
|
||||
let Some(open) = rest.find('<') else {
|
||||
out.push_str(&unescape(rest));
|
||||
push(unescape(rest));
|
||||
break;
|
||||
};
|
||||
out.push_str(&unescape(rest.get(..open).unwrap_or("")));
|
||||
out.push(' ');
|
||||
push(unescape(rest.get(..open).unwrap_or("")));
|
||||
let tag = rest.get(open..).unwrap_or("");
|
||||
if let Some(body) = tag.strip_prefix("<![CDATA[") {
|
||||
let end = body.find("]]>").unwrap_or(body.len());
|
||||
out.push_str(body.get(..end).unwrap_or(""));
|
||||
out.push(' ');
|
||||
rest = body.get(end + 3..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if tag.starts_with("<!--") {
|
||||
rest = tag.find("-->").and_then(|e| tag.get(e + 3..)).unwrap_or("");
|
||||
let delimited = [("<![CDATA[", "]]>"), ("<!--", "-->"), ("<?", "?>")].into_iter().find(|(start, _)| tag.starts_with(start));
|
||||
if let Some((start, stop)) = delimited {
|
||||
let body = tag.get(start.len()..).unwrap_or("");
|
||||
let end = body.find(stop).unwrap_or(body.len());
|
||||
let text = body.get(..end).unwrap_or("");
|
||||
// The packet wrapper's fixed `begin`/`id`/`end` would match words such as "end".
|
||||
if !(start == "<?" && text.starts_with("xpacket")) {
|
||||
push(text.to_string());
|
||||
}
|
||||
rest = body.get(end.saturating_add(stop.len())..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
// Attribute values inside the tag, up to its closing `>` (which can't be inside quotes).
|
||||
let mut chars = tag.char_indices().skip(1);
|
||||
let mut end = tag.len();
|
||||
let mut name = String::new();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match c {
|
||||
'>' => {
|
||||
@@ -152,10 +179,15 @@ pub(crate) fn xml_text(xml: &str) -> String {
|
||||
}
|
||||
value.push(d);
|
||||
}
|
||||
out.push_str(&unescape(&value));
|
||||
out.push(' ');
|
||||
let declaration = name == "xmlns" || name.starts_with("xmlns:");
|
||||
if !declaration {
|
||||
push(unescape(&value));
|
||||
}
|
||||
name.clear();
|
||||
}
|
||||
_ => {}
|
||||
'=' => {}
|
||||
c if c.is_whitespace() || c == '/' => name.clear(),
|
||||
c => name.push(c),
|
||||
}
|
||||
}
|
||||
rest = tag.get(end..).unwrap_or("");
|
||||
@@ -163,48 +195,57 @@ pub(crate) fn xml_text(xml: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The decoded text of an XMP stream (UTF-8, or UTF-16 with a byte-order mark), or `None` when it
|
||||
/// can't be read.
|
||||
fn xmp_text(doc: &Document, o: &Object) -> Option<String> {
|
||||
/// Decode an XMP packet: UTF-8 (with or without a byte-order mark), or UTF-16 told by its mark
|
||||
/// or, as a packet usually begins `<?xpacket` with the mark inside the attribute, by the zero
|
||||
/// byte of its first character. `None` for anything else or any invalid sequence, so text that
|
||||
/// can't be read faithfully is never searched lossily (the caller removes it).
|
||||
pub(crate) fn decode_xmp(bytes: &[u8]) -> Option<String> {
|
||||
let utf16 = |body: &[u8], be: bool| -> Option<String> {
|
||||
let (units, rest) = body.as_chunks::<2>();
|
||||
if !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
char::decode_utf16(units.iter().map(|&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) })).collect::<Result<String, _>>().ok()
|
||||
};
|
||||
match bytes {
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).ok().map(str::to_string),
|
||||
[0xFE, 0xFF, rest @ ..] => utf16(rest, true),
|
||||
[0xFF, 0xFE, rest @ ..] => utf16(rest, false),
|
||||
[0, a, ..] if *a != 0 => utf16(bytes, true),
|
||||
[a, 0, ..] if *a != 0 => utf16(bytes, false),
|
||||
_ => std::str::from_utf8(bytes).ok().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text values of an XMP stream, or `None` when it can't be read.
|
||||
fn xmp_values(doc: &Document, o: &Object) -> Option<Vec<String>> {
|
||||
let Object::Stream(s) = &*doc.resolve(o) else { return None };
|
||||
let bytes = s.decoded().ok()?;
|
||||
if bytes.len() > MAX_XMP_BYTES {
|
||||
return None;
|
||||
}
|
||||
let units = |be: bool| {
|
||||
bytes.get(2..).unwrap_or(&[]).as_chunks::<2>().0.iter().map(move |&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) })
|
||||
};
|
||||
let text = match bytes.get(..2) {
|
||||
Some([0xFE, 0xFF]) => char::decode_utf16(units(true)).map(|c| c.unwrap_or('\u{FFFD}')).collect(),
|
||||
Some([0xFF, 0xFE]) => char::decode_utf16(units(false)).map(|c| c.unwrap_or('\u{FFFD}')).collect(),
|
||||
_ => String::from_utf8_lossy(&bytes).into_owned(),
|
||||
};
|
||||
Some(xml_text(&text))
|
||||
decode_xmp(&bytes).map(|t| xml_values(&t))
|
||||
}
|
||||
|
||||
/// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the
|
||||
/// catalog's and pages' XMP streams that mention it. Returns how many entries and streams went.
|
||||
pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
let terms = terms(removed);
|
||||
if terms.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let runs: Vec<String> = removed.iter().take(MAX_TERMS).map(|r| normalize(r).chars().take(MAX_TERM_CHARS).collect()).collect();
|
||||
/// catalog's and pages' XMP streams that mention it. `complete` is false when the collection of
|
||||
/// removed text was cut short; then, as when the terms themselves exceed their limits, every
|
||||
/// `/Info` string and every XMP stream goes. Returns how many entries and streams went.
|
||||
pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
// `None` matches everything (fail-closed).
|
||||
let search = match terms(removed).filter(|_| complete) {
|
||||
Some(t) if t.is_empty() => return Ok(0),
|
||||
Some(t) => Some((t, removed.iter().map(|r| normalize(r)).collect::<Vec<String>>())),
|
||||
None => None,
|
||||
};
|
||||
let hit = |text: &str| search.as_ref().is_none_or(|(terms, runs)| mentions(text, terms) || within(text, runs));
|
||||
let mut n = 0;
|
||||
|
||||
// The information dictionary, direct in the trailer or an object of its own.
|
||||
let info_obj = doc.trailer().get(b"Info").cloned();
|
||||
if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) {
|
||||
let doomed: Vec<Vec<u8>> = info
|
||||
.iter()
|
||||
.filter(|(_, v)| {
|
||||
doc.resolve(v).as_string().is_some_and(|s| {
|
||||
let s = s.to_text();
|
||||
mentions(&s, &terms) || within(&s, &runs)
|
||||
})
|
||||
})
|
||||
.map(|(k, _)| k.to_vec())
|
||||
.collect();
|
||||
let doomed: Vec<Vec<u8>> =
|
||||
info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| hit(&s.to_text()))).map(|(k, _)| k.to_vec()).collect();
|
||||
if !doomed.is_empty() {
|
||||
n += doomed.len();
|
||||
let mut kept = info.clone();
|
||||
@@ -223,7 +264,15 @@ pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result<usize, pdf
|
||||
holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj));
|
||||
for holder in holders {
|
||||
let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue };
|
||||
if xmp_text(doc, &xmp).is_none_or(|t| mentions(&t, &terms)) {
|
||||
let doomed = match xmp_values(doc, &xmp) {
|
||||
None => true,
|
||||
Some(values) => {
|
||||
let words = |(terms, _): &(Vec<String>, Vec<String>)| mentions(&values.join(" "), terms);
|
||||
let part = |(_, runs): &(Vec<String>, Vec<String>)| values.iter().any(|v| within(v, runs));
|
||||
search.as_ref().is_none_or(|s| words(s) || part(s))
|
||||
}
|
||||
};
|
||||
if doomed {
|
||||
doc.update_dict(holder, |d| {
|
||||
d.remove(b"Metadata");
|
||||
})?;
|
||||
|
||||
+110
-7
@@ -136,8 +136,10 @@ fn redacted_text_does_not_survive_in_info_or_xmp() {
|
||||
mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((r.glyphs, r.metadata), (7, 3), "{r:?}");
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
// Without object streams, so the byte search below can see the `/Info` dictionary.
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle));
|
||||
assert!(has(b"Ada Lovelace"), "the kept /Info entry is visible to the search");
|
||||
assert!(!has(b"bluefin") && !has(b"BlueFin"), "the redacted word survived in the metadata");
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
let info = saved.trailer().get(b"Info").and_then(|i| saved.resolve(i).as_dict().cloned()).unwrap();
|
||||
@@ -171,6 +173,90 @@ fn an_info_value_inside_the_redacted_text_goes() {
|
||||
assert!(info.contains(b"Subject") && info.contains(b"Producer"));
|
||||
}
|
||||
|
||||
/// One page showing `content`, with `/Info` `info` and catalog XMP whose `rdf:RDF` holds
|
||||
/// `xmp_body`.
|
||||
fn with_metadata(content: &[u8], info: &str, xmp_body: &str) -> Document {
|
||||
let xmp = format!(
|
||||
r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{xmp_body}</rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#
|
||||
);
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
|
||||
stream("", content),
|
||||
FONT.replace("95 0 R", "6 0 R").into_bytes(),
|
||||
widths(),
|
||||
stream("/Type /Metadata /Subtype /XML", xmp.as_bytes()),
|
||||
info.as_bytes().to_vec(),
|
||||
];
|
||||
Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap()
|
||||
}
|
||||
|
||||
fn info_keys(doc: &Document) -> Vec<String> {
|
||||
let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap();
|
||||
info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect()
|
||||
}
|
||||
|
||||
fn has_xmp(doc: &Document) -> bool {
|
||||
doc.get(doc.root().unwrap()).as_dict().is_some_and(|c| c.contains(b"Metadata"))
|
||||
}
|
||||
|
||||
/// Letter-spaced text (character spacing wider than a word gap between plain glyphs) is still
|
||||
/// one word, so `/Title (Bluefin)` goes when "B L U E F I N" is redacted (#2539 review).
|
||||
#[test]
|
||||
fn letter_spaced_redacted_text_still_matches_metadata() {
|
||||
let body = r#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Bluefin</dc:title></rdf:Description>"#;
|
||||
let mut doc = with_metadata(b"BT /F1 10 Tf 3 Tc 10 100 Td (BLUEFIN) Tj ET", "<< /Title (Bluefin) /Producer (Test) >>", body);
|
||||
mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.metadata, 2, "{r:?}");
|
||||
assert_eq!(info_keys(&doc), ["Producer"]);
|
||||
assert!(!has_xmp(&doc));
|
||||
}
|
||||
|
||||
/// An XMP value that is part of the removed text goes, as an `/Info` value does, though its
|
||||
/// words are too short to be terms ("J. Li"). Values are compared as whole words, so "True"
|
||||
/// doesn't go with "untrue", and namespace URIs and the `xpacket` wrapper aren't searched, so
|
||||
/// redacting "Adobe … end" keeps a packet declared under `adobe:ns:meta/` and closed by
|
||||
/// `<?xpacket end="w"?>`.
|
||||
#[test]
|
||||
fn xmp_values_inside_the_redacted_text_go_as_whole_words() {
|
||||
let creator = r#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>J. Li</dc:creator></rdf:Description>"#;
|
||||
let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Prepared by J. Li) Tj ET", "<< /Producer (Test) >>", creator);
|
||||
mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.metadata, 1, "{r:?}");
|
||||
assert!(!has_xmp(&doc), "the packet named J. Li");
|
||||
|
||||
let trapped = r#"<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" pdf:Trapped="True"/>"#;
|
||||
let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Adobe said untrue at the end) Tj ET", "<< /Subject (True) /Producer (Test) >>", trapped);
|
||||
mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.metadata, 0, "{r:?}");
|
||||
assert!(has_xmp(&doc));
|
||||
assert_eq!(info_keys(&doc), ["Subject", "Producer"]);
|
||||
}
|
||||
|
||||
/// When the removed text was too much to collect, what wasn't collected can't be searched for:
|
||||
/// every `/Info` string and the XMP go (fail-closed), even though the runs that were kept match
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn metadata_is_cleared_when_the_removed_text_was_cut_short() {
|
||||
// More separate runs of one letter than are kept: each `A` is too far from the last to join.
|
||||
let mut content = b"BT /F1 10 Tf 10 100 Td".to_vec();
|
||||
for _ in 0..50_001 {
|
||||
content.extend_from_slice(b" (A) Tj 30 0 Td (A) Tj -30 0 Td");
|
||||
}
|
||||
content.extend_from_slice(b" ET");
|
||||
let body = r#"<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Annual figures</dc:title></rdf:Description>"#;
|
||||
let mut doc = with_metadata(&content, "<< /Title (Annual figures) /Producer (Test) /Trapped /False >>", body);
|
||||
mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.metadata, 3, "{r:?}");
|
||||
assert_eq!(info_keys(&doc), ["Trapped"], "strings go, the name stays");
|
||||
assert!(!has_xmp(&doc));
|
||||
}
|
||||
|
||||
/// A value shorter than the redacted sentence goes from XMP too, in a custom schema's element or
|
||||
/// attribute, as from `/Info` (the regression #2598 suggested keeping): the packets holding it are
|
||||
/// removed, one that doesn't stays.
|
||||
@@ -241,12 +327,29 @@ fn metadata_terms_and_xmp_text() {
|
||||
// Runs of two letters or digits or more are matched whole, and by their words of three or
|
||||
// more, ignoring case and spacing.
|
||||
let t = metadata::terms(&["John Smith".into(), "7".into(), "of".into(), "A-12".into()]);
|
||||
assert_eq!(t, ["john smith", "john", "smith", "of", "a-12"]);
|
||||
// XMP text is element content and attribute values, entities decoded; not names or comments.
|
||||
let x = metadata::xml_text(r#"<dc:title a="x & y"><!-- hidden --><rdf:li>Résumé <![CDATA[<raw>]]></rdf:li></dc:title>"#);
|
||||
let words: Vec<&str> = x.split_whitespace().collect();
|
||||
assert_eq!(words, ["x", "&", "y", "Résumé", "<raw>"]);
|
||||
assert!(!x.contains("title") && !x.contains("hidden"));
|
||||
assert_eq!(t.as_deref(), Some(&["john smith", "john", "smith", "of", "a-12"].map(String::from)[..]));
|
||||
// A run too long to search for whole can't be matched: the caller clears everything.
|
||||
assert_eq!(metadata::terms(&["x".repeat(5000)]), None);
|
||||
// XMP values: element content, attribute values, CDATA, comments and processing instructions,
|
||||
// entities decoded; not names, and not namespace declarations.
|
||||
let x = metadata::xml_values(
|
||||
r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><?audit James Bond?><x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns="http://example.org/"><dc:title a="x & y"><!-- hidden --><rdf:li>Résumé <![CDATA[<raw>]]></rdf:li></dc:title></x:xmpmeta>"#,
|
||||
);
|
||||
assert_eq!(x, ["audit James Bond", "x & y", " hidden ", "Résumé ", "<raw>"]);
|
||||
}
|
||||
|
||||
/// XMP is decoded from UTF-8 or UTF-16, the latter also without a byte-order mark at the start
|
||||
/// (a packet's mark sits inside `<?xpacket begin="…"`); anything that isn't valid is not read.
|
||||
#[test]
|
||||
fn xmp_packets_decode_or_fail_closed() {
|
||||
let text = "<?xpacket begin=\"\u{feff}\"?><a>Bond</a>";
|
||||
let utf16 = |be: bool| text.encode_utf16().flat_map(|u| if be { u.to_be_bytes() } else { u.to_le_bytes() }).collect::<Vec<u8>>();
|
||||
assert_eq!(metadata::decode_xmp(text.as_bytes()).as_deref(), Some(text));
|
||||
assert_eq!(metadata::decode_xmp(&utf16(true)).as_deref(), Some(text));
|
||||
assert_eq!(metadata::decode_xmp(&utf16(false)).as_deref(), Some(text));
|
||||
assert_eq!(metadata::decode_xmp(&[[0xFF, 0xFE].as_slice(), &utf16(false)].concat()).as_deref(), Some(text));
|
||||
assert_eq!(metadata::decode_xmp(b"<a>Bond \xE9</a>"), None, "Latin-1 isn't searched lossily");
|
||||
assert_eq!(metadata::decode_xmp(&utf16(false)[1..]), None, "odd length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5768,8 +5768,8 @@ milestone = "M8"
|
||||
status = "shipped"
|
||||
commands = ["redact.apply"]
|
||||
tools = ["redact_apply"]
|
||||
evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp"]
|
||||
notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns"
|
||||
evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short"]
|
||||
notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed when the removed text was too much to collect or the XMP can't be decoded); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.redact-glyph-removal"
|
||||
|
||||
Reference in new issue
Block a user