use std::sync::Arc; use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use pdfcraft_testkit::{Pdf, stream_obj as stream}; use super::*; fn pdf(objs: Vec>) -> Document { Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap() } /// A font where every glyph is 500 units wide (so a 10 pt glyph advances 5 pt). const FONT: &str = "<< /Type /Font /Subtype /TrueType /BaseFont /Arial /FirstChar 32 /LastChar 126 /Widths 95 0 R /FontDescriptor << /Ascent 800 /Descent -200 >> >>"; fn widths() -> Vec { format!("[{}]", vec!["500"; 95].join(" ")).into_bytes() } /// One 300×300 page with `content`, font /F1, and extra resources/objects. fn one_page(content: &[u8], extra_res: &str, extra: Vec>) -> Document { let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> {extra_res} >> >>") .into_bytes(), stream("", content), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), ]; objs.extend(extra); pdf(objs) } fn mark(doc: &mut Document, page: usize, rects: &[[f64; 4]], overlay: &str) { let shape = Shape::Redact { quads: rects.iter().map(|r| rect_quad(*r)).collect(), overlay: overlay.into(), look: Default::default() }; let style = Style::default_for(&shape); add_annotation(doc, &NewAnnotation { page, shape, style, contents: String::new(), author: "Tester".into() }, &Meta::default()).unwrap(); } /// The decoded content streams of a page, joined. fn content(doc: &Document, page: usize) -> String { let p = &pdfcraft_model::pages(doc)[page]; let (_, data) = page_streams(doc, &p.dict, page).unwrap(); data.iter().map(|d| String::from_utf8_lossy(d).into_owned()).collect::>().join("\n") } /// Glyphs (and inline images) of page `page` under `rects` (the verifier's count). fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let p = pdfcraft_model::pages(doc).swap_remove(page); let mut budget = pdfcraft_cos::DecodeBudget::page(); let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } fn reopen(doc: &Document) -> Document { Document::open(Arc::new(write_incremental(doc, &SaveOptions::default()).unwrap())).unwrap() } #[test] fn glyphs_under_a_mark_go_and_the_rest_stays_put() { // "AB1234CD" from x = 10 at 10 pt: each glyph 5 pt wide; 1234 spans x 20–40. let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]]), 2, "C and D before"); mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.marks, r.glyphs), (1, 4)); let c = content(&doc, 0); assert!(c.contains("[(AB) -2000 (CD)] TJ"), "{c}"); assert!(!c.contains("1234")); assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]]), 2, "C and D are where they were"); assert_eq!(under(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]]), 0); // The mark became a black box drawn into the page; the annotation is gone. assert!(marks(&doc).is_empty()); assert!(c.contains("0 0 0 rg") && c.contains("20 95 20 15 re f"), "{c}"); let doc = reopen(&doc); assert!(!content(&doc, 0).contains("1234")); } /// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a /// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to /// lie past a mark that covers all of it on screen, and was kept. #[test] fn a_whole_line_under_a_mark_goes_even_without_widths() { let text = b"illicit little lilies"; let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>"; let mut doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()), font.as_bytes().to_vec(), b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(), stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR), ]); // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126. let line = [5.0, 95.0, 90.0, 112.0]; mark(&mut doc, 0, &[line], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.glyphs, text.len(), "every glyph of the line goes"); assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); } #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. let mut doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents [4 0 R] /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), stream("/PCMark /Added /PCAdded << /Kind /Text /Text (AB1234CD) >>", b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), ]); mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); apply(&mut doc, None).unwrap(); assert!(!content(&doc, 0).contains("1234")); let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); } #[test] fn operators_split_across_content_streams_are_redacted_whole() { // One content stream in three pieces, split between tokens: a marked-content dictionary // ends in the second piece, and the TJ array that ends it shows its glyphs with the // operator in the third. let mut doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents [4 0 R 7 0 R 8 0 R] /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), stream("", b"/P << /MCID 0"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), stream("", b">> BDC BT /F1 10 Tf 10 100 Td [(AB1234CD)]"), stream("", b"TJ ET EMC BT /F1 10 Tf 10 200 Td (KEEP) Tj ET"), ]); assert_eq!(under(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]]), 4, "the verifier sees the split TJ"); mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.glyphs, 4, "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("1234"), "redacted glyphs left in the page: {c}"); assert!(c.contains("KEEP"), "{c}"); assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]]), 2, "C and D are where they were"); // Every operator still has its operands: none was cut off from them. for op in pdfcraft_content::parse(c.as_bytes()).ops { let want = match op.op.as_slice() { b"TJ" | b"Tj" => 1, b"BDC" | b"Tf" | b"Td" => 2, _ => continue, }; assert_eq!(op.operands.len(), want, "{} lost its operands in {c}", String::from_utf8_lossy(&op.op)); } let doc = reopen(&doc); assert!(!content(&doc, 0).contains("1234")); } #[test] fn kerning_spacing_scaling_and_line_operators_are_honoured() { // TJ kerning, character and word spacing, 50% horizontal scaling, ' and ". let src = b"BT /F1 10 Tf 2 Tc 4 Tw 50 Tz 12 TL 0 200 Td [(AB) -1000 (C D)] TJ (EF) ' 1 0 (GH) \" ET"; let mut doc = one_page(src, "", vec![]); // Line 1 (y 200): A at 0, B at 3.5 (advance (5+2)×0.5), kern +5 → C at 12, space at 15.5, // D at 21 (space advance (5+2+4)×0.5 = 5.5). Remove C only. mark(&mut doc, 0, &[[12.2, 195.0, 14.8, 210.0]], ""); // Line 2 (y 188) "EF" and line 3 (y 176) "GH" (Tw 1, Tc 0): remove F and G. mark(&mut doc, 0, &[[3.8, 183.0, 6.0, 198.0 - 10.0], [0.2, 171.0, 2.3, 180.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.glyphs, 3, "{r:?}"); let c = content(&doc, 0); assert!(c.contains("[(AB) -1700 ( D)] TJ"), "{c}"); assert!(c.contains("T*\n[(E) -700] TJ"), "{c}"); assert!(c.contains("1 Tw\n0 Tc\nT*\n[-500 (H)] TJ"), "{c}"); // Everything else stays readable where it was. assert_eq!(under(&mut doc, 0, &[[20.0, 195.0, 30.0, 210.0]]), 1, "D"); assert_eq!(under(&mut doc, 0, &[[2.6, 171.0, 6.0, 180.0]]), 1, "H"); } #[test] fn rotated_text_and_composite_fonts() { // A Type0 Identity-H font (two-byte codes) with /W widths, and a 90° text matrix. let t0 = b"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 1000 /W [1 [500 500] 3 4 250] >>] >>".to_vec(); let src = b"BT /F2 10 Tf 0 1 -1 0 100 50 Tm <0001000200030004> Tj ET"; let mut doc = one_page(src, "", vec![]); let font_ref = doc.add(Object::Dict(Dict::new())); let parsed = { let mut lx = pdfcraft_cos::Lexer::new(&t0, 0); lx.object().unwrap() }; doc.set(font_ref, parsed); let page = pdfcraft_model::pages(&doc)[0].obj; doc.update_dict(page, |d| { let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); let mut fonts = res.get(b"Font").and_then(Object::as_dict).cloned().unwrap(); fonts.set(b"F2".to_vec(), Object::Ref(font_ref)); res.set(b"Font".to_vec(), Object::Dict(fonts)); d.set(b"Resources".to_vec(), Object::Dict(res)); }) .unwrap(); // Glyphs run upwards from y 50: CID1 50–55, CID2 55–60, CID3 60–62.5, CID4 62.5–65. mark(&mut doc, 0, &[[85.0, 55.5, 105.0, 61.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.glyphs, 2, "CIDs 2 and 3"); let c = content(&doc, 0); assert!(c.contains("[<0001> -750 <0004>] TJ") || c.contains("[(\\000\\001) -750 (\\000\\004)] TJ"), "{c}"); } #[test] fn vertical_writing_text_under_a_mark_fails_closed() { // A Type0 font laid out vertically (Identity-V / CMap WMode 1) cannot have its glyphs placed // under a mark by the horizontal interpreter, so Apply Redactions must fail rather than ship // the text under the box. The horizontal control (Identity-H) is redacted normally. let make = |encoding: &str| -> Document { let t0 = format!( "<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /{encoding} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 1000 /W [1 [500 500] 3 4 250] >>] >>" ) .into_bytes(); let src = b"BT /F2 10 Tf 0 1 -1 0 100 50 Tm <0001000200030004> Tj ET"; let mut doc = one_page(src, "", vec![]); let font_ref = doc.add(Object::Dict(Dict::new())); let parsed = { let mut lx = pdfcraft_cos::Lexer::new(&t0, 0); lx.object().unwrap() }; doc.set(font_ref, parsed); let page = pdfcraft_model::pages(&doc)[0].obj; doc.update_dict(page, |d| { let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); let mut fonts = res.get(b"Font").and_then(Object::as_dict).cloned().unwrap(); fonts.set(b"F2".to_vec(), Object::Ref(font_ref)); res.set(b"Font".to_vec(), Object::Dict(fonts)); d.set(b"Resources".to_vec(), Object::Dict(res)); }) .unwrap(); doc }; // Identity-V: apply must refuse rather than report success with the text still in the file. let mut vdoc = make("Identity-V"); mark(&mut vdoc, 0, &[[85.0, 55.5, 105.0, 61.0]], ""); let err = apply(&mut vdoc, None).unwrap_err(); assert_eq!(err, RedactError::VerticalText(1), "vertical text with a pending mark must not silently pass"); let msg = err.to_string(); assert!(msg.contains("cannot tell whether"), "{msg}"); assert!(!msg.contains("text under a redaction mark"), "{msg}"); // Identity-H control: the same glyphs and mark are redacted normally. let mut hdoc = make("Identity-H"); mark(&mut hdoc, 0, &[[85.0, 55.5, 105.0, 61.0]], ""); let r = apply(&mut hdoc, None).unwrap(); assert_eq!(r.glyphs, 2, "the horizontal control is redacted: {r:?}"); } #[test] fn images_are_removed_or_have_their_pixels_cleared() { // Image 1 is fully covered; image 2 (4×1 gray, all white) is half covered. let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]); let src = b"q 10 0 0 10 10 10 cm /Im1 Do Q q 40 0 0 10 100 100 cm /Im2 Do Q"; let mut doc = one_page(src, "/XObject << /Im1 7 0 R /Im2 7 0 R >>", vec![img]); mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0], [95.0, 95.0, 120.0, 115.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.images_removed, r.images_cleared), (1, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("/Im1 Do") && !c.contains("/Im2 Do"), "{c}"); let p = pdfcraft_model::pages(&doc).swap_remove(0); let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); let new = xo.iter().find(|(k, _)| k.starts_with(b"PCRedacted")).map(|(_, v)| v.clone()).expect("a cleared copy"); let Object::Stream(s) = &*doc.resolve(&new) else { panic!() }; assert_eq!(s.decoded().unwrap(), [0, 0, 255, 255], "pixels 1–2 (x 100–120) cleared"); let Object::Stream(orig) = &*doc.get(ObjRef::new(7, 0)) else { panic!() }; assert_eq!(orig.decoded().unwrap(), [255; 4], "the shared original is untouched"); } #[test] fn vectors_are_removed_or_clipped_and_inline_images_go() { let src = b"0 g 10 10 20 20 re f 0 0 300 300 re f q 10 0 0 10 50 50 cm BI /W 1 /H 1 /CS /G /BPC 8 ID \x80 EI Q 1 0 0 RG 150 150 m 160 160 l S"; let mut doc = one_page(src, "", vec![]); mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("10 10 20 20 re"), "{c}"); assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); assert!(!c.contains("BI"), "{c}"); assert!(c.contains("150 150 m"), "the line elsewhere stays"); } /// A chain of nested forms deeper than the pass can walk was removed from the page, taking /// whatever else it drew with it even when nothing in it was marked. The limit now reaches as /// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; /// past that nothing is drawn either, so removing it costs nothing visible (#3645). #[test] fn a_deeply_nested_form_keeps_the_text_nobody_marked() { /// A page that draws `HEAD` at the top, then a chain of `depth` forms whose innermost draws /// `DEEP`, then `TAIL`. Only `HEAD` is marked. fn page_with_chain(depth: usize) -> Document { let first = 7; let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), format!( "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 {first} 0 R >> >> >>" ) .into_bytes(), stream("", b"BT /F1 10 Tf 10 280 Td (HEAD) Tj ET /Fm1 Do BT /F1 10 Tf 10 10 Td (TAIL) Tj ET"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), ]; for k in 1..=depth { let res = if k < depth { format!("/Resources << /Font << /F1 5 0 R >> /XObject << /Fm{} {} 0 R >> >>", k + 1, first + k) } else { "/Resources << /Font << /F1 5 0 R >> >>".to_string() }; let body: Vec = if k < depth { format!("/Fm{} Do", k + 1).into_bytes() } else { b"BT /F1 10 Tf 10 150 Td (DEEP) Tj ET".to_vec() }; objs.push(stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {res}"), &body)); } pdf(objs) } // Deeper than the old limit of 12 and within the new one: the innermost text survives. for depth in [1, 12, 13, 20, 50] { let mut doc = page_with_chain(depth); mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); let report = apply(&mut doc, None).unwrap(); assert_eq!(report.forms_removed, 0, "depth {depth}: a form was removed"); let c = content(&doc, 0); assert!(c.contains("(TAIL)"), "depth {depth}: {c}"); // The innermost form's text is still in the saved file. let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); assert!(saved.windows(6).any(|w| w == b"(DEEP)"), "depth {depth}: the innermost form's text was deleted"); } // Past what any reader follows, the form is still removed: the fail-safe, and nothing the // renderer draws is lost because it stops at 50 too. let mut doc = page_with_chain(crate::interp::MAX_DEPTH + 1); mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); assert_eq!(apply(&mut doc, None).unwrap().forms_removed, 1, "a chain past the limit is removed"); } /// Depth alone does not bound the walk: a form that draws the next level twice, or a cycle of two /// forms, costs 2^depth interpretations, which at a depth limit of 50 is a hang on a file anyone /// can send. The pass also counts the forms it enters, and a page past that budget is treated as /// one it cannot read. #[test] fn a_form_that_fans_out_or_loops_is_bounded_by_the_page_budget() { /// A page drawing `Fm1`, where each of `n` forms draws the next **twice** and the last draws /// text: 2^n paths if nothing stops the walk. With `looping`, the last form draws the first /// one again instead, so the chain has no end at all. fn fan_out(n: usize, looping: bool) -> Document { let first = 7; let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), format!( "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 {first} 0 R >> >> >>" ) .into_bytes(), stream("", b"BT /F1 10 Tf 10 280 Td (HEAD) Tj ET /Fm1 Do"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), ]; for k in 1..=n { let (next, body) = if k < n { (first + k, format!("/Fm{0} Do /Fm{0} Do", k + 1)) } else if looping { (first, "/Fm1 Do /Fm1 Do".to_string()) } else { (first, "BT /F1 10 Tf 10 150 Td (DEEP) Tj ET".to_string()) }; let name = if k < n { format!("/Fm{}", k + 1) } else { "/Fm1".to_string() }; let res = format!("/Resources << /Font << /F1 5 0 R >> /XObject << {name} {next} 0 R >> >>"); objs.push(stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {res}"), body.as_bytes())); } pdf(objs) } for looping in [false, true] { let start = std::time::Instant::now(); let mut doc = fan_out(crate::interp::MAX_DEPTH, looping); mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); let result = apply(&mut doc, None); let took = start.elapsed(); // An unbounded walk would never end; the limit leaves room for a loaded test machine. assert!(took < std::time::Duration::from_secs(120), "looping {looping}: the walk took {took:?}"); // The page's form budget stops the walk, and Apply refuses the page rather than // removing forms it could not read. assert!(result.is_err(), "looping {looping}: the page past its form budget is refused"); } } #[test] fn shared_form_xobjects_are_copied_not_changed() { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET"); let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 300 300] /Resources << /XObject << /Fm 7 0 R >> >> >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), stream("", b"/Fm Do"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), form, ]; objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec()); let mut doc = pdf(objs); mark(&mut doc, 0, &[[0.0, 90.0, 300.0, 120.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); assert!(content(&doc, 0).contains("/PCRedacted1 Do")); assert_eq!(content(&doc, 1), "/Fm Do", "page 2 shares the stream and the form: untouched"); assert_eq!(under(&mut doc, 1, &[[0.0, 90.0, 300.0, 120.0]]), 6); assert_eq!(under(&mut doc, 0, &[[0.0, 90.0, 300.0, 120.0]]), 0); } #[test] fn redacted_images_and_forms_do_not_survive_a_full_rewrite_save() { // Page 1 draws a fully covered image, a partly covered image and a form XObject whose text // is under the mark. After Apply Redactions and the forced full rewrite, none of their secret // bytes may stay in the saved file, while an unrelated image drawn only on page 2 survives. let im1 = b"REDIMGONECOVERED"; // 16 bytes, one gray row let impart = b"REDIMGPARTSECRETPUBPARTBOTTOMROW"; // 16-byte secret top row + 16-byte bottom row let imkeep = b"PUBIMGKEEPSURVIVE"; // 17 bytes, used only on page 2 let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 2 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 7 0 R /ImPart 8 0 R /FmSecret 9 0 R >> >> >>".to_vec(), 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", im1), stream("/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", impart), 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", ), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 11 0 R /Resources << /XObject << /ImKeep 12 0 R >> >> >>".to_vec(), stream("", b"q 50 0 0 50 20 20 cm /ImKeep Do Q"), stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", imkeep), ]; let mut doc = pdf(objs); 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:?}"); // The full rewrite that redaction forces must not carry the removed content forward. let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(!has(b"REDIMGONECOVERED"), "covered image samples survived the save"); assert!(!has(b"REDIMGPARTSECRET"), "partly covered image's secret row survived the save"); assert!(!has(b"REDFORMSECRETTEXT"), "the form's redacted text survived the save"); // The unrelated image drawn only on page 2 stays referenced and intact. let saved = Document::open(Arc::new(bytes)).unwrap(); let keep = pdfcraft_model::pages(&saved).swap_remove(1); let res = saved.resolve(keep.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); let kref = xo.get(b"ImKeep").and_then(Object::as_ref).expect("ImKeep still referenced"); let Object::Stream(s) = &*saved.get(kref) else { panic!("ImKeep is a stream") }; 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, } } #[test] fn redaction_clears_pixels_intersected_without_covering_their_centres() { for (content, rect) in [(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])] { let mut doc = one_page( content, "/XObject << /Im 7 0 R >>", vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])], ); mark(&mut doc, 0, &[rect], ""); let report = apply(&mut doc, None).unwrap(); assert_eq!(report.images_cleared, 1); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert_eq!(named_image(&saved, 0, b"Im"), None); assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234])); } } /// 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 hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() { use crate::sanitize::{Hidden, remove_hidden}; let mut doc = one_page( b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", "/Properties << /M 9 0 R >>", vec![ b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Off) >>".to_vec(), b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), ], ); let root = doc.root().unwrap(); doc.update_dict(root, |d| { d.set( b"OCProperties".to_vec(), Object::Dict(pdfcraft_cos::Dict::from_iter([ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), ( b"D".to_vec(), Object::Dict(pdfcraft_cos::Dict::from_iter([ (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), ])), ), ])), ); }) .unwrap(); let before = pdfcraft_edit::text_lines(&doc, 0).unwrap(); assert!(before.iter().any(|line| line.text.contains("PUBLIC"))); assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization"); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap(); assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}"); assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}"); assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET")); } #[test] fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() { use crate::sanitize::{Hidden, remove_hidden}; let mut doc = one_page( b"/Fm Do", "/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>", vec![ b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Off) >>".to_vec(), b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), stream( "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>", b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC", ), ], ); let root = doc.root().unwrap(); doc.update_dict(root, |d| { d.set( b"OCProperties".to_vec(), Object::Dict(Dict::from_iter([ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), ])), ); }) .unwrap(); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); } #[test] fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() { use crate::sanitize::{Hidden, remove_hidden, scan}; let mut doc = one_page( b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", "/Properties << /M 9 0 R >>", vec![ b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(), ], ); let counts = scan(&doc); assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); } #[test] fn hidden_ve_not_is_sanitized_when_its_group_is_on() { use crate::sanitize::{Hidden, remove_hidden, scan}; let mut doc = one_page( b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", "/Properties << /M 9 0 R >>", vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()], ); let counts = scan(&doc); assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); } fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document { let membership = match policy { "AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", "Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>", _ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", }; let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") }; let xobject = match subtype { "Image" => stream( &format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"), b"PRIVATE_HIDDEN_XOBJECT_IMAGE", ), _ => stream( &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"), b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET", ), }; let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()]; if object_number == 9 { extras.push(membership.as_bytes().to_vec()); } extras.push(xobject); let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras); let root = doc.root().unwrap(); doc.update_dict(root, |d| { d.set( b"OCProperties".to_vec(), Object::Dict(Dict::from_iter([ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))), ])), ); }) .unwrap(); doc } #[test] fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() { use crate::sanitize::{Hidden, remove_hidden, scan}; let mut failures = Vec::new(); for subtype in ["Image", "Form"] { for policy in ["AllOff", "Not"] { let mut doc = all_on_ocmd_xobject(subtype, policy); let before = scan(&doc); let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n); if hidden_count == 0 { failures.push(format!("{subtype} /{policy} was not counted: {before:?}")); } match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) { Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {} Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")), Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")), } let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") { failures.push(format!("{subtype} /{policy} image payload remains in full save")); } if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") { failures.push(format!("{subtype} /{policy} Form payload remains in full save")); } let saved = Document::open(Arc::new(bytes)).unwrap(); if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) { failures.push(format!("{subtype} /{policy} Form text remains extractable")); } if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() { failures.push(format!("{subtype} /{policy} image remains on the page")); } } } assert!(failures.is_empty(), "{}", failures.join("\n")); } #[test] fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() { use crate::sanitize::{Hidden, scan}; let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff"); let page = pdfcraft_model::pages(&doc)[0].obj; let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do"))); doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap(); let original = write_full(&doc, &SaveOptions::default()).unwrap(); let counts = scan(&doc); assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}"); assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document"); } #[test] fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() { use crate::sanitize::{Hidden, remove_hidden, scan}; let mut doc = one_page( b"/Bad Do", "/XObject << /Bad 7 0 R >>", vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")], ); let counts = scan(&doc); assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM")); } #[test] fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() { use crate::sanitize::{Hidden, remove_hidden, scan}; const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH"; // The final Form is encountered at MAX_DEPTH and is removed conservatively without being // entered. Verify must count that removal or sanitize can skip the HiddenLayers pass entirely. let max = crate::interp::MAX_DEPTH; let mut forms = Vec::new(); for depth in 0..=max { let content = if depth == max { b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec() } else { format!("/F{} Do", depth + 1).into_bytes() }; forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content)); } let xobjects = (0..=max).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms); assert!( write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), "fixture lacked nested payload before sanitization" ); let counts = scan(&doc); assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}"); let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); assert!( removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not reported removed: {removed:?}" ); let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save"); } #[test] fn hidden_ocmd_resolves_an_indirect_policy_name() { use crate::sanitize::{Hidden, remove_hidden}; let mut doc = one_page( b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", "/Properties << /M 9 0 R >>", vec![ b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Off) >>".to_vec(), b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(), ], ); doc.set(ObjRef::new(12, 0), Object::name("AnyOn")); let root = doc.root().unwrap(); doc.update_dict(root, |d| { d.set( b"OCProperties".to_vec(), Object::Dict(Dict::from_iter([ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), ])), ); }) .unwrap(); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); } #[test] fn surviving_anyoff_and_not_content_keeps_its_off_group_state() { use crate::sanitize::{Hidden, remove_hidden}; for use_not in [false, true] { let mut doc = one_page( b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", "/Properties << /M 9 0 R >>", vec![ b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Off) >>".to_vec(), if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() }, ], ); let root = doc.root().unwrap(); doc.update_dict(root, |d| { d.set( b"OCProperties".to_vec(), Object::Dict(Dict::from_iter([ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), ])), ); }) .unwrap(); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let root = doc.root().unwrap(); let catalog = doc.get(root); let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o)); let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o)); let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o)); assert!( off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))), "off group lost for /VE Not={use_not}" ); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); } } #[test] fn hidden_ocmd_policy_matches_the_default_configuration() { use crate::sanitize::{Hidden, remove_hidden}; let cases = [ (None, false, None, false), (Some("AnyOn"), false, None, false), (Some("AllOn"), false, None, true), (Some("AnyOff"), false, None, false), (Some("AllOff"), false, None, true), (Some("UnknownPolicy"), false, None, true), (None, true, None, true), (None, false, Some("and"), true), (None, false, Some("and_single_not"), true), (None, false, Some("and_single_off"), true), (None, false, Some("or_single_on"), false), (None, false, Some("or_single_off"), true), (Some("AllOn"), false, Some("not_off"), false), (None, false, Some("malformed_not"), true), (None, false, Some("malformed_without_ocgs"), true), ]; for (policy, malformed_policy, visibility, should_remove) in cases { let mut doc = one_page( b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", "/Properties << /M 9 0 R >>", vec![ b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Off) >>".to_vec(), b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), ], ); let mut membership = Dict::new(); membership.set(b"Type".to_vec(), Object::name("OCMD")); if visibility != Some("malformed_without_ocgs") { membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])); } if let Some(policy) = policy { membership.set(b"P".to_vec(), Object::name(policy)); } else if malformed_policy { membership.set(b"P".to_vec(), Object::Int(17)); } match visibility { Some("and") => membership .set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), Some("and_single_not") => membership.set( b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]), ), Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])), Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])), Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])), Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])), Some("malformed_not") => membership .set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), Some("malformed_without_ocgs") => { membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))])) } _ => {} } doc.set(ObjRef::new(9, 0), Object::Dict(membership)); let root = doc.root().unwrap(); doc.update_dict(root, |d| { d.set( b"OCProperties".to_vec(), Object::Dict(Dict::from_iter([ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), ( b"D".to_vec(), Object::Dict(Dict::from_iter([ (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), ])), ), ])), ); }) .unwrap(); remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")); assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}"); } } #[test] fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { // A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA // datasets packet (Designer-style /XFA array). Redacting the widget must delete the field and // stop the form being XFA, so neither the saved file nor a later relayout keeps the value. let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm 7 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [8 0 R] >>".to_vec(), stream("", b"BT ET"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), b"<< /Fields [8 0 R] /XFA 9 0 R /PCXfaLayout << /Pages 1 /Fields 1 >> >>".to_vec(), b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (ssn) /V (SSN-987-65-4321) /Rect [72 200 272 220] /P 3 0 R >>".to_vec(), b"[(datasets) 10 0 R (template) 11 0 R]".to_vec(), stream( "", b"SSN-987-65-4321", ), stream( "", b"", ), ]; let mut doc = pdf(objs); assert_eq!(pdfcraft_forms::fields(&doc).len(), 1, "the XFA-bound field is present before"); mark(&mut doc, 0, &[[72.0, 200.0, 272.0, 220.0]], ""); let r = apply(&mut doc, None).unwrap(); assert!(r.fields >= 1 && r.xfa_removed, "{r:?}"); let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); assert!(!bytes.windows(15).any(|w| w == b"SSN-987-65-4321"), "the redacted value survived in the datasets packet"); let saved = Document::open(Arc::new(bytes)).unwrap(); let cat = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); let acro = saved.resolve(cat.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); assert!(acro.get(b"XFA").is_none(), "/XFA is gone from the saved AcroForm"); assert!(acro.get(b"PCXfaLayout").is_none(), "the cached layout mark is cleared"); assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared"); } #[test] fn replies_to_a_redacted_comment_go_with_it() { // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file. let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(), stream("", b"BT ET"), b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); let p = pdfcraft_model::pages(&doc).swap_remove(0); assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays"); // A classic table, so the objects' strings are plain in the file. let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] { assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); } assert!(bytes.windows(9).any(|w| w == b"UNRELATED")); } /// The replies listed before the annotations they answer, deepest first, and a pop-up on a /// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of /// #2697). #[test] fn replies_listed_before_their_parent_and_their_pop_ups_go_too() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(), stream("", b"BT ET"), b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); let p = pdfcraft_model::pages(&doc).swap_remove(0); let left = annots_of(&doc, &p.dict); assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too"); } #[test] fn comments_links_and_fields_under_a_mark_go() { let mut doc = one_page(b"", "", vec![]); // A comment under the mark, a link elsewhere, and a text field under the mark. let square = Shape::Rectangle { rect: [20.0, 20.0, 40.0, 40.0] }; add_annotation( &mut doc, &NewAnnotation { page: 0, style: Style::default_for(&square), shape: square, contents: "x".into(), author: "a".into() }, &Meta::default(), ) .unwrap(); let link = Shape::Rectangle { rect: [200.0, 200.0, 220.0, 220.0] }; add_annotation( &mut doc, &NewAnnotation { page: 0, style: Style::default_for(&link), shape: link, contents: "keep".into(), author: "a".into() }, &Meta::default(), ) .unwrap(); pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap(); mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 80.0]], "REDACTED"); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.annotations, r.fields), (1, 1), "{r:?}"); assert!(pdfcraft_forms::fields(&doc).is_empty()); let p = pdfcraft_model::pages(&doc).swap_remove(0); assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the far rectangle stays"); let c = content(&doc, 0); assert!(c.contains("(REDACTED) Tj"), "overlay text: {c}"); } #[test] fn nothing_to_apply_and_clearing_marks() { let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); assert_eq!(apply(&mut doc, None), Err(RedactError::NothingToApply)); mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); assert_eq!(marks(&doc).len(), 1); assert_eq!(clear_marks(&mut doc, None), Ok(1)); assert!(marks(&doc).is_empty()); assert!(content(&doc, 0).contains("(AB) Tj")); } #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); let page = pdfcraft_model::pages(&doc)[0].obj; let bad = doc.add(Object::Stream(Stream::from_raw( { let mut d = Dict::new(); d.set(b"Filter".to_vec(), Object::name("DCTDecode")); d }, b"garbage".to_vec(), ))); doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap(); mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); } /// Leaves a mark with nothing readable: /QuadPoints becomes a name and /Rect is removed. fn break_area(doc: &mut Document, obj: ObjRef) { doc.update_dict(obj, |d| { d.set(b"QuadPoints".to_vec(), Object::name("oops")); d.remove(b"Rect"); }) .unwrap(); } /// A Redact annotation with no readable area (neither usable QuadPoints nor a usable Rect) used to /// vanish: `marks` skipped it, so applying the readable mark next to it reported success and left /// the text under the broken mark in place. Now the whole operation is refused before anything /// changes, and the count is of the unreadable marks. #[test] fn a_mark_with_no_readable_area_is_refused_instead_of_vanishing() { let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); mark(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]], ""); mark(&mut doc, 0, &[[0.0, 95.0, 10.0, 110.0]], ""); for m in marks(&doc) { break_area(&mut doc, m.obj); } mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); assert_eq!(marks(&doc).len(), 1, "only the readable mark is listed"); assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(2))); // Nothing changed: the readable mark is still pending and its text is still drawn. assert_eq!(marks(&doc).len(), 1); assert!(content(&doc, 0).contains("(AB1234CD) Tj"), "{}", content(&doc, 0)); } /// A mark whose QuadPoints can't be used still redacts through its Rect, as it always did. #[test] fn a_mark_with_only_a_usable_rect_still_redacts() { let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); let m = marks(&doc).swap_remove(0); doc.update_dict(m.obj, |d| { d.set(b"QuadPoints".to_vec(), Object::name("oops")); d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(20), Object::Int(95), Object::Int(40), Object::Int(110)])); }) .unwrap(); assert_eq!(marks(&doc).len(), 1, "the Rect keeps the mark readable"); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.marks, r.glyphs), (1, 4)); assert!(!content(&doc, 0).contains("1234")); } /// Only the pages being applied decide the refusal: an unreadable mark on another page does not /// stop the marks of the selected page from being applied. #[test] fn an_unreadable_mark_on_another_page_does_not_stop_the_selected_page() { let mut doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), stream("", b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET"), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 8 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), stream("", b"BT /F1 10 Tf 10 100 Td (XY) Tj ET"), ]); mark(&mut doc, 1, &[[10.0, 95.0, 30.0, 110.0]], ""); let broken = marks(&doc).swap_remove(0); break_area(&mut doc, broken.obj); mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1))); assert_eq!(apply(&mut doc, Some(&[0])).map(|r| r.glyphs), Ok(4)); assert!(!content(&doc, 0).contains("1234")); } /// A document with something in every hidden-information category. fn hidden_fixture() -> Document { let content = b"BT /F1 10 Tf 10 100 Td (Visible) Tj 3 Tr (Hidden) Tj 0 Tr ET BT /F1 10 Tf 500 500 Td (Offpage) Tj ET /OC /L1 BDC BT /F1 10 Tf 10 50 Td (Layer) Tj ET EMC"; let objs: Vec> = vec![ // 1 catalog b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R /Names << /EmbeddedFiles << /Names [(a.txt) 8 0 R] >> /JavaScript << /Names [(init) 9 0 R] >> >> /OpenAction 9 0 R /Outlines 10 0 R /PageMode /UseOutlines /PieceInfo << /App << /Private 1 >> >> /OCProperties << /OCGs [13 0 R 14 0 R] /D << /OFF [13 0 R] /Order [13 0 R 14 0 R] >> >> /AcroForm << /Fields [17 0 R] >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), // 3 page b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 13 0 R >> >> /Annots [15 0 R 16 0 R 17 0 R 18 0 R 19 0 R] /PieceInfo << /App << /Private 2 >> >> /AA << /O 9 0 R >> >>".to_vec(), stream("", content), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), stream("/Type /Metadata /Subtype /XML", b""), b"<< /Type /Filespec /F (a.txt) /EF << /F 20 0 R >> >>".to_vec(), b"<< /S /JavaScript /JS (app.alert(1)) >>".to_vec(), b"<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>".to_vec(), b"<< /Title (One) /Parent 10 0 R /Next 12 0 R >>".to_vec(), b"<< /Title (Two) /Parent 10 0 R /Prev 11 0 R >>".to_vec(), b"<< /Type /OCG /Name (Secret layer) >>".to_vec(), b"<< /Type /OCG /Name (Shown layer) >>".to_vec(), // 15 comment + 16 its pop-up, 17 widget, 18 link, 19 attachment b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Popup 16 0 R >>".to_vec(), b"<< /Type /Annot /Subtype /Popup /Rect [60 10 160 60] /Parent 15 0 R >>".to_vec(), b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 200 150 220] /A 9 0 R >>".to_vec(), b"<< /Type /Annot /Subtype /Link /Rect [10 230 50 240] /A << /S /URI /URI (https://example.org) >> >>".to_vec(), b"<< /Type /Annot /Subtype /FileAttachment /Rect [200 10 210 20] /FS 8 0 R >>".to_vec(), stream("", b"attached"), ]; let mut doc = pdf(objs); let info = doc.add(Object::Dict({ let mut d = Dict::new(); d.set(b"Title".to_vec(), pdfcraft_cos::PdfString::text("Secret plan")); d.set(b"Author".to_vec(), pdfcraft_cos::PdfString::text("Ada")); d })); doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(info)); doc } #[test] fn hidden_information_is_counted_and_removed() { use crate::sanitize::{Hidden, remove_hidden, sanitize, scan}; let mut doc = hidden_fixture(); let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Metadata], 3, "two Info entries and the XMP stream"); assert_eq!(counts[&Hidden::Attachments], 2, "the embedded file and the attachment annotation"); assert_eq!(counts[&Hidden::Comments], 1); assert_eq!(counts[&Hidden::FormFields], 1); assert_eq!(counts[&Hidden::HiddenText], 6 + 7, "Hidden (render mode 3) and Offpage (off the page)"); assert_eq!(counts[&Hidden::HiddenLayers], 2, "one off layer and its block"); assert_eq!(counts[&Hidden::Bookmarks], 2); assert_eq!(counts[&Hidden::LinksActionsScripts], 5, "link, open action, page actions, widget action, document script"); assert_eq!(counts[&Hidden::PrivateData], 2); // Only hidden text first. let done = remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); assert_eq!(done, [(Hidden::HiddenText, 13)]); let c = content(&doc, 0); assert!(c.contains("(Visible)") && !c.contains("Hidden") && !c.contains("Offpage") && c.contains("(Layer)"), "{c}"); assert!(doc.full_save_required()); // Then everything. let done = sanitize(&mut doc).unwrap(); assert!(done.iter().all(|(h, _)| *h != Hidden::HiddenText)); let c = content(&doc, 0); assert!(!c.contains("Layer") && c.contains("(Visible)"), "{c}"); let doc = reopen(&doc); let after: Vec<(Hidden, usize)> = scan(&doc).into_iter().filter(|(_, n)| *n > 0).collect(); // A full save writes a fresh /Info with the modification date only. assert!(after.iter().all(|(h, _)| *h == Hidden::Metadata), "{after:?}"); let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); for k in [&b"Outlines"[..], b"OpenAction", b"PieceInfo", b"AcroForm", b"Metadata"] { assert!(!cat.contains(k), "{}", String::from_utf8_lossy(k)); } assert!(pdfcraft_forms::fields(&doc).is_empty()); let p = pdfcraft_model::pages(&doc).swap_remove(0); assert!(annots_of(&doc, &p.dict).is_empty()); let shown = doc.object_numbers().into_iter().any(|n| match &*doc.get(ObjRef::new(n, doc.generation(n))) { Object::Stream(s) => s.decoded().is_ok_and(|d| d.windows(5).any(|w| w == b"(Ada)")), _ => false, }); assert!(shown, "the field's value stays visible as page content"); } /// A one-page document whose catalog names an outline root (object 5). `items` are the outline /// items, objects 6 onward. fn outlined_document(items: Vec>) -> Document { let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), stream("", b"BT ET"), b"<< /Type /Outlines /First 6 0 R >>".to_vec(), ]; objs.extend(items); pdf(objs) } /// An outline of `levels` levels with two items each. Both items of a level name the first item /// of the next level as their /First, so each of those items has two parents, and a walk that /// follows every path takes 2^levels steps. Items a and b of level k are objects 6 + 2k and 7 + 2k. fn hostile_outline(levels: usize) -> Document { let mut items = Vec::new(); for k in 0..levels { let (a, b) = (6 + 2 * k, 7 + 2 * k); let child = if k + 1 < levels { format!(" /First {} 0 R", a + 2) } else { String::new() }; items.push(format!("<< /Title (a) /Next {b} 0 R{child} >>").into_bytes()); items.push(format!("<< /Title (b){child} /Parent {a} 0 R >>").into_bytes()); } outlined_document(items) } /// Runs `work` on another thread and returns its result. A walk that spins fails the test after /// `bound` instead of hanging the whole run; the thread stays behind and ends with the process. fn finishes_within(bound: std::time::Duration, work: impl FnOnce() -> T + Send + 'static) -> T { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let _ = tx.send(work()); }); match rx.recv_timeout(bound) { Ok(out) => out, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { panic!("the walk did not finish within {bound:?}: it is spinning on shared outline items") } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the walk panicked"), } } /// Generous for a slow machine: the bounded walk takes a few milliseconds. const WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10); /// An outline whose items name shared children is counted item by item, each item once, and the /// walk ends. Counting along every path instead takes 2^60 steps. #[test] fn an_outline_whose_items_share_children_is_counted_without_spinning() { use crate::sanitize::{Hidden, scan}; let doc = hostile_outline(60); let counts = finishes_within(WALK_BOUND, move || scan(&doc)); assert_eq!(counts.iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 120)), "60 levels of two distinct items"); } /// Removing the bookmarks counts them through the same walk first, so it must finish too, and /// it must take the outline out of the catalog. #[test] fn removing_bookmarks_from_an_outline_whose_items_share_children_finishes() { use crate::sanitize::{Hidden, remove_hidden}; let mut doc = hostile_outline(60); let (done, outlines_left) = finishes_within(WALK_BOUND, move || { let done = remove_hidden(&mut doc, &[Hidden::Bookmarks]); let root = doc.root().unwrap(); let left = doc.get(root).as_dict().is_some_and(|c| c.contains(b"Outlines")); (done, left) }); assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 120)]); assert!(!outlines_left, "the outline is removed from the catalog"); } /// Nesting and siblings: every item of an ordinary outline is counted once, as before. #[test] fn a_nested_outline_is_counted_item_by_item() { use crate::sanitize::{Hidden, scan}; let doc = outlined_document(vec![ b"<< /Title (Chapter 1) /Parent 5 0 R /First 7 0 R /Last 8 0 R /Next 9 0 R >>".to_vec(), b"<< /Title (1.1) /Parent 6 0 R /Next 8 0 R >>".to_vec(), b"<< /Title (1.2) /Parent 6 0 R /Prev 7 0 R >>".to_vec(), b"<< /Title (Chapter 2) /Parent 5 0 R /Prev 6 0 R /First 10 0 R >>".to_vec(), b"<< /Title (2.1) /Parent 9 0 R >>".to_vec(), ]); assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 5))); } /// Two levels of 60,000 distinct items: each level is under the per-level cap, so only the total /// cap stops the count at 100,000 (without it the count would be 120,000). #[test] fn an_outline_counts_no_more_items_than_its_total_budget() { use crate::sanitize::{Hidden, scan}; let per_level = 60_000; let mut items = Vec::with_capacity(2 * per_level); // Item j is object 6 + j. Level one is items 0..per_level; its first item leads to level two. for i in 0..per_level { let next = if i + 1 < per_level { format!(" /Next {} 0 R", 7 + i) } else { String::new() }; let first = if i == 0 { format!(" /First {} 0 R", 6 + per_level) } else { String::new() }; items.push(format!("<< /Title (a){next}{first} >>").into_bytes()); } for i in 0..per_level { let next = if i + 1 < per_level { format!(" /Next {} 0 R", 7 + per_level + i) } else { String::new() }; items.push(format!("<< /Title (b){next} >>").into_bytes()); } let doc = outlined_document(items); assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000))); } /// Items written inline (not as indirect objects) count towards the total budget too. Each of /// 60,000 indirect siblings has one inline child, so 120,000 items are reached, and the total must /// still stop at 100,000. #[test] fn inline_outline_children_count_towards_the_total_budget() { use crate::sanitize::{Hidden, scan}; let siblings = 60_000; let mut items = Vec::with_capacity(siblings); for i in 0..siblings { let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() }; items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes()); } let doc = outlined_document(items); assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000))); } /// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags /// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the /// annotations, the URL and the comment text included, while the page no longer lists them. #[test] fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { use crate::sanitize::{Hidden, remove_hidden}; let objs: Vec> = vec![ // 1 catalog, with a structure tree b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), FONT.replace("95 0 R", "13 0 R").into_bytes(), // 6 link, 7 comment b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR), // 12 annotation element (OBJR by reference, as K's only kid) b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), // the link element's alternate text repeats the URL b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), widths(), b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), ]; let mut doc = pdf(objs); let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); // Without object streams, whose compression would hide the strings from the search. let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(has(b"(See the site)"), "the search sees the saved objects"); assert!(!has(b"secret.example"), "the removed link's URL survived the save"); assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); // The tagged paragraph and its content stay; the elements lose only the references. let saved = Document::open(Arc::new(bytes)).unwrap(); assert!(content(&saved, 0).contains("(See the site)")); let objrs = saved.object_numbers().into_iter().filter(|&n| { let o = saved.get(ObjRef::new(n, saved.generation(n))); let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR")); let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))), _ => false, }); direct || nested }); assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left"); let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); } /// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the /// structure tree too (their field value with them), and a pop-up listed on another page than /// its removed comment goes with it. #[test] fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { use crate::sanitize::{Hidden, remove_hidden}; let objs: Vec> = vec![ // 1 catalog with a form and a structure tree b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /StructTreeRoot 8 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(), // 3 page 1: a text field's widget and a comment b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), FONT.replace("95 0 R", "12 0 R").into_bytes(), // 6 the field and its widget, with an appearance b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(), // 7 comment, its pop-up (15) listed on page 2 b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(), // 8 struct tree root, 9 document, 10 form element tagging the widget b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(), b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"), widths(), // 13 page 2, listing the pop-up of page 1's comment b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(), b"<< >>".to_vec(), b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(), // 16 an unrelated link on page 2 stays b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), ]; let mut doc = pdf(objs); remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap(); let page2 = pdfcraft_model::pages(&doc)[1].dict.clone(); let left: Vec = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default(); assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment"); let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1)); assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget"); let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let saved = Document::open(Arc::new(bytes)).unwrap(); let field_left = saved.object_numbers().into_iter().any(|n| { saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") || d.contains(b"FT")) }); assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); } /// Issue #2874: applying redactions removes the comment and the form field under a mark, and /// with them the structure tree's object references (`/OBJR`) to them, so a full rewrite of a /// tagged PDF can't keep the comment's text or the field's value. The field's widget on another /// page goes with it; a comment outside the mark keeps its reference. #[test] fn applied_redactions_drop_structure_tree_references_to_what_they_remove() { let objs: Vec> = vec![ // 1 catalog with a form and a structure tree b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 9 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 16 0 R] /Count 2 >>".to_vec(), // 3 page 1: a label, a text field's widget and a comment under the mark, a comment outside b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [7 0 R 8 0 R 15 0 R] /StructParents 0 >>".to_vec(), stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), FONT.replace("95 0 R", "14 0 R").into_bytes(), // 6 the field, 7 its widget on page 1 (17 is its widget on page 2), 8 the comment b"<< /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Kids [7 0 R 17 0 R] >>".to_vec(), b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 3 0 R /Rect [50 225 200 240] /StructParent 1 /AP << /N 13 0 R >> >>".to_vec(), b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), // 9 struct tree root, 10 document, 11 form element (direct OBJRs to both widgets), // 12 annotation element (OBJR to the comment), 18 annotation element for the kept comment b"<< /Type /StructTreeRoot /K 10 0 R /ParentTree << /Nums [1 11 0 R 2 12 0 R 3 18 0 R 4 11 0 R] >> /ParentTreeNextKey 5 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 9 0 R /K [11 0 R 12 0 R 18 0 R] >>".to_vec(), b"<< /Type /StructElem /S /Form /P 10 0 R /Pg 3 0 R /Alt (FIELDSECRETVALUE) /K [<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >> << /Type /OBJR /Obj 17 0 R /Pg 16 0 R >>] >>".to_vec(), b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 8 0 R /Pg 3 0 R >>] >>".to_vec(), stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"), widths(), // 15 the comment outside the mark b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [250 10 270 30] /StructParent 3 /Contents (Kept note) >>".to_vec(), // 16 page 2, with the field's second widget b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [17 0 R] /StructParents 4 >>".to_vec(), b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 16 0 R /Rect [50 50 200 65] /StructParent 4 /AP << /N 13 0 R >> >>".to_vec(), b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 15 0 R /Pg 3 0 R >>] >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[40.0, 190.0, 230.0, 245.0]], ""); let report = apply(&mut doc, None).unwrap(); assert_eq!((report.annotations, report.fields), (1, 1), "{report:?}"); assert!(report.tags >= 3, "the three references to the comment and both widgets are counted: {report:?}"); // Without object streams, whose compression would hide the strings from the search. let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!(has(b"(Name:)") && has(b"(Kept note)"), "the search sees the saved objects"); assert!(!has(b"COMMENTSECRETNOTE"), "the redacted comment's text survived the save through the structure tree"); assert!(!has(b"FIELDSECRETVALUE"), "the redacted field's value survived the save through the structure tree"); let saved = Document::open(Arc::new(bytes)).unwrap(); let widget_left = saved .object_numbers() .into_iter() .any(|n| saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))); assert!(!widget_left, "neither of the field's widgets survived the save"); // The kept comment is still tagged. let kept = saved.object_numbers().into_iter().any(|n| { saved.get(ObjRef::new(n, saved.generation(n))).as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { Object::Array(a) => a.iter().any(|x| { saved.resolve(x).as_dict().and_then(|d| d.get(b"Obj")).and_then(Object::as_ref).is_some_and(|r| { saved.get(r).as_dict().and_then(|d| d.get(b"Contents")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "Kept note") }) }), _ => false, }) }); assert!(kept, "the comment outside the mark keeps its structure reference"); } /// #2874: a structure element written in place that tags a comment under a mark can't be edited /// without rewriting what holds it, so applying is refused rather than leaving the comment /// reachable (fail-closed, as in Sanitize). #[test] fn applying_redactions_refuses_an_in_place_element_tagging_a_removed_comment() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 0 /Contents (COMMENTSECRETNOTE) >>".to_vec(), b"<< >>".to_vec(), // 6 the root's only kid, an annotation element written in place b"<< /Type /StructTreeRoot /K [<< /Type /StructElem /S /Annot /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[190.0, 190.0, 230.0, 230.0]], ""); assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags))); } /// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a /// reference the walk never reached could keep a removed annotation. Then it is an error, and /// nothing is changed, rather than success with references left behind (removing only the tree /// would leave an outline's `/SE` reaching them, review on #3285); under the limit only the /// reference goes. #[test] fn a_structure_tree_too_large_to_walk_is_refused() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(), // 4: the removed link b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), // 5 root, 6 document, 7 section, 8 link element with the OBJR b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(), b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(), b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(), ]; let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into(); let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); let mut doc = pdf(objs.clone()); assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1); assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays"); let mut doc = pdf(objs); assert!(matches!(crate::tags::drop_object_refs_within(&mut doc, &gone, 2), Err(RedactError::Tags)), "a tree the walk couldn't finish is refused"); assert!(catalog(&doc).contains(b"StructTreeRoot"), "nothing changed"); } /// #3302: an element listed only in the parent tree (not under the root's /K) still owns the /// marked content redaction changed, so its /ActualText and /Alt, which repeat the redacted /// words, go as they do for an element under /K. #[test] fn an_element_listed_only_in_the_parent_tree_loses_its_redacted_text() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(), stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), // The root's /K reaches only the empty document element 6; the paragraph 7 is in the // parent tree only. b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) /Alt (Codename Bluefin) >>".to_vec(), b"<< /Type /StructElem /S /Note /P 6 0 R >>".to_vec(), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); let r = apply(&mut doc, None).unwrap(); assert!(r.glyphs > 0 && r.tags >= 1, "{r:?}"); let element = doc.get(ObjRef::new(7, 0)).as_dict().cloned().unwrap(); assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{element:?}"); } /// #3302: a structure tree too large to walk in full fails closed: an error, so the redaction /// isn't made, instead of the walk stopping and reporting success with elements never checked. #[test] fn a_tag_walk_cut_short_is_refused() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /StructParents 0 >>".to_vec(), b"<< >>".to_vec(), b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".to_vec(), b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), b"<< /Type /StructElem /S /Figure /P 6 0 R /Alt (A chart) /E (chart) >>".to_vec(), ]; let page = ObjRef::new(3, 0); let changed: std::collections::HashSet = [0].into(); let mut doc = pdf(objs.clone()); assert_eq!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 100).unwrap(), 1); let figure = doc.get(ObjRef::new(8, 0)).as_dict().cloned().unwrap(); assert!(figure.contains(b"Alt"), "a walk that finishes keeps an unrelated element's text"); let mut doc = pdf(objs); assert!(matches!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 1), Err(RedactError::Tags))); } /// #2532 follow-up: an element reached only through the parent tree or the ID tree, not from /// the root's /K, still points at a removed annotation, and the root keeps it reachable in a full /// rewrite. Both trees are walked too, written in place or as their own objects. #[test] fn elements_listed_only_in_the_parent_tree_or_the_id_tree_lose_their_references() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(), // 4: the removed link; 8: the removed comment b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), // 5: the root; its /K reaches only the empty document element 6. The parent tree is // written in place, the ID tree's kid (9) as its own object. b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 7 0 R] >> /IDTree << /Kids [9 0 R] >> >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), // 7: a link element only the parent tree lists b"<< /Type /StructElem /S /Link /P 6 0 R /K << /Type /OBJR /Obj 4 0 R >> /Alt (secret.example link) >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(), b"<< /Names [(note1) 10 0 R] >>".to_vec(), // 10: an annotation element only the ID tree lists b"<< /Type /StructElem /S /Annot /P 6 0 R /ID (note1) /K [<< /Type /OBJR /Obj 8 0 R >>] >>".to_vec(), ]; let gone: std::collections::HashSet = [ObjRef::new(4, 0), ObjRef::new(8, 0)].into(); let mut doc = pdf(objs); assert_eq!(crate::tags::drop_object_refs(&mut doc, &gone).unwrap(), 2, "both references go"); let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); for secret in [&b"secret.example"[..], b"hushhush"] { assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{} survived the rewrite", String::from_utf8_lossy(secret)); } } /// #3285 review: an element without its own /Pg takes its ancestor's page, also when the parent /// tree lists it. One under /K must not be reached first through the parent tree without that /// page, and one only the parent tree lists finds it through /P. #[test] fn an_element_inheriting_its_page_loses_its_redacted_text_when_the_parent_tree_lists_it() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(), stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC /P <> BDC BT /F1 14 Tf 20 100 Td (Codename Bluefin) Tj ET EMC"), // The document element 6 carries the page; 7 sits under it, 8 only points at it. b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R /K [7 0 R] >>".to_vec(), b"<< /Type /StructElem /S /P /P 6 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), b"<< /Type /StructElem /S /P /P 6 0 R /K 1 /Alt (Codename Bluefin) >>".to_vec(), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[10.0, 90.0, 290.0, 170.0]], ""); let r = apply(&mut doc, None).unwrap(); assert!(r.glyphs > 0, "{r:?}"); for n in [7, 8] { let element = doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap(); assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{n}: {element:?}"); } } /// #3285 review: a structure element written in place, here as an ID tree value, can't be /// edited without rewriting what holds it; when it tags a removed annotation, removing hidden /// information is refused instead of keeping the annotation reachable in a full rewrite. /// Direct callers discard the failed working copy to preserve the original document. #[test] fn an_element_written_in_place_that_tags_a_removed_annotation_is_refused() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(), b"<< /Type /StructTreeRoot /K 6 0 R /IDTree << /Names [(note1) << /Type /StructElem /S /Annot /P 6 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >> >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), ]; let original = pdf(objs); let before = write_full(&original, &SaveOptions::default()).unwrap(); let mut working = original.clone(); assert!(matches!(sanitize::remove_hidden(&mut working, &[sanitize::Hidden::Comments]), Err(RedactError::Tags))); assert!(!working.get(ObjRef::new(3, 0)).as_dict().unwrap().contains(b"Annots"), "the failed working copy was partially changed"); drop(working); assert_eq!(write_full(&original, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original"); } /// Whether a full rewrite of `doc` still holds the redacted word. fn bluefin_survives(doc: &Document) -> bool { let bytes = write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); bytes.windows(7).any(|w| w == b"Bluefin") } /// #3285 review (the maintainer's probes): an element written in place in the parent tree that /// owns the redacted text can't be changed, so applying is refused; the same element as an object /// of its own is cleaned, and nothing of the word is left in a full rewrite. A refusal leaves the /// document as it was. #[test] fn an_element_written_in_place_owning_redacted_text_is_refused() { let objs = |element: &str| -> Vec> { vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>" .to_vec(), stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [{element}]] >> /ParentTreeNextKey 1 >>").into_bytes(), b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), b"<< >>".to_vec(), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), ] }; let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>")); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); let before = write_full(&doc, &SaveOptions::default()).unwrap(); let error = apply(&mut doc, None).unwrap_err(); assert!(matches!(error, RedactError::Tags)); assert_eq!( error.to_string(), "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" ); assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "the refused document is unchanged"); let mut doc = pdf(objs("7 0 R")); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); let r = apply(&mut doc, None).unwrap(); assert!(r.glyphs > 0 && !bluefin_survives(&doc), "{r:?}"); } /// #3285 review: an element whose inherited page can't be found (more ancestors than the lookup /// follows) isn't taken for one with no page: applying is refused. Within the limit the page is /// found and the text goes. #[test] fn an_element_whose_inherited_page_is_out_of_reach_is_refused() { let objs = |depth: u32| -> Vec> { let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>" .to_vec(), stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R >>".to_vec(), format!("<< /Type /StructElem /S /P /P {} 0 R /K 0 /ActualText (Codename Bluefin) >>", 10 + depth - 1).into_bytes(), b"<< >>".to_vec(), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), ]; for i in 0..depth { let parent = if i == 0 { 6 } else { 10 + i - 1 }; objs.push(format!("<< /Type /StructElem /S /Div /P {parent} 0 R >>").into_bytes()); } objs }; let mut doc = pdf(objs(300)); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags))); let mut doc = pdf(objs(100)); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); apply(&mut doc, None).unwrap(); assert!(!bluefin_survives(&doc)); } /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] fn removing_comments_keeps_annotations_that_are_not_comments() { use crate::sanitize::{Hidden, remove_hidden, scan}; let kept = ["Link", "Watermark", "PrinterMark", "TrapNet", "Screen", "Movie", "RichMedia", "3D"]; let annots: Vec = (0..=kept.len()).map(|i| format!("{} 0 R", i + 4)).collect(); let mut objs = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << >> /Annots [{}] >>", annots.join(" ")).into_bytes(), b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] >>".to_vec(), ]; objs.extend(kept.iter().map(|s| format!("<< /Type /Annot /Subtype /{s} /Rect [60 60 90 90] >>").into_bytes())); let mut doc = pdf(objs); let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Comments], 1, "only the Square"); assert_eq!(remove_hidden(&mut doc, &[Hidden::Comments]).unwrap(), [(Hidden::Comments, 1)]); let doc = reopen(&doc); let p = pdfcraft_model::pages(&doc).swap_remove(0); let left: Vec = annots_of(&doc, &p.dict) .iter() .filter_map(|a| doc.resolve(a).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned())) .collect(); assert_eq!(left, kept); } #[test] fn overlay_text_takes_its_font_size_colour_alignment_and_repeats() { let mut doc = one_page(b"BT /F1 12 Tf 20 250 Td (Secret salary figures) Tj ET", "", Vec::new()); let look = pdfcraft_annot::OverlayLook { font: pdfcraft_annot::OverlayFont::Courier, size: 8.0, color: [0.0, 0.0, 1.0], align: 0, repeat: true }; let shape = Shape::Redact { quads: vec![rect_quad([10.0, 200.0, 290.0, 270.0])], overlay: "REDACTED".into(), look }; let style = Style::default_for(&shape); add_annotation(&mut doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "T".into() }, &Meta::default()).unwrap(); // Written as Acrobat writes it. let m = &marks(&doc)[0]; assert_eq!(m.look, look, "the look round-trips through /DA, /Q and /Repeat"); apply(&mut doc, None).unwrap(); let doc = reopen(&doc); let c = content(&doc, 0); assert!(c.contains("0 0 1 rg /PCCour 8 Tf"), "{c}"); // 70 pt high at 8 pt × 1.2 leading: several lines, each the word repeated. assert!(c.matches(" Tm (REDACTED REDACTED").count() >= 5, "{c}"); assert!(c.contains("1 0 0 1 11 "), "left aligned at the area's edge: {c}"); let p = &pdfcraft_model::pages(&doc)[0]; let fonts = p .dict .get(b"Resources") .and_then(|r| doc.resolve(r).as_dict().cloned()) .and_then(|r| r.get(b"Font").and_then(|f| doc.resolve(f).as_dict().cloned())) .unwrap(); assert!(fonts.contains(b"PCCour") && !fonts.contains(b"PCTimes")); } fn repeated_overlay_document(width: f64, height: f64, repeat: bool) -> Document { pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Annots [5 0 R] >>".to_vec(), stream("", b"0 0 20 20 re f"), format!("<< /Type /Annot /Subtype /Redact /Rect [0 0 {width} {height}] /IC [0 0 0] /OverlayText (X) /DA (/Helv 2 Tf) /Repeat {repeat} >>") .into_bytes(), ]) } #[test] fn oversized_repeated_overlay_is_refused_before_changing_the_document() { // About 11 MiB of generated content before the fix; a bounded reproduction, not an OOM test. let mut doc = repeated_overlay_document(16000.0, 1600.0, true); let before = write_full(&doc, &SaveOptions::default()).unwrap(); assert_eq!(apply(&mut doc, None), Err(RedactError::OverlayLimit)); assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); assert_eq!(marks(&doc).len(), 1); let mut single = repeated_overlay_document(16000.0, 1600.0, false); apply(&mut single, None).unwrap(); assert_eq!(content(&reopen(&single), 0).matches("(X) Tj").count(), 1); } #[test] fn overlay_budget_is_shared_by_pages_and_respects_the_page_selection() { let mut doc = repeated_overlay_document(8000.0, 800.0, true); let page = (*doc.get(ObjRef::new(3, 0))).clone(); let mut kids = vec![Object::Ref(ObjRef::new(3, 0))]; for _ in 0..2 { let mut page = page.as_dict().unwrap().clone(); let annotation = doc.add((*doc.get(ObjRef::new(5, 0))).clone()); page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation)])); kids.push(Object::Ref(doc.add(Object::Dict(page)))); } doc.update_dict(ObjRef::new(2, 0), |d| { d.set(b"Kids".to_vec(), Object::Array(kids)); d.set(b"Count".to_vec(), Object::Int(3)); }) .unwrap(); let before = write_full(&doc, &SaveOptions::default()).unwrap(); // Each appearance fits on its own; their sum exceeds the operation's budget. assert_eq!(apply(&mut doc, None), Err(RedactError::OverlayLimit)); assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); assert_eq!(marks(&doc).len(), 3); apply(&mut doc, Some(&[1])).unwrap(); assert_eq!(marks(&doc).iter().map(|m| m.page).collect::>(), [0, 2]); } #[test] fn grouped_marks_keep_overlay_order_and_ignore_duplicate_page_selections() { let mut doc = repeated_overlay_document(60.0, 30.0, false); let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); let original_mark = (*doc.get(ObjRef::new(5, 0))).clone(); let mut kids = vec![Object::Ref(ObjRef::new(3, 0))]; for _ in 0..2 { let mut copy = page.clone(); let mark = doc.add(original_mark.clone()); copy.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(mark)])); kids.push(Object::Ref(doc.add(Object::Dict(copy)))); } doc.update_dict(ObjRef::new(2, 0), |d| { d.set(b"Kids".to_vec(), Object::Array(kids)); d.set(b"Count".to_vec(), Object::Int(3)); }) .unwrap(); let mut second = original_mark.as_dict().unwrap().clone(); second.set(b"OverlayText".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"Y".to_vec()))); let second = doc.add(Object::Dict(second)); doc.update_dict(ObjRef::new(3, 0), |d| { d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(5, 0)), Object::Ref(second)])); }) .unwrap(); let untouched = content(&doc, 1); let report = apply(&mut doc, Some(&[2, 0, 2])).unwrap(); assert_eq!((report.pages, report.marks), (2, 3)); let saved = reopen(&doc); assert_eq!(marks(&saved).iter().map(|m| m.page).collect::>(), [1]); assert_eq!(content(&saved, 1), untouched); let first = content(&saved, 0); assert!(first.find("(X) Tj").unwrap() < first.find("(Y) Tj").unwrap()); assert_eq!(content(&saved, 2).matches("(X) Tj").count(), 1); } #[test] fn overlay_extreme_counts_and_invalid_geometry_are_refused_without_large_allocations() { let doc = repeated_overlay_document(20.0, 10.0, true); let original = marks(&doc).remove(0); for rect in [[0.0, 0.0, 1e100, 10.0], [0.0, 0.0, 20.0, 1e100], [-f64::MAX, 0.0, f64::MAX, 10.0], [0.0, 0.0, f64::NAN, 10.0]] { let mut mark = original.clone(); mark.rects = vec![rect]; let mut remaining = overlay::MAX_BYTES; assert_eq!(overlay::content(&[&mark], &mut remaining), Err(RedactError::OverlayLimit)); } } #[test] fn repeated_overlay_keeps_its_exact_appearance_and_counts_escaped_bytes() { let doc = repeated_overlay_document(20.0, 10.0, true); let mut mark = marks(&doc).remove(0); let expected = b"q 0 0 0 rg\n0 0 20 10 re f\nQ\nq 0 0 20 10 re W n BT 1 0 0 rg /PCHelv 2 Tf 1 0 0 1 1.773 7.9 Tm (X X X X X X X X X) Tj 1 0 0 1 1.773 5.5 Tm (X X X X X X X X X) Tj 1 0 0 1 1.773 3.1 Tm (X X X X X X X X X) Tj 1 0 0 1 1.773 0.7 Tm (X X X X X X X X X) Tj ET Q\n"; let mut remaining = expected.len(); assert_eq!(overlay::content(&[&mark], &mut remaining).unwrap(), expected); assert_eq!(remaining, 0); mark.overlay = "é(\\)".into(); let mut remaining = overlay::MAX_BYTES; let encoded = overlay::content(&[&mark], &mut remaining).unwrap(); let escaped = b"\xe9\\(\\\\\\)"; assert!(encoded.windows(escaped.len()).any(|w| w == escaped)); assert_eq!(overlay::MAX_BYTES - remaining, encoded.len()); let mut too_small = encoded.len() - 1; assert_eq!(overlay::content(&[&mark], &mut too_small), Err(RedactError::OverlayLimit)); } #[test] #[ignore = "manual overlay timing probe"] fn repeated_overlay_timing_probe() { use std::hash::{Hash, Hasher}; for (w, h) in [(20.0, 10.0), (2000.0, 200.0), (4000.0, 400.0), (8000.0, 800.0)] { let doc = repeated_overlay_document(w, h, true); let marks = marks(&doc); let refs: Vec<_> = marks.iter().collect(); let start = std::time::Instant::now(); let mut bytes = Vec::new(); for _ in 0..20 { let mut remaining = overlay::MAX_BYTES; bytes = std::hint::black_box(overlay::content(std::hint::black_box(&refs), &mut remaining).unwrap()); } let elapsed = start.elapsed().as_secs_f64() * 1000.0 / 20.0; let mut hash = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hash); println!("{w}x{h}: bytes={} hash={} ms={elapsed:.3}", bytes.len(), hash.finish()); } } /// Overlay text the standard fonts can't draw: refused when it is marked, and for a mark another /// program made, refused when it is applied, before anything is redacted, rather than burnt into /// the page as `?`. #[test] fn overlay_text_the_standard_fonts_cannot_draw_is_refused() { let mut doc = one_page(b"BT /F1 12 Tf 20 250 Td (Secret salary figures) Tj ET", "", Vec::new()); let look = pdfcraft_annot::OverlayLook::default(); let mark = |overlay: &str| Shape::Redact { quads: vec![rect_quad([10.0, 200.0, 290.0, 270.0])], overlay: overlay.into(), look }; let add = |doc: &mut Document, shape: Shape| { let style = Style::default_for(&shape); add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "T".into() }, &Meta::default()) }; let err = add(&mut doc, mark("机密")).unwrap_err().to_string(); assert!(err.contains("\"机\" (U+673A)") && err.contains("overlay text"), "{err}"); assert!(marks(&doc).is_empty()); // A mark whose overlay text was written by another program. add(&mut doc, mark("REDACTED")).unwrap(); let obj = marks(&doc)[0].obj; doc.update_dict(obj, |d| d.set(b"OverlayText".to_vec(), pdfcraft_cos::PdfString::text("机密"))).unwrap(); let before = content(&doc, 0); let err = apply(&mut doc, None).unwrap_err(); assert!( matches!(&err, RedactError::Undrawable(m) if m.contains("page 1") && m.contains("U+673A") && m.contains("nothing was redacted")), "{err:?}" ); assert_eq!(content(&doc, 0), before, "nothing was redacted"); assert_eq!(marks(&doc).len(), 1, "the mark is still there"); // Cleared, it applies. doc.update_dict(obj, |d| d.set(b"OverlayText".to_vec(), pdfcraft_cos::PdfString::text(""))).unwrap(); apply(&mut doc, None).unwrap(); assert!(!content(&reopen(&doc), 0).contains("Secret")); } #[test] fn redaction_codes_join_in_set_order_and_reject_strangers() { use crate::codes::{CODE_SETS, CodeSet}; let foia = CodeSet::from_id("foia").unwrap(); assert_eq!(foia.overlay(&["(b)(6)", "(b)(1)(A)", "(b)(6)"]), Ok("(b)(1)(A), (b)(6)".into())); assert_eq!(foia.overlay(&[]), Ok(String::new())); assert_eq!(foia.overlay(&["(b)(6)", "(k)(1)"]), Err("(k)(1)")); let privacy = CodeSet::from_id("privacy-act").unwrap(); assert_eq!(privacy.overlay(&["(k)(7)", "(d)(5)"]), Ok("(d)(5), (k)(7)".into())); assert_eq!(CodeSet::from_id("gdpr"), None); assert!(CODE_SETS.iter().all(|s| !s.codes.is_empty() && s.codes.iter().all(|c| !c.is_empty()))); } #[test] fn tags_lose_what_redaction_removed() { let content = b"/P <> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC"; let mut doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), stream("", content), FONT.replace("95 0 R", "6 0 R").into_bytes(), widths(), b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(), b"<< /S /Document /P 7 0 R /K [9 0 R 10 0 R] >>".to_vec(), b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (SECRET) /Alt (the secret) /K 0 >>".to_vec(), b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), ]); mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); let report = apply(&mut doc, None).unwrap(); assert_eq!(report.tags, 1); let secret = doc.get(pdfcraft_cos::ObjRef::new(9, 0)).as_dict().cloned().unwrap(); assert!(secret.get(b"ActualText").is_none() && secret.get(b"Alt").is_none()); assert!(secret.get(b"K").is_none(), "its marked content is empty now"); let public = doc.get(pdfcraft_cos::ObjRef::new(10, 0)).as_dict().cloned().unwrap(); assert_eq!(public.get(b"K").and_then(Object::as_int), Some(1)); assert!(public.get(b"ActualText").is_some(), "untouched content keeps its tags"); } fn assert_no_secret(doc: &Document, secret: &[u8]) { let saved = reopen(doc); for (r, object) in saved.scan_objects() { let mut bytes = Vec::new(); pdfcraft_cos::serialize(&object, &mut bytes); assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); if let Object::Stream(s) = &*object { let data = s.decoded().unwrap(); assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); } } } #[test] fn named_mcids_and_indirect_content_references_lose_descriptions() { let mut doc = one_page( b"/P /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC", "/Properties << /P0 7 0 R >>", vec![ b"<< /MCID 0 >>".to_vec(), b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R] >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_TAG) /K 12 0 R >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), b"<< /Type /MCR /Pg 3 0 R /MCID 0 >>".to_vec(), ], ); doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); assert_eq!(apply(&mut doc, None).unwrap().tags, 1); assert_no_secret(&doc, b"LEAK_"); assert!(!doc.get(ObjRef::new(10, 0)).as_dict().unwrap().contains(b"K")); assert_eq!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().get(b"ActualText").unwrap().as_string().unwrap().to_text(), "Public"); } #[test] fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() { let mut doc = one_page( b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![ stream( "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /Properties << /P0 << /MCID 0 >> >> >>", b"/P /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC", ), b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree 14 0 R >>".to_vec(), b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R] >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_FORM_TAG) /K 12 0 R >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 13 0 R >>".to_vec(), b"<< /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >>".to_vec(), b"<< /Type /MCR /Pg 3 0 R /Stm 7 0 R /StmOwn 7 0 R /MCID 1 >>".to_vec(), b"<< /Nums [0 [10 0 R 11 0 R]] >>".to_vec(), ], ); doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); apply(&mut doc, None).unwrap(); assert_no_secret(&doc, b"LEAK_"); assert_no_secret(&doc, b"SECRET"); assert!(!doc.get(ObjRef::new(10, 0)).as_dict().unwrap().contains(b"K")); let reference = doc.get(ObjRef::new(13, 0)).as_dict().unwrap().get(b"Stm").unwrap().as_ref().unwrap(); assert_ne!(reference, ObjRef::new(7, 0)); assert_eq!(doc.get(ObjRef::new(13, 0)).as_dict().unwrap().get(b"StmOwn").unwrap().as_ref(), Some(reference)); let Object::Stream(s) = &*doc.get(reference) else { panic!() }; assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public")); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText")); } #[test] fn inherited_resources_do_not_keep_the_original_form() { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET"); let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone(); doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); doc.update_dict(ObjRef::new(3, 0), |d| { d.remove(b"Resources"); }) .unwrap(); mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); apply(&mut doc, None).unwrap(); assert_no_secret(&doc, b"LEAK_"); assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); } #[test] fn unselected_pages_keep_inherited_forms() { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); let resources = page.remove(b"Resources").unwrap(); let second = doc.add(Object::Dict(page)); doc.update_dict(ObjRef::new(2, 0), |d| { d.set(b"Resources".to_vec(), resources); d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); d.set(b"Count".to_vec(), Object::Int(2)); }) .unwrap(); doc.update_dict(ObjRef::new(3, 0), |d| { d.remove(b"Resources"); }) .unwrap(); mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); apply(&mut doc, Some(&[0])).unwrap(); let mut saved = reopen(&doc); assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); } /// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. fn inheriting_pages(n: usize) -> (Document, Vec) { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); let resources = page.remove(b"Resources").unwrap(); doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); let mut kids = vec![ObjRef::new(3, 0)]; kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone())))); doc.update_dict(ObjRef::new(2, 0), |d| { d.set(b"Resources".to_vec(), resources); d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())); d.set(b"Count".to_vec(), Object::Int(n as i64)); }) .unwrap(); let saved = reopen(&doc); (saved, kids) } #[test] fn redacting_one_page_leaves_the_other_pages_untouched() { let (mut doc, kids) = inheriting_pages(50); mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); let before = doc.modified_objects(); apply(&mut doc, Some(&[0])).unwrap(); let changed: Vec = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect(); for kid in &kids[1..] { assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten"); } // The page, its new content and resources, the cleaned form, the parent and one shared // resources object: not one object per page. assert!(changed.len() < 10, "{changed:?}"); // The parent keeps the resources the other pages draw with, through a single shared object. let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone(); assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some()); let mut saved = reopen(&doc); assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6); } #[test] fn redacting_every_inheriting_page_retires_the_parent_resources() { let (mut doc, kids) = inheriting_pages(20); for page in 0..kids.len() { mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], ""); } apply(&mut doc, None).unwrap(); assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); assert_no_secret(&doc, b"SECRET"); let mut saved = reopen(&doc); for page in 0..kids.len() { assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources")); assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0); } } #[test] fn replacement_text_and_property_resources_do_not_survive_redaction() { let mut doc = one_page( b"/Span /P0 BDC /Span <> BDC BT /F1 10 Tf 10 200 Td (SECRET)", "/Properties << /P0 7 0 R >>", vec![ b"<< /MCID 0 /ActualText (LEAK_PROPERTY) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) >>".to_vec(), stream("", b"Tj ET EMC EMC /Span <> DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), ], ); doc.update_dict(ObjRef::new(3, 0), |d| { d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(ObjRef::new(8, 0))])); }) .unwrap(); mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); apply(&mut doc, None).unwrap(); assert_no_secret(&doc, b"LEAK_"); let saved = reopen(&doc); assert!(content(&saved, 0).contains("Public")); let p = pdfcraft_model::pages(&saved).swap_remove(0); assert!(!saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); } #[test] fn inherited_form_properties_lose_replacement_text() { let mut doc = one_page( b"/Fm Do", "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", vec![ stream( "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC BT /F1 10 Tf 10 100 Td (Public) Tj ET", ), b"<< /ActualText (LEAK_FORM_PROPERTY) >>".to_vec(), ], ); mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); apply(&mut doc, None).unwrap(); assert_no_secret(&doc, b"LEAK_"); assert_no_secret(&doc, b"SECRET"); let mut saved = reopen(&doc); assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); } #[test] fn properties_used_by_unmodified_inheriting_forms_stay() { let mut doc = one_page( b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET /Fm Do", "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/Span /P0 BDC 0 0 10 10 re f EMC"), b"<< /ActualText (Public) >>".to_vec()], ); mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); apply(&mut doc, None).unwrap(); let saved = reopen(&doc); let page = pdfcraft_model::pages(&saved).swap_remove(0); let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); let property = saved.resolve(properties.as_dict().unwrap().get(b"P0").unwrap()); assert_eq!(property.as_dict().unwrap().get(b"ActualText").unwrap().as_string().unwrap().to_text(), "Public"); } #[test] fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup() { for hidden in [false, true] { let mode = if hidden { 3 } else { 0 }; let data = format!( "/Span <> BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC \ /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC" ); let mut doc = one_page( data.as_bytes(), "/Properties << /Public 7 0 R >>", vec![b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec()], ); if hidden { sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); } else { mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); apply(&mut doc, None).unwrap(); } assert_no_secret(&doc, b"LEAK_"); assert_no_secret(&doc, b"SECRET"); let saved = reopen(&doc); let page = pdfcraft_model::pages(&saved).swap_remove(0); let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); let dict = property.as_dict().unwrap(); for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { assert!(dict.contains(key), "surviving description removed: {key:?}, hidden={hidden}"); } } } #[test] fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() { // /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped // to a huge usize. Either way the half-covered image can't be cleared, so it is removed whole. for n in ["1152921504606846976", "-1", "2"] { let img = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 8 0 R] /BitsPerComponent 8", [255, 255]); let icc = stream(&format!("/N {n}"), b""); let mut doc = one_page(b"q 20 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![img, icc]); mark(&mut doc, 0, &[[95.0, 95.0, 110.0, 115.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.images_removed, r.images_cleared), (1, 0), "/N {n}: {r:?}"); assert!(!content(&doc, 0).contains("/Im Do"), "/N {n}"); } } #[test] fn image_alternatives_and_masks_cannot_keep_redacted_pixels() { for extra in ["/Alternates [<< /Image 8 0 R >>]", "/SMask 8 0 R", "/Mask 8 0 R", "/Mask [0 0]"] { let mut doc = one_page( b"q 0 0 300 300 re W n 8 0 0 10 10 10 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![ stream( &format!("/Type /XObject /Subtype /Image /Width 8 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 {extra}"), b"LEAK_TOPPUBLIC__", ), stream("/Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"LEAK_ALT"), ], ); mark(&mut doc, 0, &[[10.0, 15.0, 18.0, 20.0]], ""); let report = apply(&mut doc, None).unwrap(); assert_no_secret(&doc, b"LEAK_"); assert_eq!((report.images_cleared, report.images_removed), if extra.starts_with("/Alternates") { (1, 0) } else { (0, 1) }); } } #[test] fn masks_and_alternates_outside_redaction_stay() { let mut doc = one_page( b"q 8 0 0 10 10 10 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![ stream( "/Type /XObject /Subtype /Image /Width 8 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 8 0 R /Alternates [<< /Image 8 0 R >>]", b"PUBLIC_TOPBOTTOM", ), stream("/Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC__"), ], ); mark(&mut doc, 0, &[[100.0, 100.0, 120.0, 120.0]], ""); let report = apply(&mut doc, None).unwrap(); assert_eq!((report.images_removed, report.images_cleared), (0, 0)); let saved = reopen(&doc); let page = pdfcraft_model::pages(&saved).swap_remove(0); let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); let xobjects = saved.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); let image = saved.resolve(xobjects.as_dict().unwrap().get(b"Im").unwrap()); let Object::Stream(image) = &*image else { panic!() }; assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates")); assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM"); } #[test] fn stream_boundary_comment_stays_inert_after_unrelated_redaction() { for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] { let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]); let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec()))); let page = pdfcraft_model::pages(&doc)[0].obj; doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap(); mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); let reopened = reopen(&doc); let page = &pdfcraft_model::pages(&reopened)[0]; let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap(); // Independent of Pieces::join: the renderer concatenates streams with spaces. let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable"); let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE"))); assert_eq!(live, tail.contains(&b'\n')); } } #[test] fn rewriting_a_piece_keeps_its_leading_comment_terminator() { for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { let mut doc = one_page(b"q Q % note", "", vec![]); let mut tail = eol.to_vec(); tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET"); let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail))); let page = pdfcraft_model::pages(&doc)[0].obj; doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap(); mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); let p = &pdfcraft_model::pages(&doc)[0]; let (_, data) = page_streams(&doc, &p.dict, 0).unwrap(); let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed"); assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP")))); } } #[test] fn named_hidden_text_descriptions_are_removed_from_saved_bytes() { let mut doc = one_page( b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC", "/Properties << /P0 7 0 R >>", vec![b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec()], ); sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); assert_no_secret(&doc, b"SECRET"); assert_no_secret(&doc, b"LEAK_"); let saved = reopen(&doc); let page = pdfcraft_model::pages(&saved).swap_remove(0); assert!(!saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); } #[test] fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { for hidden in [false, true] { for inherited in [false, true] { let mode = if hidden { 3 } else { 0 }; let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); let mut doc = one_page( data.as_bytes(), "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", vec![ b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec(), b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec(), stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), ], ); let first = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); let resources = doc.add(first.get(b"Resources").unwrap().clone()); let second_content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Fm Do".to_vec()))); let mut second = first; second.set(b"Contents".to_vec(), Object::Ref(second_content)); if inherited { second.remove(b"Resources"); } else { second.set(b"Resources".to_vec(), Object::Ref(resources)); } let second = doc.add(Object::Dict(second)); doc.update_dict(ObjRef::new(3, 0), |d| { if inherited { d.remove(b"Resources"); } else { d.set(b"Resources".to_vec(), Object::Ref(resources)); } }) .unwrap(); doc.update_dict(ObjRef::new(2, 0), |d| { d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); d.set(b"Count".to_vec(), Object::Int(2)); if inherited { d.set(b"Resources".to_vec(), Object::Ref(resources)); } }) .unwrap(); if hidden { sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); } else { mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); apply(&mut doc, Some(&[0])).unwrap(); } assert_no_secret(&doc, b"LEAK_"); assert_no_secret(&doc, b"SECRET"); let mut saved = reopen(&doc); for page_index in 0..2 { assert_eq!(under(&mut saved, page_index, &[[0.0, 90.0, 100.0, 110.0]]), 6); let page = pdfcraft_model::pages(&saved).swap_remove(page_index); let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); assert!(!properties.as_dict().unwrap().contains(b"P0")); let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { assert!(property.as_dict().unwrap().contains(key), "shared form lost {key:?}: hidden={hidden}, inherited={inherited}"); } } } } } mod descriptions; mod inline_images; mod limits; mod property_retirement; mod surviving_references; mod text_state; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not /// the first (FIRSTARR, 8 glyphs), which is never drawn at all, as the renderer agrees (#3569). #[test] fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET", "", vec![]); assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } #[test] fn form_entries_share_one_budget_per_page() { // 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a // few hundred bytes. let draws = "/X Do ".repeat(20); let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")]; for inner in 7..10 { objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes())); } let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs); let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]); assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted"); // Applying refuses the page rather than leave forms past the budget unread. mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); assert!(apply(&mut doc, None).is_err()); } /// A name tree whose levels each list the next level twice: a walk that follows every path /// visits 2^levels nodes. #[test] fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { use crate::sanitize::{Hidden, scan}; let levels = 12; let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), stream("", b"BT ET"), ]; for k in 0..levels { let next = 6 + k; objs.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>").into_bytes()); } objs.push(b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec()); let doc = pdf(objs); let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); } /// Page content that stops decoding at its start (`z` inside an ASCII85 group) while the rest of /// the stream draws hidden text. A tolerant decode reads nothing, counts nothing and keeps the /// stream; sanitizing must refuse instead. #[test] fn hidden_text_in_content_that_stops_decoding_is_refused() { use crate::sanitize::{Hidden, remove_hidden, sanitize}; let encoded: &[u8] = br"!!z6<#'\7PQ#?0Ha>,+>Y->EZd(j+>PW)+B2ko-rWQ%;FOP6+B3(u78s~>"; let fresh = || { let mut doc = one_page(b"", "", vec![stream("/Filter /ASCII85Decode", encoded)]); doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); doc }; let mut doc = fresh(); let out = remove_hidden(&mut doc, &[Hidden::HiddenText]); assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); let mut doc = fresh(); let out = sanitize(&mut doc); assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); } /// An array of references to objects `nums` (generation 0). fn ref_array(nums: &[u32]) -> Object { Object::Array(nums.iter().map(|&n| Object::Ref(ObjRef::new(n, 0))).collect()) } /// An interactive form dictionary listing fields `nums`. fn fields_dict(nums: &[u32]) -> Object { let mut d = Dict::new(); d.set(b"Fields".to_vec(), ref_array(nums)); Object::Dict(d) } /// Flattening keeps hidden widgets (they draw nothing); removing form fields must still take them /// and their values, and their parent field's value, out of the file. #[test] fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() { use crate::sanitize::{Hidden, remove_hidden}; let mut doc = one_page( b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![ // 7 a hidden terminal field merged with its widget, 8 a parent field, 9 its hidden widget b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (own) /V (LEAK_WIDGET_VALUE) /F 2 /Rect [10 200 150 220] /AP << /N 10 0 R >> >>".to_vec(), b"<< /FT /Tx /T (parent) /V (LEAK_PARENT_VALUE) /Kids [9 0 R] >>".to_vec(), b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /F 32 /Rect [10 230 150 250] /AP << /N 10 0 R >> >>".to_vec(), stream("/Type /XObject /Subtype /Form /BBox [0 0 140 20]", b"0 g"), ], ); doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[7, 8]))).unwrap(); doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7, 9]))).unwrap(); remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); assert_no_secret(&doc, b"LEAK_"); assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } /// Removing hidden text cleans the structure tree as redaction does: the elements describing the /// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. #[test] fn removing_hidden_text_cleans_the_structure_tree() { use crate::sanitize::{Hidden, remove_hidden}; let mut doc = one_page( b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC /Fm Do", "/XObject << /Fm 7 0 R >>", vec![ stream( "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", b"/P <> BDC BT /F1 10 Tf 3 Tr 10 150 Td (FORMSECRET) Tj ET EMC", ), b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_PAGE_TAG) /K 0 >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), b"<< /S /P /P 9 0 R /Pg 3 0 R /Alt (LEAK_FORM_TAG) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >>".to_vec(), ], ); doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"StructParents".to_vec(), Object::Int(0))).unwrap(); remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); assert_no_secret(&doc, b"LEAK_"); assert_no_secret(&doc, b"SECRET"); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); } /// One page drawing image /Im (object 7) and form /Fm (object 8), with `extra` objects from 9 on. fn owners_fixture(image: &str, form: &str, extra: Vec>) -> Document { let mut objs = vec![ stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image}"), b"\x80"), stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 10 10] {form}"), b"0 0 5 5 re f"), ]; objs.extend(extra); one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET q 10 0 0 10 0 0 cm /Im Do Q /Fm Do", "/XObject << /Im 7 0 R /Fm 8 0 R >>", objs) } /// Removing one category and saving leaves none of `secret` in the file, and the scan counted it. fn removes_from_every_owner(mut doc: Document, h: crate::sanitize::Hidden, secret: &[u8]) { use crate::sanitize::{remove_hidden, scan}; let counts: std::collections::HashMap<_, usize> = scan(&doc).into_iter().collect(); assert!(counts[&h] > 0, "{h:?} is counted"); remove_hidden(&mut doc, &[h]).unwrap(); assert_no_secret(&doc, secret); assert!(scan(&reopen(&doc)).into_iter().all(|(k, n)| k != h || n == 0 || h == crate::sanitize::Hidden::Metadata)); } #[test] fn private_data_of_a_form_xobject_is_removed() { let doc = owners_fixture("", "/PieceInfo << /App << /Private (LEAK_PRIVATE) >> >>", vec![]); removes_from_every_owner(doc, crate::sanitize::Hidden::PrivateData, b"LEAK_"); } #[test] fn metadata_of_an_image_is_removed() { let doc = owners_fixture("/Metadata 9 0 R", "", vec![stream("/Type /Metadata /Subtype /XML", b"LEAK_XMP")]); removes_from_every_owner(doc, crate::sanitize::Hidden::Metadata, b"LEAK_"); } #[test] fn a_pages_associated_files_are_removed_with_attachments() { let mut doc = owners_fixture( "", "", vec![b"<< /Type /Filespec /F (a.txt) /AFRelationship /Data /EF << /F 10 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], ); doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"AF".to_vec(), ref_array(&[9]))).unwrap(); removes_from_every_owner(doc, crate::sanitize::Hidden::Attachments, b"LEAK_"); } #[test] fn a_fields_own_actions_are_removed_with_links_actions_and_scripts() { let mut doc = owners_fixture( "", "", vec![ b"<< /FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (LEAK_SCRIPT) >> >> /Kids [10 0 R] >>".to_vec(), b"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 200 150 220] >>".to_vec(), ], ); doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[9]))).unwrap(); doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[10]))).unwrap(); removes_from_every_owner(doc, crate::sanitize::Hidden::LinksActionsScripts, b"LEAK_"); } /// Resource names are chosen by the producer: a font named like an owned entry stays. #[test] fn a_resource_named_like_an_owned_entry_is_kept() { let mut doc = one_page( b"BT /AF 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Filespec /F (a.txt) /EF << /F 8 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], ); doc.update_dict(ObjRef::new(3, 0), |d| { let mut fonts = Dict::new(); fonts.set(b"AF".to_vec(), Object::Ref(ObjRef::new(5, 0))); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); d.set(b"Resources".to_vec(), Object::Dict(res)); d.set(b"AF".to_vec(), ref_array(&[7])); }) .unwrap(); removes_from_every_owner(doc.clone(), crate::sanitize::Hidden::Attachments, b"LEAK_"); crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Attachments]).unwrap(); let page = pdfcraft_model::pages(&doc).swap_remove(0); let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); assert!(res.get(b"Font").and_then(|f| f.as_dict()).is_some_and(|f| f.contains(b"AF"))); assert!(!page.dict.contains(b"AF")); } /// Owners written in place deeper than the sweep follows are refused, not skipped. #[test] fn owners_nested_too_deeply_are_refused() { use crate::sanitize::{Hidden, remove_hidden}; let nested = format!("{}<< /AA << /O << /S /JavaScript /JS (LEAK_DEEP) >> >> >>{}", "[".repeat(100), "]".repeat(100)); let mut doc = one_page(b"BT ET", "", vec![format!("<< /Deep {nested} >>").into_bytes()]); doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"PieceInfo".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); } /// A name tree node that lists the same kid more times than the node budget: each repeat costs a /// step, so the walk stops at the budget instead of scanning every repeat, and the distinct kid /// listed after them is never reached. #[test] fn repeated_name_tree_kids_count_towards_the_node_budget() { use crate::sanitize::{Hidden, scan}; let repeats = "6 0 R ".repeat(100_010); let doc = pdf(vec![ b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), stream("", b"BT ET"), format!("<< /Kids [{repeats}7 0 R] >>").into_bytes(), b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec(), b"<< /Names [(b.txt) << /Type /Filespec /F (b.txt) >>] >>".to_vec(), ]); let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); } /// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a /// 130 MiB form are each under it, together past it. #[test] fn content_and_forms_share_one_budget_per_page() { use pdfcraft_cos::{Dict, Stream}; let mut doc = one_page(b"", "", vec![]); let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec(); content.resize(130 << 20, b'x'); let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content))); let mut form = Dict::new(); form.set(b"Type".to_vec(), Object::name("XObject")); form.set(b"Subtype".to_vec(), Object::name("Form")); let mut body = b"%".to_vec(); body.resize(130 << 20, b'x'); let form = doc.add(Object::Stream(Stream::flate(form, &body))); doc.update_dict(ObjRef::new(3, 0), |d| { let mut xobjects = Dict::new(); xobjects.set(b"X".to_vec(), Object::Ref(form)); let mut fonts = Dict::new(); fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0))); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); res.set(b"XObject".to_vec(), Object::Dict(xobjects)); d.set(b"Resources".to_vec(), Object::Dict(res)); d.set(b"Contents".to_vec(), Object::Ref(content)); }) .unwrap(); mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read"); } /// A page that fails to read after another page was already redacted leaves the whole document as /// it was, the shared resources the first page's rewrite localised included. #[test] fn a_failure_on_a_later_page_leaves_the_document_unchanged() { let mut doc = two_pages_one_resource_dictionary(true, false); let all = pdfcraft_model::pages(&doc); let (first, second, parent) = (all[0].obj, all[1].obj, doc.get(all[0].obj).as_dict().unwrap().reference(b"Parent").unwrap()); let mut dct = Dict::new(); dct.set(b"Filter".to_vec(), Object::name("DCTDecode")); let bad = doc.add(Object::Stream(Stream::from_raw(dct, b"garbage".to_vec()))); doc.update_dict(second, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap(); mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], ""); mark(&mut doc, 1, &[[0.0, 0.0, 50.0, 50.0]], ""); let before: Vec = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect(); let text = content(&doc, 0); assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(2))); let after: Vec = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect(); assert_eq!(after, before); assert_eq!(content(&doc, 0), text); } #[test] fn a_reply_and_pop_up_on_another_page_go_with_the_removed_comment() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [6 0 R] >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [7 0 R 8 0 R] >>".to_vec(), stream("", b"BT ET"), b"<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (PARENT-NOTE) /Popup 8 0 R >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 6 0 R /Contents (OTHER-PAGE-REPLY) >>".to_vec(), b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 50.0]], ""); apply(&mut doc, None).unwrap(); let second = pdfcraft_model::pages(&doc).swap_remove(1); assert!(annots_of(&doc, &second.dict).is_empty(), "the reply and the pop-up leave page 2"); let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(10).any(|w| w == b"PARENT-NOT"), "the removed comment survived the save"); } #[test] fn a_redact_mark_written_in_place_is_refused() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Redact /Rect [10 10 70 40] >>] >>" .to_vec(), stream("", b"BT ET"), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[100.0, 100.0, 120.0, 120.0]], ""); assert!(matches!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1)))); } #[test] fn do_acts_on_the_operand_before_the_operator_like_the_renderer() { // `/Missing /Im Do`: the renderer draws /Im, so redaction must find the image there. let img = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"\x00"); let mut doc = one_page(b"q 100 0 0 100 10 10 cm /Missing /Im Do Q", "/XObject << /Im 7 0 R >>", vec![img]); mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!(r.images_removed, 1, "{r:?}"); } #[test] fn hidden_text_removal_drops_the_source_text_of_added_content() { let mut doc = one_page(b"BT /F1 12 Tf 3 Tr 20 20 Td (SECRET) Tj ET", "", vec![]); let content_ref = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").and_then(Object::as_ref).unwrap(); doc.update_dict(content_ref, |d| { d.set(b"PCAdded".to_vec(), Object::Dict(Dict::new())); }) .unwrap(); sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); let p = &pdfcraft_model::pages(&doc)[0]; let (list, _) = page_streams(&doc, &p.dict, 0).unwrap(); assert!(list.iter().all(|o| doc.resolve(o).as_dict().is_some_and(|d| !d.contains(b"PCAdded")))); } #[test] fn a_reply_on_another_page_to_a_removed_popup_goes_too() { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [6 0 R 8 0 R] >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [7 0 R] >>".to_vec(), stream("", b"BT ET"), b"<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (PARENT-NOTE) /Popup 8 0 R >>".to_vec(), b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 8 0 R /Contents (POPUP-REPLY) >>".to_vec(), b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R /Contents (POPUP-TEXT) >>".to_vec(), ]; let mut doc = pdf(objs); mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 50.0]], ""); apply(&mut doc, None).unwrap(); let second = pdfcraft_model::pages(&doc).swap_remove(1); assert!(annots_of(&doc, &second.dict).is_empty()); let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); for gone in [&b"POPUP-REPLY"[..], b"POPUP-TEXT", b"PARENT-NOTE"] { assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone)); } }