Files
pdfcraft/crates/redact/src/tests.rs
T

2951 lines
158 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Vec<u8>>) -> 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<u8> {
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<Vec<u8>>) -> Document {
let mut objs: Vec<Vec<u8>> = 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::<Vec<_>>().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<u8>> = 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<u8> = 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<u8>> = 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<u8>> = 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<u8>> = 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<Vec<u8>> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
match &*doc.get(xo.get(name)?.as_ref()?) {
Object::Stream(s) => s.decoded().ok(),
_ => None,
}
}
#[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::<Vec<_>>().join(" ");
let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms);
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"fixture lacked nested payload before sanitization"
);
let counts = scan(&doc);
assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}");
let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
assert!(
removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0),
"depth-limited Form was not reported removed: {removed:?}"
);
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save");
}
#[test]
fn hidden_ocmd_resolves_an_indirect_policy_name() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(),
],
);
doc.set(ObjRef::new(12, 0), Object::name("AnyOn"));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
}
#[test]
fn surviving_anyoff_and_not_content_keeps_its_off_group_state() {
use crate::sanitize::{Hidden, remove_hidden};
for use_not in [false, true] {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() },
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let root = doc.root().unwrap();
let catalog = doc.get(root);
let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o));
let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o));
let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o));
assert!(
off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))),
"off group lost for /VE Not={use_not}"
);
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
}
}
#[test]
fn hidden_ocmd_policy_matches_the_default_configuration() {
use crate::sanitize::{Hidden, remove_hidden};
let cases = [
(None, false, None, false),
(Some("AnyOn"), false, None, false),
(Some("AllOn"), false, None, true),
(Some("AnyOff"), false, None, false),
(Some("AllOff"), false, None, true),
(Some("UnknownPolicy"), false, None, true),
(None, true, None, true),
(None, false, Some("and"), true),
(None, false, Some("and_single_not"), true),
(None, false, Some("and_single_off"), true),
(None, false, Some("or_single_on"), false),
(None, false, Some("or_single_off"), true),
(Some("AllOn"), false, Some("not_off"), false),
(None, false, Some("malformed_not"), true),
(None, false, Some("malformed_without_ocgs"), true),
];
for (policy, malformed_policy, visibility, should_remove) in cases {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let mut membership = Dict::new();
membership.set(b"Type".to_vec(), Object::name("OCMD"));
if visibility != Some("malformed_without_ocgs") {
membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))]));
}
if let Some(policy) = policy {
membership.set(b"P".to_vec(), Object::name(policy));
} else if malformed_policy {
membership.set(b"P".to_vec(), Object::Int(17));
}
match visibility {
Some("and") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("and_single_not") => membership.set(
b"VE".to_vec(),
Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]),
),
Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])),
Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])),
Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])),
Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_not") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_without_ocgs") => {
membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
}
_ => {}
}
doc.set(ObjRef::new(9, 0), Object::Dict(membership));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"));
assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}");
}
}
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA
// 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<u8>> = 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"<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><ssn>SSN-987-65-4321</ssn></xfa:data></xfa:datasets>",
),
stream(
"",
b"<template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"><field name=\"ssn\"/></subform></template>",
),
];
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<u8>> = 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<u8>> = 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<u8>> = 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"<x:xmpmeta/>"),
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<Hidden, usize> = 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<Vec<u8>>) -> Document {
let mut objs: Vec<Vec<u8>> = 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<T: Send + 'static>(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<u8>> = 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<u8>> = 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<Object> = 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<u8>> = 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<u8>> = 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<u8>> = 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> = [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<u8>> = 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 <</MCID 0>> 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<u8>> = 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<i64> = [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<u8>> = 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> = [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<u8>> = 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 <</MCID 0>> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC /P <</MCID 1>> 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<u8>> = 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<u8>> {
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 <</MCID 0>> 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<Vec<u8>> {
let mut objs: Vec<Vec<u8>> = 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 <</MCID 0>> 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<String> = (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<Hidden, usize> = 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<String> = 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::<Vec<_>>(), [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::<Vec<_>>(), [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 <</MCID 0>> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <</MCID 1>> 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 <</MCID 1>> 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 <</MCID 1>> 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<ObjRef>) {
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<u32> = 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 <</ActualText (LEAK_INLINE)>> 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 <</ActualText (Public point)>> 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 <</ActualText (LEAK_REMOVED)>> 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<u8>> = 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<Hidden, usize> = 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 <</MCID 0>> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <</MCID 1>> 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 <</MCID 0>> 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<Vec<u8>>) -> 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"<x:xmpmeta>LEAK_XMP</x:xmpmeta>")]);
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<Hidden, usize> = 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<Object> = [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<Object> = [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<u8>> = 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<u8>> = 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<u8>> = 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));
}
}