mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Security: fix three audit findings in redaction and signature validation (#1631)
* M8: redaction unlinks removed images and forms from inherited and shared resources
Apply Redactions and Remove Hidden Information give a rewritten page its own
pruned /Resources, but the dictionary the page drew through before could still
name the image or form that was removed:
- a /Resources inherited from a /Pages node;
- one resource dictionary object shared by several pages;
- the page's own dictionary, when a form without /Resources drew the image
through it.
A full-rewrite save keeps whatever is still referenced, so the removed image
samples and form text stayed in the saved file while the operation reported
success. Per-page resources (the case fixed in #1099) were not affected.
The interpreter now records the original object behind every XObject it drops
or replaces. After the page loop, each page-tree resource dictionary that still
names one loses that entry unless a page drawing through that dictionary still
draws the name (the same image on another page, or again outside a mark). A
dictionary that loses an entry is replaced by a private copy on its node, so a
dictionary object shared with other nodes is left alone for them. If a page's
use of a name cannot be told (unreadable content, a form that will not decode),
the operation fails closed.
Tests: redacted_xobjects_do_not_survive_through_inherited_or_shared_resources,
an_image_another_page_still_draws_through_the_same_resources_is_kept,
an_image_a_form_drew_through_the_pages_resources_does_not_survive,
a_hidden_layers_image_does_not_survive_through_inherited_resources.
Not covered here: originals named by a form XObject's own /Resources, by an
annotation appearance or by a pattern.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* M9: a cross-reference entry the reader sets aside is never an unchanged signed object
The reader that judges post-signing changes and the renderer that draws the
page resolve some cross-reference entries differently. A revision appended
after signing could redefine a page's content stream and give the object an
entry the reader sets aside while the renderer follows it:
- an in-use entry at offset 0 (the reader skips it; the renderer fails to read
the object there, rebuilds its index and takes the last definition);
- a second entry for the same object in one section (the reader keeps the
first, the renderer the last).
The classifier compared the signed object with itself, found nothing changed
and reported the signature Valid while the page showed the new content.
pdfcraft-cos now records the entries it does not take at face value
(Document::xref_oddities: offset-0 in-use entries other than object 0,
duplicate entries that differ, reserved cross-reference stream types, and a
hybrid /XRefStm entry that disagrees with the table), and can say whether an
entry locates the object itself (Document::xref_entry_is_exact). What it
resolves is unchanged.
classify_changes treats any such entry added after the signed revision as a
change that is never permitted (page content when the object was a signed page
or its content, other changes otherwise). An object whose content compares
equal at a new place is unchanged only when its entry is exact: where the
reader had to search for the object, another reader may arrive elsewhere.
Tests: a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content,
a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object,
cross_reference_entries_that_are_set_aside_are_recorded,
an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact.
This closes the two demonstrated triggers and their near variants. It does not
prove that the two parsers agree on every file: other differences in what each
tolerates may remain.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* M8: Apply Redactions is refused when the page still shows text under a mark
Redaction decides what lies under a mark from the positions its own
interpreter computes, and its verify pass reuses them. Where those differ from
the renderer's, a mark drawn over the text as displayed lies over glyphs the
interpreter places elsewhere, or does not see at all: they stayed in the file
and the operation reported success.
The case the test pins: a font and size set through an ExtGState /Font entry
(`gs`), which the renderer honours and the interpreter does not model. The same
kind of gap was observed for a font a form XObject takes from its caller, for
some predefined CMaps and for a rotated Type 3 font matrix. The standard-14
width error that first showed it is fixed at its root by d0d1ad81.
The engine now asks the renderer, which is what the user saw when placing the
marks. After redaction runs, the result is written as a save would write it
(without the marks' own overlay), its text is extracted, and a glyph still at
least half inside a mark fails the edit with the existing "could not be
verified" error. The edit runs on a copy, so the document is unchanged. A page
whose text cannot be read back fails the same way.
This makes such mismatches fail closed; it does not make those documents
redactable. Teaching the interpreter each of them is separate work.
Test: redaction_is_refused_when_the_page_still_shows_text_under_a_mark (the
line whose font is set by `gs` is refused and left unchanged; the same line set
with `Tf` is redacted).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
396c9c7836
commit
759cfa65f4
10 files changed
+604
-17
No files matched your search
+113
-14
@@ -105,6 +105,8 @@ pub struct Document {
|
||||
entries: Arc<BTreeMap<u32, XrefEntry>>,
|
||||
trailer: Dict,
|
||||
revisions: Arc<Vec<Revision>>,
|
||||
/// See [`Self::xref_oddities`].
|
||||
xref_oddities: Arc<Vec<(u64, u32)>>,
|
||||
repair_log: Arc<Vec<String>>,
|
||||
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
|
||||
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
|
||||
@@ -219,6 +221,7 @@ impl Document {
|
||||
entries: Arc::new(BTreeMap::new()),
|
||||
trailer: Dict::new(),
|
||||
revisions: Arc::new(Vec::new()),
|
||||
xref_oddities: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
@@ -282,6 +285,7 @@ impl Document {
|
||||
entries: Arc::new(BTreeMap::new()),
|
||||
trailer: Dict::new(),
|
||||
revisions: Arc::new(Vec::new()),
|
||||
xref_oddities: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
@@ -304,10 +308,11 @@ impl Document {
|
||||
let parsed = doc.read_xref_chain(&mut log);
|
||||
let mut authenticated = false;
|
||||
let ok = match parsed {
|
||||
Ok((entries, trailer, revisions)) => {
|
||||
Ok((entries, trailer, revisions, oddities)) => {
|
||||
doc.entries = Arc::new(entries);
|
||||
doc.trailer = trailer;
|
||||
doc.revisions = Arc::new(revisions);
|
||||
doc.xref_oddities = Arc::new(oddities);
|
||||
// Authenticate before checking the catalog: it may sit in an encrypted object
|
||||
// stream, which reads as garbage until the key is known.
|
||||
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
|
||||
@@ -587,6 +592,36 @@ impl Document {
|
||||
self.entries.get(&num).cloned()
|
||||
}
|
||||
|
||||
/// Cross-reference entries this reader did not take at face value, as (byte offset of the
|
||||
/// section, object number): an in-use entry at offset 0, a second and different entry for
|
||||
/// one object in the same section, a reserved cross-reference stream entry type, or a
|
||||
/// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside
|
||||
/// and keeps what it already had for the object. Another reader, the page renderer among
|
||||
/// them, may follow it instead, so the two can then show different objects for one number.
|
||||
/// Code that compares revisions (signature validation) must not call such an object
|
||||
/// unchanged.
|
||||
pub fn xref_oddities(&self) -> &[(u64, u32)] {
|
||||
&self.xref_oddities
|
||||
}
|
||||
|
||||
/// Whether the cross-reference entry of `num` locates that object itself: its `num G obj`
|
||||
/// header at exactly the recorded offset, or the member with that number at the recorded
|
||||
/// position of its object stream. When it does not, [`Self::get`] still finds the object by
|
||||
/// searching, but another reader may resolve the number differently. Ignores unsaved edits.
|
||||
pub fn xref_entry_is_exact(&self, num: u32) -> bool {
|
||||
match self.entries.get(&num) {
|
||||
None | Some(XrefEntry::Free { .. }) => true,
|
||||
Some(XrefEntry::InFile { offset, .. }) => {
|
||||
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num);
|
||||
// Offsets may count from a header that does not start the file.
|
||||
usize::try_from(*offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
|
||||
}
|
||||
Some(XrefEntry::InStream { stream, index }) => {
|
||||
self.objstm(*stream).is_ok_and(|stm| usize::try_from(*index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Object `num` has an unsaved edit.
|
||||
pub fn is_edited(&self, num: u32) -> bool {
|
||||
self.overlay.contains_key(&num)
|
||||
@@ -951,7 +986,7 @@ impl Document {
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>), CosError> {
|
||||
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
|
||||
let data = &self.data;
|
||||
let tail_start = data.len().saturating_sub(4096);
|
||||
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
|
||||
@@ -963,6 +998,7 @@ impl Document {
|
||||
let mut entries = BTreeMap::new();
|
||||
let mut trailer: Option<Dict> = None;
|
||||
let mut revisions = Vec::new();
|
||||
let mut oddities = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
let mut next = Some(first);
|
||||
while let Some(off) = next.take() {
|
||||
@@ -972,12 +1008,15 @@ impl Document {
|
||||
}
|
||||
// This section's own entries; older sections only fill what newer ones left out.
|
||||
let mut sect = BTreeMap::new();
|
||||
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect) {
|
||||
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
|
||||
let mut odd = Vec::new();
|
||||
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) {
|
||||
Ok(v) => v,
|
||||
Err(e) if self.header_offset > 0 => {
|
||||
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
|
||||
sect.clear();
|
||||
self.read_section(off as usize + self.header_offset, &mut sect)?
|
||||
odd.clear();
|
||||
self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)?
|
||||
}
|
||||
Err(e) => {
|
||||
if revisions.is_empty() {
|
||||
@@ -992,17 +1031,23 @@ impl Document {
|
||||
// free entries it gives objects only a cross-reference stream can locate.
|
||||
if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
|
||||
let mut stm = BTreeMap::new();
|
||||
match self.read_section(x as usize, &mut stm) {
|
||||
match self.read_section(x as usize, &mut stm, &mut odd) {
|
||||
Ok(_) => {
|
||||
for (num, e) in stm {
|
||||
if sect.get(&num).is_none_or(|old| matches!(old, XrefEntry::Free { .. })) {
|
||||
sect.insert(num, e);
|
||||
match sect.get(&num) {
|
||||
None | Some(XrefEntry::Free { .. }) => {
|
||||
sect.insert(num, e);
|
||||
}
|
||||
// The table already places the object somewhere else.
|
||||
Some(old) if *old != e => odd.push(num),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")),
|
||||
}
|
||||
}
|
||||
oddities.extend(odd.into_iter().map(|num| (off, num)));
|
||||
for (num, e) in sect {
|
||||
entries.entry(num).or_insert(e);
|
||||
}
|
||||
@@ -1016,11 +1061,12 @@ impl Document {
|
||||
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
|
||||
trailer.remove(k);
|
||||
}
|
||||
Ok((entries, trailer, revisions))
|
||||
Ok((entries, trailer, revisions, oddities))
|
||||
}
|
||||
|
||||
/// Read one section at `off`, adding entries not already known (newer sections win).
|
||||
fn read_section(&self, off: usize, entries: &mut BTreeMap<u32, XrefEntry>) -> Result<(Dict, bool), CosError> {
|
||||
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
|
||||
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
|
||||
fn read_section(&self, off: usize, entries: &mut BTreeMap<u32, XrefEntry>, odd: &mut Vec<u32>) -> Result<(Dict, bool), CosError> {
|
||||
let data = &self.data;
|
||||
let mut lx = Lexer::new(data, off);
|
||||
if lx.eat_keyword(b"xref") {
|
||||
@@ -1052,11 +1098,17 @@ impl Document {
|
||||
(b"f", _) => XrefEntry::Free { next_generation: generation },
|
||||
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
|
||||
};
|
||||
// Offset 0 "in use" entries are a known producer bug: ignore them.
|
||||
// Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0
|
||||
// is never a real object, so its entry cannot mislead anyone.)
|
||||
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
|
||||
if num != 0 {
|
||||
odd.push(num);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entries.entry(num).or_insert(entry);
|
||||
if *entries.entry(num).or_insert(entry) != entry {
|
||||
odd.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1110,12 +1162,21 @@ impl Document {
|
||||
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
|
||||
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
|
||||
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
|
||||
_ => continue, // reserved types are treated as null references (§7.5.8.3)
|
||||
// Reserved types are treated as null references (§7.5.8.3).
|
||||
_ => {
|
||||
odd.push(num);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
|
||||
if num != 0 {
|
||||
odd.push(num);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entries.entry(num).or_insert(entry);
|
||||
if *entries.entry(num).or_insert(entry) != entry {
|
||||
odd.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((s.dict.clone(), true))
|
||||
@@ -1263,6 +1324,44 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// Entries the reader sets aside are remembered, so a caller comparing revisions can tell
|
||||
/// that another reader may resolve the object differently.
|
||||
#[test]
|
||||
fn cross_reference_entries_that_are_set_aside_are_recorded() {
|
||||
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
|
||||
let plain = Document::open(Arc::new(base.clone())).unwrap();
|
||||
assert!(plain.xref_oddities().is_empty());
|
||||
assert!((1..=3).all(|n| plain.xref_entry_is_exact(n)));
|
||||
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
|
||||
let third = plain.xref_entry(3).unwrap();
|
||||
|
||||
// A later section: object 3 "in use" at offset 0, object 2 listed twice with different
|
||||
// offsets, and object 0 at offset 0 (the usual producer slip, which names no object).
|
||||
let mut bytes = base.clone();
|
||||
let section = bytes.len();
|
||||
bytes.extend_from_slice(
|
||||
format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]);
|
||||
// What the reader resolves is what it resolved before: the first entry it kept.
|
||||
assert_eq!(doc.xref_entry(3), Some(third));
|
||||
assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 }));
|
||||
}
|
||||
|
||||
/// An entry that misses its object still resolves (by searching) but is not exact.
|
||||
#[test]
|
||||
fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() {
|
||||
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
|
||||
let text = String::from_utf8_lossy(&base).into_owned();
|
||||
let at = text.find("3 0 obj").unwrap();
|
||||
let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec()));
|
||||
assert!(!doc.xref_entry_is_exact(3));
|
||||
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_xref_object_ranges_are_reconstructed() {
|
||||
// Original in-memory PDF: an intact catalog followed by a damaged xref section.
|
||||
|
||||
@@ -773,6 +773,74 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u
|
||||
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
|
||||
}
|
||||
|
||||
/// Fails when the renderer still draws text under an applied redaction mark.
|
||||
///
|
||||
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
|
||||
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
|
||||
/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`,
|
||||
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
|
||||
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
|
||||
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
|
||||
/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its
|
||||
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
|
||||
/// the document as it was.
|
||||
fn shown_under_redactions(
|
||||
after: &pdfcraft_cos::Document,
|
||||
marks: &[pdfcraft_redact::Mark],
|
||||
pages: &[pdfcraft_render::PageInfo],
|
||||
password: Option<&str>,
|
||||
) -> Result<(), EditError> {
|
||||
use pdfcraft_cos::Object;
|
||||
use pdfcraft_redact::RedactError;
|
||||
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
|
||||
marked.sort_unstable();
|
||||
marked.dedup();
|
||||
if marked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut shown = after.clone();
|
||||
let model = pdfcraft_model::pages(&shown);
|
||||
for &pi in &marked {
|
||||
let Some(page) = model.get(pi) else { continue };
|
||||
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
|
||||
let kept: Vec<Object> =
|
||||
list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect();
|
||||
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
|
||||
let mut residue = 0;
|
||||
for &pi in &marked {
|
||||
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
|
||||
// A page that cannot be read back cannot be confirmed clean.
|
||||
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
|
||||
let rects: Vec<[f64; 4]> = marks
|
||||
.iter()
|
||||
.filter(|m| m.page == pi)
|
||||
.flat_map(|m| m.rects.iter())
|
||||
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
|
||||
.collect();
|
||||
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
|
||||
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
|
||||
let area = (x1 - x0) * (y1 - y0);
|
||||
if area.is_nan() || area <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Redaction itself takes any glyph a fifth under a mark; one that is half under it
|
||||
// and still drawn was not where redaction thought it was.
|
||||
let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area;
|
||||
if rects.iter().any(under) {
|
||||
residue += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if residue > 0 {
|
||||
return Err(RedactError::Residue(residue).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_threads() -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
|
||||
}
|
||||
@@ -3260,6 +3328,14 @@ impl Session {
|
||||
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
|
||||
guard(|| run_edit(&mut next, &edit, &mut cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
// Redaction removed what its own interpreter places under the marks. The marks were drawn
|
||||
// over the page as the renderer shows it, so the renderer has the last word.
|
||||
if let Edit::ApplyRedactions { pages } = &edit {
|
||||
let marks: Vec<pdfcraft_redact::Mark> =
|
||||
pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect();
|
||||
guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref()))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
}
|
||||
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
|
||||
// scripts were left alone) and a count in the undo step's label.
|
||||
let renamed_note = |renamed: &[(String, String)]| {
|
||||
|
||||
@@ -1522,6 +1522,72 @@ fn preparing_a_form_adds_renames_and_deletes_fields() {
|
||||
assert_eq!(names(&s), ["Text1", "Check Box1", "email"]);
|
||||
}
|
||||
|
||||
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
|
||||
/// the page's /Resources holds.
|
||||
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does.
|
||||
fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
|
||||
let text = r.render(RenderRequestFor::text(0)).text.expect("the page has text");
|
||||
let range = text.find(needle).pop().expect("the page shows the text");
|
||||
let info = &doc.info.pages[0];
|
||||
let quads = text.line_rects(range).into_iter().map(|v| pdfcraft_annot::rect_quad(info.view_rect_to_user(v).map(f64::from))).collect();
|
||||
let shape = Shape::Redact { quads, overlay: String::new(), look: Default::default() };
|
||||
let mark =
|
||||
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() });
|
||||
s.apply(id, mark).unwrap();
|
||||
}
|
||||
|
||||
/// Redaction removes text by the positions its own interpreter works out. Here the font and size
|
||||
/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does
|
||||
/// not model, so it places no glyph under the mark the user drew over the word as displayed: it
|
||||
/// used to report success and leave the word in the file. The renderer is asked now, and the edit
|
||||
/// is refused.
|
||||
#[test]
|
||||
fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() {
|
||||
let line = "Public words then SECRET42 and more";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"));
|
||||
let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap();
|
||||
mark_shown_text(&mut s, id, "SECRET42");
|
||||
let refused = s.apply(id, Edit::ApplyRedactions { pages: None });
|
||||
assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}");
|
||||
// Nothing was changed: the mark is still pending and the page still shows the line.
|
||||
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
|
||||
assert_eq!(page_texts(&s, id), [line]);
|
||||
|
||||
// The same line with its font set by `Tf`: the mark is applied and the word is gone.
|
||||
let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET"));
|
||||
let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap();
|
||||
mark_shown_text(&mut s, id, "SECRET42");
|
||||
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
|
||||
let shown = page_texts(&s, id).swap_remove(0);
|
||||
assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_marks_apply_for_good_and_undo() {
|
||||
let (mut s, id) = session_with(2);
|
||||
|
||||
+124
-1
@@ -23,7 +23,7 @@ use std::rc::Rc;
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use crate::{Report, image, tags};
|
||||
use crate::{RedactError, Report, image, page_streams, tags};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
|
||||
const MAX_DEPTH: usize = 12;
|
||||
@@ -62,6 +62,10 @@ pub(crate) struct Output {
|
||||
/// can unlink the original from the resource dictionary (a full save keeps whatever is still
|
||||
/// referenced, which would otherwise retain the removed image or form bytes).
|
||||
pub replaced: Vec<Vec<u8>>,
|
||||
/// The original objects behind `replaced`, nested scopes included. Other resource
|
||||
/// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a
|
||||
/// form has none of its own): [`unlink_doomed`] removes those entries.
|
||||
pub doomed: Vec<ObjRef>,
|
||||
/// Glyphs / inline images still overlapping a region (verify mode).
|
||||
pub residue: usize,
|
||||
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
|
||||
@@ -203,6 +207,117 @@ pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec<u8>], content: &[Vec<u8>]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or
|
||||
/// through a form that has no /Resources of its own (such a form draws through its caller's).
|
||||
/// `None` when that cannot be told (a form that will not decode, or nesting past the limit).
|
||||
fn content_draws(doc: &Document, content: &[Vec<u8>], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet<ObjRef>) -> Option<bool> {
|
||||
let joined = Pieces::join(content);
|
||||
let ops = joined.parse().ops;
|
||||
for op in ops.iter().filter(|op| op.is("Do")) {
|
||||
let Some(n) = op.name(0) else { continue };
|
||||
if n == name {
|
||||
return Some(true);
|
||||
}
|
||||
let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let Ok(data) = s.decoded() else { return None };
|
||||
match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) {
|
||||
Some(false) => {}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource
|
||||
/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the
|
||||
/// originals stay reachable, and so stay in a full-rewrite save, when:
|
||||
/// - the page inherited its /Resources from a /Pages node, which still lists them;
|
||||
/// - several pages share one resource dictionary, and another page still refers to it;
|
||||
/// - a form without /Resources drew them through the page's dictionary.
|
||||
///
|
||||
/// An entry goes unless a page that draws through that dictionary still draws the name (the same
|
||||
/// image shown on another page, or again outside a mark). A dictionary that loses an entry is
|
||||
/// replaced by a private copy on its node, so a dictionary object shared with other nodes is
|
||||
/// left as it is for them. Fails closed when a page's use of a name cannot be told.
|
||||
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> {
|
||||
if doomed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let doomed: HashSet<ObjRef> = doomed.iter().copied().collect();
|
||||
let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) };
|
||||
// Every page-tree node with a /Resources of its own, and the pages that draw through it
|
||||
// (the nearest such node above a page, or the page itself). Cycles are cut as in
|
||||
// `pdfcraft_model::pages`.
|
||||
let mut holders: Vec<(ObjRef, Vec<ObjRef>)> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack: Vec<(ObjRef, Option<usize>)> = vec![(top, None)];
|
||||
while let Some((node, mut holder)) = stack.pop() {
|
||||
if !seen.insert(node) || seen.len() > 1_000_000 {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
if d.contains(b"Resources") {
|
||||
holders.push((node, Vec::new()));
|
||||
holder = Some(holders.len() - 1);
|
||||
}
|
||||
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
|
||||
if is_pages {
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder)));
|
||||
} else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) {
|
||||
h.1.push(node);
|
||||
}
|
||||
}
|
||||
let index: HashMap<ObjRef, usize> = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
|
||||
for (node, users) in holders {
|
||||
let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
|
||||
let mut xo = res_dict(doc, &res, b"XObject");
|
||||
let names: Vec<Vec<u8>> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect();
|
||||
if names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut contents = Vec::with_capacity(users.len());
|
||||
for user in &users {
|
||||
let page = doc.get(*user).as_dict().cloned().unwrap_or_default();
|
||||
// A page whose content cannot be read may still draw the name: fail closed.
|
||||
contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1);
|
||||
}
|
||||
let mut pruned = false;
|
||||
for n in names {
|
||||
let mut drawn = Some(false);
|
||||
for content in &contents {
|
||||
drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new());
|
||||
if drawn != Some(false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match drawn {
|
||||
Some(true) => {}
|
||||
Some(false) => {
|
||||
xo.remove(&n);
|
||||
pruned = true;
|
||||
}
|
||||
None => return Err(RedactError::Residue(1)),
|
||||
}
|
||||
}
|
||||
if pruned {
|
||||
let mut res = res;
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The clip that hides everything inside each region: one even-odd clip per region (a big
|
||||
/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM
|
||||
/// can't be inverted (nothing is painted then anyway).
|
||||
@@ -607,6 +722,7 @@ fn xobject(
|
||||
}
|
||||
scope.layer_blocks += 1;
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
Some(None)
|
||||
}
|
||||
Some(b"Image") => {
|
||||
@@ -617,6 +733,7 @@ fn xobject(
|
||||
if scope.covered(b) {
|
||||
scope.report.images_removed += 1;
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
return Some(None);
|
||||
}
|
||||
match image::clear(doc, s, &gs.ctm, scope.rects) {
|
||||
@@ -627,12 +744,14 @@ fn xobject(
|
||||
xobjects.set(n.clone(), Object::Ref(nr));
|
||||
out.xobjects.push((n.clone(), nr));
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
scope.report.images_cleared += 1;
|
||||
Some(Some(Op::new("Do", vec![Object::Name(n)])))
|
||||
}
|
||||
Err(()) => {
|
||||
scope.report.images_removed += 1;
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
Some(None)
|
||||
}
|
||||
}
|
||||
@@ -656,6 +775,7 @@ fn xobject(
|
||||
}
|
||||
scope.report.forms_removed += 1;
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
return Some(None);
|
||||
}
|
||||
let data = match s.decoded() {
|
||||
@@ -663,6 +783,7 @@ fn xobject(
|
||||
Err(_) if scope.mode == Mode::Apply => {
|
||||
scope.report.forms_removed += 1;
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
return Some(None);
|
||||
}
|
||||
Err(_) => return None,
|
||||
@@ -676,6 +797,7 @@ fn xobject(
|
||||
scope.fonts = saved_fonts;
|
||||
out.residue += inner.residue;
|
||||
out.vertical |= inner.vertical;
|
||||
out.doomed.extend_from_slice(&inner.doomed);
|
||||
out.tag_changes.extend(inner.tag_changes);
|
||||
if scope.mode == Mode::Verify {
|
||||
return None;
|
||||
@@ -711,6 +833,7 @@ fn xobject(
|
||||
xobjects.set(n.clone(), Object::Ref(nr));
|
||||
out.xobjects.push((n.clone(), nr));
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
scope.report.forms_rewritten += 1;
|
||||
Some(Some(Op::new("Do", vec![Object::Name(n)])))
|
||||
}
|
||||
|
||||
@@ -388,6 +388,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
report.pages = by_page.len();
|
||||
localize_resources(doc, &by_page)?;
|
||||
let mut doomed_fields: Vec<String> = Vec::new();
|
||||
// The original image and form XObjects the rewrite drops or replaces, on any page.
|
||||
let mut doomed_xobjects: Vec<ObjRef> = Vec::new();
|
||||
let widget_owner: Vec<(ObjRef, String)> =
|
||||
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
|
||||
|
||||
@@ -410,6 +412,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
if out.vertical {
|
||||
return Err(RedactError::VerticalText(pi + 1));
|
||||
}
|
||||
doomed_xobjects.extend_from_slice(&out.doomed);
|
||||
let mut new_list = list.clone();
|
||||
let mut changed = false;
|
||||
for (i, new) in out.streams.into_iter().enumerate() {
|
||||
@@ -560,6 +563,10 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
}
|
||||
}
|
||||
|
||||
// A removed image or form must not stay reachable through another resource dictionary (an
|
||||
// inherited or shared /Resources): a full save keeps whatever is still referenced.
|
||||
interp::unlink_doomed(doc, &doomed_xobjects)?;
|
||||
|
||||
// 5. Verify: nothing readable may remain under a region.
|
||||
let mut residue = 0;
|
||||
for &pi in &by_page {
|
||||
|
||||
@@ -144,6 +144,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
return Ok(0);
|
||||
}
|
||||
let mut total = 0;
|
||||
// The original image and form XObjects the rewrite drops or replaces, on any page.
|
||||
let mut doomed = Vec::new();
|
||||
let n = pdfcraft_model::pages(doc).len();
|
||||
for pi in 0..n {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(pi);
|
||||
@@ -168,6 +170,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
if !write {
|
||||
continue;
|
||||
}
|
||||
doomed.extend_from_slice(&out.doomed);
|
||||
let mut new_list = list.clone();
|
||||
let mut changed = false;
|
||||
for (i, new) in out.streams.into_iter().enumerate() {
|
||||
@@ -205,6 +208,9 @@ 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)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,120 @@ fn redacted_images_and_forms_do_not_survive_a_full_rewrite_save() {
|
||||
assert_eq!(s.decoded().unwrap(), imkeep, "the unrelated image survives intact");
|
||||
}
|
||||
|
||||
/// Two pages that draw through one resource dictionary: inherited from the /Pages node, or one
|
||||
/// dictionary object both pages refer to. Page 1 draws a covered image, a partly covered image
|
||||
/// and a form whose text is under the mark; page 2 draws /ImKeep, and /Im1 too when `again`.
|
||||
fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document {
|
||||
let res = "<< /Font << /F1 5 0 R >> /XObject << /Im1 7 0 R /ImPart 8 0 R /FmSecret 9 0 R /ImKeep 12 0 R >> >>";
|
||||
let (pages_res, page_res) = if inherited { (format!("/Resources {res}"), "") } else { (String::new(), "/Resources 13 0 R") };
|
||||
let page2: &[u8] =
|
||||
if again { b"q 50 0 0 50 20 20 cm /ImKeep Do Q q 50 0 0 50 120 20 cm /Im1 Do Q" } else { b"q 50 0 0 50 20 20 cm /ImKeep Do Q" };
|
||||
pdf(vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
format!("<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 2 {pages_res} >>").into_bytes(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R {page_res} >>").into_bytes(),
|
||||
stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q q 50 0 0 100 100 180 cm /ImPart Do Q /FmSecret Do"),
|
||||
FONT.replace("95 0 R", "6 0 R").into_bytes(),
|
||||
widths(),
|
||||
stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"),
|
||||
stream("/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGPARTSECRETPUBPARTBOTTOMROW"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>",
|
||||
b"BT /F1 10 Tf 20 230 Td (REDFORMSECRETTEXT) Tj ET",
|
||||
),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 11 0 R {page_res} >>").into_bytes(),
|
||||
stream("", page2),
|
||||
stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGKEEPSURVIVE"),
|
||||
// 13: the dictionary both pages refer to (unused when the resources are inherited).
|
||||
if inherited { b"<< >>".to_vec() } else { res.as_bytes().to_vec() },
|
||||
])
|
||||
}
|
||||
|
||||
/// The samples of the image a page's (possibly inherited) resources name `name`.
|
||||
fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(page);
|
||||
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
|
||||
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
|
||||
match &*doc.get(xo.get(name)?.as_ref()?) {
|
||||
Object::Stream(s) => s.decoded().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
|
||||
/// before (inherited from the /Pages node, or shared with another page) still named the removed
|
||||
/// image and form, so a full-rewrite save kept them.
|
||||
#[test]
|
||||
fn redacted_xobjects_do_not_survive_through_inherited_or_shared_resources() {
|
||||
for inherited in [true, false] {
|
||||
let mut doc = two_pages_one_resource_dictionary(inherited, false);
|
||||
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert!(r.images_removed >= 1 && r.forms_rewritten >= 1, "{r:?}");
|
||||
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
|
||||
for secret in [&b"REDIMGONECOVERED"[..], b"REDIMGPARTSECRET", b"REDFORMSECRETTEXT"] {
|
||||
assert!(!has(secret), "{} survived the save (inherited: {inherited})", String::from_utf8_lossy(secret));
|
||||
}
|
||||
// Page 2 still draws its own image through the same dictionary.
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(named_image(&saved, 1, b"ImKeep").as_deref(), Some(&b"PUBIMGKEEPSURVIVE"[..]), "inherited: {inherited}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same image shown on another page is not under a mark there: that page keeps it.
|
||||
#[test]
|
||||
fn an_image_another_page_still_draws_through_the_same_resources_is_kept() {
|
||||
for inherited in [true, false] {
|
||||
let mut doc = two_pages_one_resource_dictionary(inherited, true);
|
||||
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(named_image(&saved, 1, b"Im1").as_deref(), Some(&b"REDIMGONECOVERED"[..]), "inherited: {inherited}");
|
||||
assert_eq!(named_image(&saved, 0, b"Im1"), None, "the redacted page no longer names it (inherited: {inherited})");
|
||||
assert!(content(&saved, 1).contains("/Im1 Do"), "page 2 is untouched");
|
||||
}
|
||||
}
|
||||
|
||||
/// A form without /Resources draws through its caller's: the image it drew under the mark is
|
||||
/// named by the page's own dictionary, which no `Do` of the page itself ever used.
|
||||
#[test]
|
||||
fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() {
|
||||
let mut doc = one_page(
|
||||
b"/FmBare Do",
|
||||
"/XObject << /FmBare 7 0 R /Im1 8 0 R >>",
|
||||
vec![
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 20 230 cm /Im1 Do Q"),
|
||||
stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"),
|
||||
],
|
||||
);
|
||||
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.images_removed, 1, "{r:?}");
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save");
|
||||
}
|
||||
|
||||
/// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited
|
||||
/// /Resources must leave the saved file too.
|
||||
#[test]
|
||||
fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let mut doc = pdf(vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /Resources << /XObject << /ImOC 5 0 R >> >> >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(),
|
||||
stream("", b"q 50 0 0 50 20 20 cm /ImOC Do Q"),
|
||||
stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC 6 0 R", b"HIDDENLAYERIMAGE"),
|
||||
b"<< /Type /OCG /Name (Secret layer) >>".to_vec(),
|
||||
]);
|
||||
let done = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
|
||||
assert!(done.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{done:?}");
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -1220,6 +1220,18 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
// A later cross-reference section may carry an entry this reader sets aside (an in-use entry
|
||||
// at offset 0, a second entry for one object, a reserved entry type). The renderer may follow
|
||||
// it and draw another object than the one compared below, so the page could show new content
|
||||
// while every comparison here finds the signed object untouched. No writer has a reason to
|
||||
// add such an entry after signing: it is never a permitted change.
|
||||
for (_, num) in doc.xref_oddities().iter().filter(|o| !old.xref_oddities().contains(o)) {
|
||||
let r = ObjRef { num: *num, generation: doc.generation(*num) };
|
||||
let what = if old_content.contains(&r) || signed_pages.contains(&r) { kind::PAGE_CONTENT } else { kind::OTHER };
|
||||
if !disallowed.contains(&what) {
|
||||
disallowed.push(what);
|
||||
}
|
||||
}
|
||||
for num in doc.object_numbers() {
|
||||
if same_place(num) {
|
||||
continue;
|
||||
@@ -1228,6 +1240,12 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
let new = doc.get(r);
|
||||
let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null));
|
||||
if before.as_deref() == Some(&*new) {
|
||||
// The same content at a new place. That is true for the renderer as well only when
|
||||
// the entry locates the object itself: where this reader had to search for the
|
||||
// object, another reader may arrive at a different one.
|
||||
if !doc.is_edited(num) && !doc.xref_entry_is_exact(num) && !disallowed.contains(&kind::OTHER) {
|
||||
disallowed.push(kind::OTHER);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let what = match &*new {
|
||||
|
||||
@@ -1900,3 +1900,81 @@ fn details_say_themselves() {
|
||||
assert_eq!(holes, wanted, "{:?} has {holes} holes", d.pattern);
|
||||
}
|
||||
}
|
||||
|
||||
/// `signed` with one more revision appended by hand: a new definition of object 4 (the page's
|
||||
/// content stream) that draws `text`, then a cross-reference table section whose subsections are
|
||||
/// what `xref` returns for the offset of that definition. The section continues the signed chain.
|
||||
fn append_content_revision(signed: &[u8], text: &str, xref: impl Fn(usize) -> String) -> Vec<u8> {
|
||||
let at = signed.windows(9).rposition(|w| w == b"startxref").unwrap() + 9;
|
||||
let prev: String = signed[at..].iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect();
|
||||
let mut out = signed.to_vec();
|
||||
let content = format!("BT /F1 14 Tf 20 250 Td ({text}) Tj ET");
|
||||
let object = out.len();
|
||||
out.extend_from_slice(format!("4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content.len()).as_bytes());
|
||||
let section = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("xref\n{}trailer\n<< /Size 60 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n", xref(object)).as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn xref_entry(offset: usize) -> String {
|
||||
format!("{offset:010} 00000 n \n")
|
||||
}
|
||||
|
||||
/// The reader that judges changes and the renderer that draws the page resolve some
|
||||
/// cross-reference entries differently. A revision appended after signing can redefine the page's
|
||||
/// content and give the object an entry the reader sets aside (an in-use entry at offset 0, or
|
||||
/// a second entry after one that still names the signed object): the reader then keeps comparing
|
||||
/// the signed object with itself while the page shows the new one. Such an entry is never a
|
||||
/// permitted change.
|
||||
#[test]
|
||||
fn a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content() {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
for certify in LEVELS {
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap();
|
||||
// Where the signed revision keeps the page's content stream.
|
||||
let signed_at = signed.windows(8).position(|w| w == b"\n4 0 obj").unwrap() + 1;
|
||||
// Each writes the section's subsections, given where the new object 4 was put.
|
||||
type Subsections = Box<dyn Fn(usize) -> String>;
|
||||
let attacks: [(&str, Subsections); 3] = [
|
||||
("an in-use entry at offset 0", Box::new(|_| format!("4 1\n{}", xref_entry(0)))),
|
||||
("a second entry for the object", Box::new(move |new| format!("4 1\n{}4 1\n{}", xref_entry(signed_at), xref_entry(new)))),
|
||||
// The plain case, which was always caught: one entry naming the new object.
|
||||
("an ordinary entry", Box::new(|new| format!("4 1\n{}", xref_entry(new)))),
|
||||
];
|
||||
for (name, xref) in attacks {
|
||||
let edited = append_content_revision(&signed, "Forged text", xref);
|
||||
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{certify:?}, {name}: {:?}", s.details);
|
||||
assert!(
|
||||
matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")),
|
||||
"{certify:?}, {name}: {:?}",
|
||||
s.modification
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry that does not point at the object it names leaves the reader to find the object by
|
||||
/// searching the file. What it finds may equal the signed object, yet another reader may search
|
||||
/// differently: the object cannot be called unchanged.
|
||||
#[test]
|
||||
fn a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object() {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
// A copy of the signed content, named by an entry two bytes into its `4 0 obj` header.
|
||||
let edited = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new + 2)));
|
||||
let doc = open(&edited);
|
||||
assert!(!doc.xref_entry_is_exact(4) && doc.xref_entry_is_exact(3));
|
||||
let s = signatures(&doc, &edited, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification);
|
||||
|
||||
// The same copy under an entry that does point at it is the unchanged object it looks like.
|
||||
let honest = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new)));
|
||||
let s = signatures(&open(&honest), &honest, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
}
|
||||
@@ -5656,8 +5656,8 @@ milestone = "M8"
|
||||
status = "shipped"
|
||||
commands = ["redact.apply"]
|
||||
tools = ["redact_apply"]
|
||||
evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/automation.rs::redacting_through_tools"]
|
||||
notes = "confirmation; one undo step; next save is a full rewrite"
|
||||
evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"]
|
||||
notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.redact-glyph-removal"
|
||||
|
||||
Reference in new issue
Block a user