mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
* Redaction: a pattern nothing paints with any more leaves the page
A tiling pattern's cell is a content stream of its own and can draw
anything, text included. Redacting a shape filled with such a pattern
removed the fill from the page's content -- the page looked right, a
black box -- but left the pattern in /Resources /Pattern, and its cell,
with the text that was under the mark, was written into the saved file
unchanged.
The pass does not enter pattern cells, so it cannot redact inside one.
What it can do is notice that nothing paints with the pattern any more.
The graphics state now carries the fill and stroke pattern a `scn` or
`SCN` selected (and `cs`, `g`, `rg`, `k` and their stroke forms clear
it), every painting operator that survives the rewrite records the
pattern it drew with, and a pattern the page selected but no surviving
paint used leaves the page's resources.
Measured on the report's file:
before applied: 1, (SECRET) in the saved bytes
after applied: 1, no /Pattern on the page, (SECRET) gone
A pattern some surviving paint still uses is kept, and so is its cell:
with two boxes filled from one pattern and only the first marked,
patterns_removed is 0 and the resources keep the entry -- the second box
still shows the word, so it is not a leak.
Report gains patterns_removed.
Fixes #3647.
* Keep a pattern every operator that can paint with it still uses
Two defects CodeRabbit found in the first version of this change, both
of which could remove a pattern the page still draws with.
Pattern use was recorded for path painting only. Text shows with the
fill pattern too -- and with the stroke pattern in the stroking render
modes -- as do image masks and anything a form draws, and a form's
content is interpreted with a graphics state of its own, so a pattern it
inherits is never seen inside it. Content like
/Pattern cs /P0 scn 20 140 100 30 re f BT (KEEP) Tj ET
with the box redacted therefore lost the fill of text still on the page.
Every operator that can paint with the current pattern now records it:
text (except mode 3, which paints nothing, and mode 7, which only
clips), inline images, and image or form XObjects. Only path painting
knows exactly what it drew and whether the paint survived, so the others
are recorded as they are met. That can keep a pattern whose paint was in
fact all redacted, which costs an unused resource entry, while dropping
one something still draws with takes paint off the page.
/Resources /Pattern may be an indirect object, like any value. Reading
the reference itself as a dictionary found none and pruned nothing, so
on such a file the cell -- with the text that was under the mark --
stayed in the saved bytes, which is the leak this change is for. It is
resolved now, and what is written back is a dictionary of this page's
own, so pruning never reaches through a /Pattern dictionary another page
shares.
The controls in the test: without the first fix the surviving text's
pattern is removed (left: 1, right: 0), and without the second the
indirect file prunes nothing (left: 0, right: 1).
* M8: refuse retained unsafe tiling patterns
* M8: guard shared appearances and narrow pattern scans
* M8: update TLS dependencies for new advisories
* Redaction: the comment on prune_patterns said more than the code does
`prune_patterns` drops the `/Pattern` resource entry; the `scn` that selected
it stays in the content, so the page names a pattern its resources no longer
hold. The comment claimed the selector went too (noted in review).
ISO 32000-1 8.6.6.2 makes that operand a key of the `/Pattern` subdictionary,
so the open question is whether a reader stops reading the stream at the stale
name. `a_page_whose_pruned_pattern_is_still_selected_renders` answers it with
the renderer rather than with prose: the redacted page is saved, reopened and
rasterized, and
* it renders with no error and the same 300x300 page box,
* the text written *after* the stale selector is still painted -- which is
exactly what a reader would drop if it gave up at the bad operand,
* the redaction overlay is black over the box the pattern filled.
Control: with the drawing after the selector removed, the pixel assertion fails
("the text after the stale selector is still painted"), so it is reading that
text and not a stray pixel.
The selector is left in place rather than cut out: removing operators is a
rewrite of the content this pass does not otherwise do, and nothing surviving
paints with that colour. `pdfcraft-render` is a dev dependency of this crate
only -- layer 4 on layer 3, which the layering allows.
* WIP: pattern guard depth bound sized from the interpreter's
* Pattern tests: keep the guard's cases reaching the guard now a hit path is removed whole
* Pattern automation tests: keep the pattern in use where they test the guard, since a hit path is now removed whole
* Redaction: a form without resources keeps the page pattern it still paints
A form with no /Resources of its own paints with its caller's patterns, but
its surviving pattern paint was recorded only in the form's own output and
never reached the page. With the page's own fill under the mark removed
whole, prune_patterns then dropped /P0 from the page while the form outside
the mark still painted with it, so that fill lost its paint in the saved file.
Pass the painted names up from a form that inherits its caller's resources.
Only the painted side goes up, so this can keep a pattern, never prune one.
* Redaction: a pruned pattern does not stay named by the page tree
prune_patterns writes only the page's own /Resources, and the guard walked
only pages' effective resources. A /Pages node that still named the pruned
pattern kept its cell reachable from Root, so a full save wrote the cell,
text included, while apply returned Ok. Three shapes did it: a page with no
/Parent inheriting from the root, a /Parent that disagrees with the /Kids
tree, and a root /Resources shadowed by a nearer holder that was retired.
Patterns pruned from a page are now unlinked from page-tree holders no page
inherits from, as unlink_doomed does for XObjects, when every /Parent agrees
with the tree. Whenever a pattern was pruned (or a selected page had any),
the guard also inspects the /Resources of every /Pages node Root reaches
down /Kids and up each page's /Parent chain, so whatever stays reachable is
proven safe or the redaction is refused, leaving the document unchanged.
* Redaction: a Type 3 glyph without resources keeps the page pattern it paints
A Type 3 font with no /Resources looks the names in its glyph descriptions
up in the resources of the page or form showing it, so a glyph outside the
mark can paint with the page's pattern. Text recorded only the fill and
stroke pattern of the graphics state, so with the page's own fill under the
mark removed whole, prune_patterns dropped the pattern and the glyph lost its
paint. Showing such a font now keeps every pattern of the scope, as a form
without /Resources keeps what it paints.
* Redaction: say what text-recorded pattern use keeps
Text, inline images and XObjects record their pattern before the hit test,
so a pattern painted only by redacted text keeps its cell. The guard refuses
a cell with text or images; a vector-only cell stays in the file. The comment
said this cost only an unused resource entry. Pin the behaviour in a test.
* Redaction: the page tree is followed up every /Parent link
A full save writes every node a /Parent link reaches, but the guard followed
/Parent only from pages and stopped at the first node it had already seen,
and unlinking never followed it. A /Pages node reached only through the
/Parent of a /Kids node -- a root /Pages with a /Parent, or an intermediate
node whose /Parent leads out of the tree -- kept naming the pruned pattern,
and its cell, text included, stayed in the saved file while apply returned
Ok.
The guard now follows /Parent from every node it visits, page or /Pages, with
the same seen-set against cycles. Unlinking treats a root with a /Parent as an
inconsistent tree, like a disagreeing link, and leaves the decision to the
guard, which inspects every node those links reach and refuses an unsafe
cell.
* Redaction: a long page tree does not exhaust the pattern guard's budget
The page-tree walk counted every node, pages included, against the guard's
16,384-object budget, so redacting a pattern page of a 17,000-page document
was refused with a limit error where it had applied before. Only the holder
nodes the guard inspects count against that budget now; the walk itself is
bounded at a million nodes, as unlink_doomed bounds the same tree.
* Redaction: a Type 3 font set by an ExtGState or shown in a form keeps the page patterns
The resourceless Type 3 check covered only a font set by Tf in the scope that
paints. A Type 3 font without /Resources set through an ExtGState /Font entry
was not flagged, and a glyph shown inside a form with /Resources of its own
recorded its patterns in the form's scope, which the page never saw. In both
cases the page's own fill under the mark was removed whole, prune_patterns
dropped the pattern, and the glyph outside the mark lost its paint.
An ExtGState font is now checked the same way, and a glyph shown in any
nested form marks the page as keeping every pattern it selected. This can
only keep patterns; whatever is kept is checked by the guard.
* Redaction: a pruned pattern a full save would still write is proven safe or refused
The walks that check retained pattern cells follow the references they know
-- resource categories, the page tree, appearances -- but a full save writes
whatever the trailer reaches by any reference. A direct /Parent dictionary, a
/Kids array on a page, or a stray resource entry (/Shading naming the pattern
stream) kept a pruned cell, text included, in the saved file while apply
returned Ok.
pdfcraft_cos gains reachable_objects, which runs the same breadth-first walk
write_full uses (factored out as full_save_order, so the two cannot drift).
Before apply succeeds, every pruned pattern that walk still reaches is
inspected like a retained cell: vector-only passes, anything else refuses
with the existing pattern error. It runs only when a pattern was pruned and
costs one walk of the reachable objects.
* Redaction: a direct /Parent dictionary is part of the page tree too
A /Parent given as a dictionary rather than a reference is written inline by
a full save, but the page-tree walk followed /Parent only as a reference, and
unlinking took such a tree for consistent. A root /Pages whose direct /Parent
held /Resources naming the pruned pattern kept its cell in the saved file.
The guard now walks a direct /Parent like any node (its /Resources, /Parent
and /Kids), and unlinking counts any /Parent that is not the reference /Kids
came from as an inconsistent tree, leaving the decision to the guard.
* Redaction: page-tree walks queue each node once and borrow /Kids
On a hostile tree whose /Pages nodes share one long /Kids array, the guard's
page-tree walk and pattern unlinking pushed every /Kids entry of every node
before checking whether it had been seen, and copied the array on each
visit. Both now queue only nodes not seen yet and read /Kids in place;
unlinking also stops at the first node reached twice, since its /Parent can
agree with at most one of the nodes above it, and leaves that tree to the
guard.
* Redaction: a Type 3 font with /Resources null is resourceless too
/Resources null is the same as no /Resources, but the check counted any
entry, so such a font's glyph outside the mark lost the page pattern it
painted with. A Type 3 glyph shown by a kept annotation appearance is a known
limit, pinned as an ignored test: appearances are not interpreted for pattern
use, and under ISO 32000 the glyph resolves in the appearance's own resources.
* Redaction: a pruned pattern is recorded along its whole reference chain
A pattern name can lead to a reference-valued object whose value is the cell
(/P0 7 0 R, object 7 being 6 0 R). Pruning recorded only object 7, so the
reachability backstop checked an object nothing reached while a stray
reference kept the cell, object 6, in the saved file. Every object along the
chain is recorded now, bounded like the other walks.
* Pattern tests: pin the file identifier where two saves are compared
main now gives a redacted rewrite a new identifier from fresh entropy, so an
incremental and a full save after Apply differ in /ID alone. The test that
checks they are the same save passes fixed entropy to both.
* Redaction: replies and pop-ups removed on other pages feed the shared-appearance check
Apply checks every page for retained unsafe pattern cells when a removed
annotation's appearance is shared with one that stays on an unselected page.
Only annotations removed under a mark fed that check. Replies and pop-ups
that go with them in step 2b (removed_everywhere), often listed on another
page, did not, so a text-bearing cell in an appearance a kept annotation
shares with them escaped the all-pages check and Apply returned Ok.
Their appearance references now go into removed_appearances too, with the
same limit. Raised by FredApps' review of the rebase.
---------
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
305 lines
15 KiB
Rust
305 lines
15 KiB
Rust
//! Pattern redaction against references no walk follows -- a direct `/Parent`, `/Kids` on a
|
|
//! page, a stray resource entry -- Type 3 fonts reached in other ways, and hostile wide trees.
|
|
//! Each test reads the saved bytes back.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad};
|
|
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
|
|
use pdfcraft_redact::{RedactError, apply, marks};
|
|
use pdfcraft_testkit::{Pdf, stream_obj};
|
|
|
|
const SECRET: &[u8] = b"ADV2CELLSECRET";
|
|
const TEXT_CELL: &[u8] = b"BT /F1 20 Tf 40 120 Td (ADV2CELLSECRET) Tj ET\n";
|
|
const PATTERN: &str =
|
|
"/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 500 300] /XStep 500 /YStep 300 /Resources << /Font << /F1 4 0 R >> >>";
|
|
const FONT: &[u8] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
|
|
const RES: &str = "<< /Font << /F1 4 0 R >> /Pattern << /P0 6 0 R >> >>";
|
|
const AREA: [f64; 4] = [30.0, 105.0, 215.0, 150.0];
|
|
const CONTENT: &[u8] = b"BT /F1 20 Tf 40 250 Td (PUBLIC) Tj ET\n/Pattern cs /P0 scn 35 110 170 35 re f\n";
|
|
|
|
fn open(objs: Vec<Vec<u8>>) -> Document {
|
|
Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap()
|
|
}
|
|
|
|
fn mark(doc: &mut Document, page: usize) {
|
|
let shape = Shape::Redact { quads: vec![rect_quad(AREA)], overlay: String::new(), look: Default::default() };
|
|
let style = Style::default_for(&shape);
|
|
add_annotation(doc, &NewAnnotation { page, shape, style, contents: String::new(), author: "Synthetic test".into() }, &Meta::default()).unwrap();
|
|
}
|
|
|
|
fn decoded_streams_contain(bytes: Vec<u8>, needle: &[u8]) -> bool {
|
|
let doc = Document::open(Arc::new(bytes)).unwrap();
|
|
doc.object_numbers().into_iter().any(|num| {
|
|
let object = doc.try_get(num).unwrap();
|
|
matches!(&*object, Object::Stream(s) if s.decoded().unwrap_or_default().windows(needle.len()).any(|w| w == needle))
|
|
})
|
|
}
|
|
|
|
fn saved_contains(doc: &Document, needle: &[u8]) -> bool {
|
|
[true, false].into_iter().any(|object_streams| {
|
|
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
|
|
decoded_streams_contain(write_full(doc, &opts).unwrap(), needle) || decoded_streams_contain(write_incremental(doc, &opts).unwrap(), needle)
|
|
})
|
|
}
|
|
|
|
/// Ok means confidential; Err must be a pattern refusal that leaves the document unchanged.
|
|
/// Returns whether it applied.
|
|
fn confidential_or_refused(mut doc: Document, page: usize) -> bool {
|
|
mark(&mut doc, page);
|
|
assert!(saved_contains(&doc, SECRET), "the fixture must carry the secret");
|
|
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
|
match apply(&mut doc, None) {
|
|
Ok(report) => {
|
|
assert_eq!(report.paths_removed, 1, "{report:?}");
|
|
assert!(!saved_contains(&doc, SECRET), "Apply succeeded but the saved file still holds the pattern cell's text ({report:?})");
|
|
true
|
|
}
|
|
Err(error) => {
|
|
assert!(matches!(error, RedactError::Pattern { .. }), "{error}");
|
|
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before);
|
|
assert_eq!(marks(&doc).len(), 1);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn catalog() -> Vec<u8> {
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()
|
|
}
|
|
|
|
fn glyph_pixel(doc: &Document, x: u32, row: u32) -> Vec<u8> {
|
|
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
|
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
|
|
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
|
let i = ((row * out.width + x) * 4) as usize;
|
|
out.rgba[i..i + 3].to_vec()
|
|
}
|
|
|
|
const T3: &[u8] = b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 40 40] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [40] >>";
|
|
const BLUE_CELL: &[u8] = b"0 0 1 rg 0 0 500 300 re f\n";
|
|
|
|
/// The root's `/Parent` is a direct dictionary, not a reference: a full save writes it inline
|
|
/// with the root, and the pattern it names with it.
|
|
#[test]
|
|
fn a_direct_parent_dictionary_on_the_root_does_not_keep_the_pruned_cell() {
|
|
let doc = open(vec![
|
|
catalog(),
|
|
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /Parent << /Type /Pages /Kids [] /Count 0 /Resources {RES} >> /Resources {RES} >>")
|
|
.into_bytes(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R >>".to_vec(),
|
|
FONT.to_vec(),
|
|
stream_obj("", CONTENT),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
]);
|
|
confidential_or_refused(doc, 0);
|
|
}
|
|
/// A leaf (`/Type /Page`) that also carries `/Kids`: readers stop at the leaf, a full save does
|
|
/// not, and the hidden node names the pattern.
|
|
#[test]
|
|
fn a_node_hidden_under_a_leaf_s_kids_does_not_keep_the_pruned_cell() {
|
|
let doc = open(vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources {RES} /Kids [7 0 R] >>").into_bytes(),
|
|
FONT.to_vec(),
|
|
stream_obj("", CONTENT),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
format!("<< /Type /Pages /Parent 3 0 R /Kids [] /Count 0 /Resources {RES} >>").into_bytes(),
|
|
]);
|
|
confidential_or_refused(doc, 0);
|
|
}
|
|
/// A stray reference to the cell from a resource category the guard does not inspect
|
|
/// (`/Shading`): the class #3999 tracks, shown here for patterns.
|
|
#[test]
|
|
fn a_stray_resource_reference_to_the_cell_does_not_keep_it() {
|
|
let doc = open(vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources << /Font << /F1 4 0 R >> /Pattern << /P0 6 0 R >> /Shading << /Sh 6 0 R >> >> >>".to_vec(),
|
|
FONT.to_vec(),
|
|
stream_obj("", CONTENT),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
]);
|
|
confidential_or_refused(doc, 0);
|
|
}
|
|
fn type3_page(page_res_extra: &str, content: &[u8], extra: Vec<Vec<u8>>) -> Document {
|
|
let mut objs = vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources << /Font << /F1 4 0 R /T3 7 0 R >> /Pattern << /P0 6 0 R >> {page_res_extra} >> >>").into_bytes(),
|
|
FONT.to_vec(),
|
|
stream_obj("", content),
|
|
stream_obj(PATTERN, BLUE_CELL),
|
|
T3.to_vec(),
|
|
stream_obj("", b"40 0 d0 /Pattern cs /P0 scn 0 0 40 40 re f\n"),
|
|
];
|
|
objs.extend(extra);
|
|
open(objs)
|
|
}
|
|
fn assert_glyph_kept(mut doc: Document) {
|
|
let before = glyph_pixel(&doc, 320, 50);
|
|
assert_eq!(before, vec![0, 0, 255], "the glyph paints with the page pattern before the redaction");
|
|
mark(&mut doc, 0);
|
|
let report = apply(&mut doc, None).unwrap();
|
|
assert_eq!(glyph_pixel(&doc, 320, 50), before, "the unmarked glyph lost its pattern paint ({report:?})");
|
|
}
|
|
/// The Type 3 font is shown in a form without `/Resources`, which draws through the page's.
|
|
#[test]
|
|
fn a_type3_glyph_in_a_resourceless_form_keeps_the_page_pattern() {
|
|
assert_glyph_kept(type3_page(
|
|
"/XObject << /Fm 9 0 R >>",
|
|
b"/Fm Do\n/Pattern cs /P0 scn 35 110 170 35 re f\n",
|
|
vec![stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 500 300]", b"BT /T3 1 Tf 300 230 Td (a) Tj ET\n")],
|
|
));
|
|
}
|
|
/// The Type 3 font is shown inside a pattern cell the page paints outside the mark: the cell
|
|
/// carries text, so the guard refuses rather than dropping anything.
|
|
#[test]
|
|
fn a_type3_glyph_inside_a_pattern_cell_is_refused() {
|
|
let mut doc = open(vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources << /Font << /F1 4 0 R >> /Pattern << /P0 6 0 R /P1 9 0 R >> >> >>".to_vec(),
|
|
FONT.to_vec(),
|
|
stream_obj("", b"/Pattern cs /P1 scn 300 200 100 40 re f\n/Pattern cs /P0 scn 35 110 170 35 re f\n"),
|
|
stream_obj(PATTERN, BLUE_CELL),
|
|
T3.to_vec(),
|
|
stream_obj("", b"40 0 d0 /Pattern cs /P0 scn 0 0 40 40 re f\n"),
|
|
stream_obj("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 500 300] /XStep 500 /YStep 300 /Resources << /Font << /T3 7 0 R >> >>", b"BT /T3 1 Tf 300 230 Td (a) Tj ET\n"),
|
|
]);
|
|
mark(&mut doc, 0);
|
|
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
|
let error = apply(&mut doc, None).unwrap_err();
|
|
assert!(matches!(error, RedactError::Pattern { .. }), "{error}");
|
|
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before);
|
|
}
|
|
/// A hostile wide tree: `K` `/Pages` nodes that all share one `/Kids` array of `M` references
|
|
/// back to themselves, beside the real page. Time and memory are measured from outside
|
|
/// (`PDF_WIDE_K`, `PDF_WIDE_M`). Ignored by default.
|
|
#[test]
|
|
#[ignore]
|
|
fn hostile_wide_tree_probe() {
|
|
let k: usize = std::env::var("PDF_WIDE_K").ok().and_then(|v| v.parse().ok()).unwrap_or(200);
|
|
let m: usize = std::env::var("PDF_WIDE_M").ok().and_then(|v| v.parse().ok()).unwrap_or(500_000);
|
|
let mut array = String::with_capacity(m * 9);
|
|
for i in 0..m {
|
|
array.push_str(&format!("{} 0 R ", 10 + i % k));
|
|
}
|
|
let mut objs = vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 1 >>".to_vec(),
|
|
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources {RES} >>").into_bytes(),
|
|
FONT.to_vec(),
|
|
stream_obj("", CONTENT),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
format!("[{array}]").into_bytes(),
|
|
b"null".to_vec(),
|
|
b"null".to_vec(),
|
|
];
|
|
for _ in 0..k {
|
|
objs.push(b"<< /Type /Pages /Kids 7 0 R /Count 0 >>".to_vec());
|
|
}
|
|
let mut doc = open(objs);
|
|
mark(&mut doc, 0);
|
|
let start = std::time::Instant::now();
|
|
let result = apply(&mut doc, None);
|
|
eprintln!("WIDE k={k} m={m} apply={:?} in {:?}", result.as_ref().map(|r| r.patterns_removed), start.elapsed());
|
|
}
|
|
/// The Type 3 font has `/Resources null`, which readers treat as absent.
|
|
#[test]
|
|
fn a_type3_font_with_null_resources_keeps_the_page_pattern() {
|
|
let mut doc = type3_page("", b"BT /T3 1 Tf 300 230 Td (a) Tj ET\n/Pattern cs /P0 scn 35 110 170 35 re f\n", vec![]);
|
|
doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |d| d.set(b"Resources".to_vec(), Object::Null)).unwrap();
|
|
assert_glyph_kept(doc);
|
|
}
|
|
/// Known limit (fidelity only, never a leak: a missed use can only prune more). Kept annotation
|
|
/// appearances are not interpreted for pattern use, so a Type 3 font without resources shown in
|
|
/// one does not keep the page's patterns. ISO 32000 resolves the glyph's names in the
|
|
/// appearance's own resources, where `/P0` does not exist, so only a lenient renderer draws it.
|
|
/// The Type 3 font is shown by a kept annotation's appearance stream (outside the mark).
|
|
#[test]
|
|
#[ignore = "known limit: kept annotation appearances are not interpreted, so a resourceless Type 3 glyph in one does not keep the page pattern; ISO 32000 resolves it in the appearance's own resources, which pdfcraft's renderer does not"]
|
|
fn a_type3_glyph_in_an_annotation_appearance_renders_the_same() {
|
|
let mut doc = type3_page(
|
|
"",
|
|
b"/Pattern cs /P0 scn 35 110 170 35 re f\n",
|
|
vec![
|
|
stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Resources << /Font << /T3 7 0 R >> >>", b"BT /T3 1 Tf 0 0 Td (a) Tj ET\n"),
|
|
b"<< /Type /Annot /Subtype /Stamp /Rect [300 230 340 270] /F 4 /AP << /N 9 0 R >> >>".to_vec(),
|
|
],
|
|
);
|
|
doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| {
|
|
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef::new(10, 0))]))
|
|
})
|
|
.unwrap();
|
|
let before = glyph_pixel(&doc, 320, 50);
|
|
mark(&mut doc, 0);
|
|
let report = apply(&mut doc, None).unwrap();
|
|
assert_eq!(glyph_pixel(&doc, 320, 50), before, "the annotation renders differently ({report:?}, before {before:?})");
|
|
}
|
|
/// A form wholly under the mark is removed; its own `/Pattern` cell carries text. Nothing prunes
|
|
/// that pattern, so the backstop never looks, and a stray reference (`/Shading`) keeps the cell.
|
|
#[test]
|
|
fn a_removed_form_s_pattern_does_not_survive_through_a_stray_reference() {
|
|
let doc = open(vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources << /Font << /F1 4 0 R >> /XObject << /Fm 7 0 R >> /Shading << /Sh 6 0 R >> >> >>".to_vec(),
|
|
FONT.to_vec(),
|
|
stream_obj("", b"BT /F1 20 Tf 40 250 Td (PUBLIC) Tj ET\nq 1 0 0 1 35 110 cm /Fm Do Q\n"),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 170 35] /Resources << /Pattern << /P0 6 0 R >> >>", b"/Pattern cs /P0 scn 0 0 170 35 re f\n"),
|
|
]);
|
|
let mut doc = doc;
|
|
mark(&mut doc, 0);
|
|
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
|
match apply(&mut doc, None) {
|
|
Ok(report) => assert!(!saved_contains(&doc, SECRET), "Apply succeeded but the removed form's cell is in the saved file ({report:?})"),
|
|
Err(error) => {
|
|
assert!(matches!(error, RedactError::Pattern { .. }), "{error}");
|
|
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before);
|
|
}
|
|
}
|
|
}
|
|
/// `/P0` names an indirect object whose value is a reference to the cell; the cell is also named
|
|
/// by a stray reference. The backstop checks the number it pruned, not what it points at.
|
|
#[test]
|
|
fn a_pruned_pattern_named_through_a_reference_object_does_not_dodge_the_backstop() {
|
|
let doc = open(vec![
|
|
catalog(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R /Resources << /Font << /F1 4 0 R >> /Pattern << /P0 7 0 R >> /Shading << /Sh 6 0 R >> >> >>".to_vec(),
|
|
FONT.to_vec(),
|
|
stream_obj("", CONTENT),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
b"6 0 R".to_vec(),
|
|
]);
|
|
confidential_or_refused(doc, 0);
|
|
}
|
|
/// The backstop's reachability is a full save's: after Apply an incremental save must be that
|
|
/// full save, byte for byte, never an appended revision over the old bytes.
|
|
#[test]
|
|
fn an_incremental_save_after_apply_is_the_full_save() {
|
|
let doc = open(vec![
|
|
catalog(),
|
|
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /Resources {RES} >>").into_bytes(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 300] /Contents 5 0 R >>".to_vec(),
|
|
FONT.to_vec(),
|
|
stream_obj("", CONTENT),
|
|
stream_obj(PATTERN, TEXT_CELL),
|
|
]);
|
|
let mut doc = doc;
|
|
mark(&mut doc, 0);
|
|
apply(&mut doc, None).unwrap();
|
|
for object_streams in [true, false] {
|
|
// A redacted rewrite gets a new identifier from fresh entropy; pin it so the two saves
|
|
// can be compared byte for byte.
|
|
let opts = SaveOptions { object_streams, id_entropy: Some([7; 16]), ..SaveOptions::default() };
|
|
assert_eq!(write_incremental(&doc, &opts).unwrap(), write_full(&doc, &opts).unwrap());
|
|
}
|
|
// A later edit after a save-and-reopen starts from the clean file.
|
|
let reopened = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
|
assert!(!reopened.bytes().windows(SECRET.len()).any(|w| w == SECRET));
|
|
}
|