diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 6938f82b1..0f05661b2 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -105,6 +105,8 @@ pub struct Document { entries: Arc>, trailer: Dict, revisions: Arc>, + /// See [`Self::xref_oddities`]. + xref_oddities: Arc>, repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, @@ -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) -> Result<(BTreeMap, Dict, Vec), CosError> { + fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec, 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 = 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) -> 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, odd: &mut Vec) -> 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. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9073115e1..74b569af4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -773,6 +773,74 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = 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 = + 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::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)]| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 672c654f3..35b848009 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { + 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); diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index d70880832..c76e11995 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -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>, + /// 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, /// 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], content: &[Vec]) { } } +/// 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], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { + 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 = 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)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = 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 = 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> = 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)]))) } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 15f839691..7b09602cd 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -388,6 +388,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); + // The original image and form XObjects the rewrite drops or replaces, on any page. + let mut doomed_xobjects: Vec = 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::>()).collect(); @@ -410,6 +412,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result 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) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 81b3b328c..2983b3d83 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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> { + 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 diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index cf608c1f9..012cd43a6 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1220,6 +1220,18 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> 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, p: Option) -> 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 { diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index d4cd2bae3..609926f3b 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -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 { + 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 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); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e36d1420a..1464b18f4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"