Commit Graph
22 Commits
Author SHA1 Message Date
Dylan M. Taylor c73cccfb33 fix(redact): hidden-layer removal reads the default state as viewers do (#5867)
* fix(redact): hidden-layer removal reads the default state as viewers do

hidden_layers ignored an indirect /BaseState and, with /BaseState /OFF,
let /ON override /OFF, so a group both lists named survived as visible.
The OCMD and OCG classifier also read /Type directly, so a membership
dictionary with an indirect /Type was never evaluated and its content kept.

The default state is now /BaseState (resolved), then /ON, then /OFF, and
/Type is resolved before classifying a group or membership dictionary.

* address review: follow chained /Type and /BaseState references, hash-set ON/OFF lists

* address review: a visibility expression naming a group through an alias reference fails closed
2026-11-11 15:37:44 -05:00
FredApps 203c4fd8fa Merge pull request #6927 from Piohrun/fix/redact-marked-content-last-operands
M8: redaction reads an operator's operands from the end, as the renderer does
2026-11-11 14:38:05 +01:00
Grzegorz MartensandClaude Opus 5.5 b16f0c0a77 M8: redaction reads an operator's operands from the end, as the renderer does
An operator takes its operands from the end, and the renderer ignores a
surplus before them: `(x) /OC /MC0 BDC` marks a layer's block, and
`(x) /GS1 gs` selects `/GS1`. Redaction read some of them from the front,
so a surplus stood in for them and what they named went unseen. Found in
review of #6882, where Remove Hidden Information left the text of a
hidden layer in `(x) /OC /MC0 BDC (LAYERSECRET) Tj EMC`, which both the
app's renderer and Poppler hide.

In what Apply Redactions and Remove Hidden Information decide:
- the layers walk read a block's tag and property list from the front,
  and kept a hidden layer's block;
- the cleanup of `/ActualText`, `/Alt`, `/E` and `/T` in a block whose
  paint was removed read (and wrote) the property list from the front,
  and kept the name that still read the removed word;
- the tags cleanup read a block's `MCID` from the front, and left the
  removed text in its structure element's `/ActualText` and `/Alt`; and a
  `TJ`'s array, so a block that still drew was taken for empty and its
  structure element lost the content the page still shows;
- the property cleanups read the name of a `BDC` or `DP` property list
  from the front, and retired a property list the page still draws with;
- the walk read the ExtGState a `gs` selects from the front, so a font set
  only that way was not set, and `cs`/`CS` the same for a pattern space;
- the Type 3 cleanup's reads of `cs`, `gs`, `Tf`, `BDC` and `DP`, which
  decide which resources and fonts are still used.
The redaction proof read `Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tf`, `"`, `TJ` and
`Do` from the front too. `pdfcraft_content::Op` gains `back`, `name_back`
and `num_back`: operand `k` counted from the end.

Tests (`crates/redact/src/tests/operands_from_the_end.rs`): a hidden
layer's block, `/ActualText` on removed paint, a tag's `MCID`, a tagged
`TJ` that still draws, a property list still drawn with, a font from an
ExtGState, the pattern a `/Pattern cs` keeps, and a Type 3 font with the
ExtGState, colour space, property list and font its glyph uses, each
after a surplus operand. In `tests_verify.rs`,
`the_proof_reads_operands_from_the_end` (`Tf`, `TJ`, and `Do` of a form
and of images), `the_proof_reads_the_text_states_operands_from_the_end`
(`Tc`, `Tw`, `Tz`, `TL`, `Ts` and `"`) and
`an_image_a_type3_glyph_draws_after_a_surplus_operand_is_found`. And
`operands_are_counted_from_the_end` in the content crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 22:22:09 +01:00
Grzegorz MartensandClaude Opus 5.5 ac1eba1092 M8: the form and hidden-text callers are tested without a Type 3 font too
Review of the allowance change. Only Apply's page caller had a row with no
Type 3 font listed. `the_cleanups_of_a_rewritten_form_spend_the_pages_allowance`
and `removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance`
now have one too: at 100 MiB the redaction and the removal go on.

Doc comments that said every cleanup of a page spends the page's allowance
(`prune_type3_from_resources`, `resource_ops`, `prune`) now say the font
cleanup has one of its own when no Type 3 font is listed. The doc of
`text_beside_bytes_the_parser_cannot_read_refuses_the_redaction` says the
cleanups run only when the walk rewrites some of the page, and read what it
rewrote as rewritten. The comment in `prune_type3_from_resources` no longer
calls its read the only check for text: `prune_form_resources` (for a
rewritten form), `prune` (when an image or form is replaced) and
`prune_properties` make the same read when they run, and `unlink_type3`
reads every page when the document still lists a Type 3 font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 14:10:19 +01:00
Grzegorz MartensandClaude Opus 5.5 e4d150a92a Merge main into fix/redact-prune-through-forms
Main reworked the redaction cleanups this branch changes: #5969 reads
each inherited form once for all names (`names_content_draws`), #5924
prunes the forms a surviving Type 3 glyph draws and passes the page to
`prune_type3_from_resources`, and #6474 reads a page's annotation
appearances toward one operator total and one byte allowance.

- `interp/prune.rs`: `names_content_draws` keeps main's one read per
  form and takes this branch's three changes to `content_draws`: it
  reads operators one at a time, holding no stream's whole and decoding
  each form only in its turn; it reads forms with `/DecodeParms` given
  by reference; and a `/Resources` that isn't a dictionary is none of
  the form's own. It charges no allowance, as `content_draws` didn't:
  it runs after the pages' walks, over every holder, where no page's
  allowance is left to spend.
- `type3.rs`: `prune_type3_from_resources` takes both the page's
  allowance and main's `page`. The appearances main reads are charged
  to that allowance, which the content and its forms spend too, in
  place of a fresh one of their own, so one page has one allowance.
  Main's `record_used` serves this branch's one-operator-at-a-time read
  of glyph programs, and `scan_stream_object`, which took over
  `scan_xobject`'s read, reads with `/DecodeParms` by reference.
  `prune_font_forms` gives each form's walk the allowance of a page, as
  `prune_retired_properties` does for each content.
- `interp.rs`, `lib.rs`, `sanitize.rs`: both arguments at each call.
- `edit/src/rules.rs`: the refusal of incomplete content stays, with
  main's `text::joined`.
- Tests: both sides' kept, in `edit/src/tests.rs` and
  `redact/src/tests/surviving_references.rs`. A new one,
  `a_page_s_appearances_spend_what_its_content_left_of_the_allowance`,
  pins the shared allowance: 1 MiB of content and an appearance within
  half a MiB of the allowance are refused together, though the
  appearance alone fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 11:42:10 +01:00
bflatastic 12dfc73cfd Format the renamed helper's call 2026-11-09 09:03:50 +07:00
bflatastic fb9926858a Name the content-draws helper apart from the undrawn-image one in redaction pruning 2026-11-09 09:02:23 +07:00
bflatastic 52f31397be Merge #5969: fix(redact): read inherited forms once for all names in resource cleanup
# Conflicts:
#	crates/redact/src/tests/surviving_references.rs
2026-11-09 08:06:41 +07:00
bflatastic a97b83fc83 Read undrawn-image streams with their /DecodeParms by reference 2026-11-09 08:06:03 +07:00
Dylan M. Taylor da5a664276 review: keep reading queued forms after one that will not decode 2026-11-08 11:14:23 -05:00
oracola91 e27d8d9cc1 Unlinking undrawn images matches what a reader draws
A page's content streams are read as one, and forms named through a
reference-only object or listed in a Type 3 font's own resources are
read too, so the images they draw stay. A page tree that lists a node
twice is left alone. The pass repeats while names go, so an image only
an unused form drew now leaves with that form.
2026-11-07 16:58:46 +02:00
oracola91 6a56476a07 Remove Hidden Information unlinks images nothing draws
Delete Image takes only the Do out of the page, and Replace Image adds a
new name and leaves the old image listed. Remove Hidden Information only
pruned the names its own content pass replaced, so a deleted image stayed
in the page's resources and in the full-rewrite save.

Every run now unlinks the image and form XObjects that nothing draws from
the page-tree resource dictionaries that name them. A name stays when
anything drawing through the dictionary uses it with Do: the pages, their
annotations' appearances, and every form, tiling pattern, Type 3 glyph and
soft mask they reach, with /Resources of their own or not, since a name
those don't define is looked up in the page's. Going by name keeps more
than it must, never less. Each stream is read once against one decode
budget; a page or stream that won't decode, or a spent budget, leaves the
dictionary as it is. The page-tree walk is shared with unlink_doomed.
2026-11-06 20:30:25 +02:00
Grzegorz MartensandClaude Opus 5.5 2976caeb32 M8: the forms redaction follows through resources are read one operator at a time
resource_ops parsed the content and each form it follows whole before
checking its 2,000,000-operator limit, and content_draws parsed whole
streams with no limit at all. A parsed operator takes about 120 bytes,
and a form of `n` operators outside the mark cost 78 to 138 bytes of
memory per byte of content. Measured as the growth of the process's
peak: 629 MiB at 8 MiB, 1,253 MiB at 16 MiB and 4,415 MiB at 32 MiB, so
tens of GiB at the 256 MiB the page's allowance admits, from a file of
a few hundred KiB. The page's check of what it still
draws (prune), added in this branch, reads such forms on pages with no
fonts or properties too. On main the font and property cleanups read
them, and the unlinking check (content_draws) on any page that shares a
dictionary with a removed image, so the content_draws half fixes what
main does too.

Both now read with pdfcraft_content::ops, one operator at a time.
resource_ops holds at most one past its limit, and content_draws holds
no operators, as it only looks for Do. Measured as the peak resident memory of
the whole process (dev profile): the page's check over a form of 8, 16
and 64 MiB of `n` takes 311, 327 and 422 MiB, and the unlinking check
over one of 16 MiB takes 69 MiB, against 1,269 before (2,527 MiB at
32 MiB).

This bounds the operators held, not their operands: the parser still
holds the operands that no operator ends, so content of operands alone
costs about 30 bytes per byte (974 MiB for 32 MiB of `0`). Bounding
those is pdfcraft-content's, for every reader of content.

It also drops resource_ops' check that it followed no more than
2,000,000 forms. Each form it follows is a Do among the operators it
holds, which the operator limit already bounds, so that check could
never fail; cargo-mutants found it untested.

Test the_forms_followed_through_resources_are_read_one_operator_at_a_time
(tests/operator_memory.rs, a binary of its own) measures how much the
process's peak grows over 32 MiB of `n` operators through each check, on
Linux, and fails with either read reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-06 17:24:56 +01:00
Grzegorz MartensandClaude Opus 5.5 6eabed25ca M8: the forms redaction follows through resources spend the page's allowance
The walk of the forms that content draws through its resources
(`resource_ops`) decoded each one outside the page's decode allowance:
up to 1 GiB a form, read again by each cleanup that walks them, on every
page. The interpreter skips a form outside the marks, so for such a form
this walk is the only read.

`resource_ops` now charges the bytes it decodes to the `DecodeBudget` it
is given. The content walk hands back what it left of the page's
allowance (`Scope::into_budget`), and the cleanups after it spend that:
the check of what the page still draws (`prune`), the properties and the
Type 3 fonts, in Apply Redactions and in Remove Hidden Information. A
rewritten form's cleanups spend the walk's allowance as they go. The
unlinking pass's check of the properties, which walks every page that
shares a resource dictionary, gives each page's content an allowance of
its own.

Only bytes are charged, not form entries: each cleanup reads the same
forms again, so a page drawing many small forms would run out of entries
having decoded little.

Tests, each failing with the charge reverted, and the last three with
their own part of it reverted (the hand-back on either page, the
rewritten form's cleanups):
- what_content_draws_through_its_resources_is_read_within_its_allowance
  (a 2,000-byte form read with 2,000 bytes left, refused with 1,999);
- the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance;
- the_cleanups_of_a_rewritten_form_spend_the_pages_allowance;
- removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance.
In each of the last three a form without resources of its own is read
three times: refused at 100 MiB, more than the page's 256 MiB, and not
at 50 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-06 15:51:12 +01:00
Grzegorz MartensandClaude Opus 5.5 f227d63ab1 M8: redaction reads the forms and glyphs it follows with /DecodeParms by reference
To tell which resources content still draws, redaction reads the forms
it follows, and the Type 3 glyph programs, with `Stream::decoded`, which
reads `/DecodeParms` only when direct. Behind a predictor whose
parameters are an indirect object, a form read as the predictor's tag
bytes and row differences instead of its operators. So:

- the check of what a page still draws (`resource_ops`), the unlinking
  check (`content_draws`) and the unlinking from surviving forms'
  resources (`unlink_from_forms`) didn't see such a form draw an image
  again, and the image went from resources it is still drawn through;
- the Type 3 cleanup didn't see such a form, drawn or not, show a glyph,
  and dropped the glyph's program;
- it refused the redaction over a glyph program behind such a predictor,
  which it couldn't parse.

All six reads now go through `Document::with_direct_parms`. Each has a
test, the form or glyph behind a predictor whose parameters are given by
reference, that fails with that read reverted:
- an_image_a_form_with_parameters_by_reference_draws_again_is_kept
  (`resource_ops` and `content_draws`);
- an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept;
- a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept;
- a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept;
- an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-06 15:50:40 +01:00
Grzegorz MartensandClaude Opus 5.5 ea51451efc M8: tests pin which forms redaction follows and where its walk stops
A form with resources of its own draws through those, so neither check
of what is still drawn may follow it: the page's (prune) and the
unlinking one at the end of a redaction (content_draws). Nothing pinned
either. A test now has a form on another page naming a different image
the same way, and the redacted image still leaves the dictionary both
pages inherit, which pins the unlinking check. Another calls the page's
check directly with such a form, since in a whole redaction the
unlinking check would drop the name anyway.

The walk of what content draws through its resources refuses content
of more than 2,000,000 operators, counting those of the forms it
follows. A test now pins that boundary for both counts.

Remove Hidden Information keeps a name that a form without resources,
left as it was, still draws, as Apply Redactions does. A test now pins
that with hidden text, which a form loses or keeps depending on where
it is drawn.

The doc comment of prune now says when it refuses, with examples of
what Stream::decoded refuses, and that corrupt data in such a form is
read up to the damage, which may be nothing, not refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-06 15:50:40 +01:00
Grzegorz MartensandClaude Opus 5.5 8c06f7a3eb M8: a form whose /Resources is null draws through its caller's when redaction unlinks
A form whose /Resources is null, or a reference to an object that isn't
there, has no resources of its own. The interpreter, the walk the
cleanup uses and the Type 3 cleanup all take it so. `content_draws` took
it as having its own. It decides at the end of a redaction whether a
removed image is still drawn through a page's or a surviving form's
resources, so it dropped the name the page had just kept for such a
form, and the form drew nothing there.

The page's check refuses a page whose content it can't read in full.
That includes bytes the parser can't read in a stream the redaction
leaves as it was, for Remove Hidden Information as for Apply
Redactions. Tests now pin that, and the error no longer calls the
content "property resources".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-06 15:50:40 +01:00
Grzegorz MartensandClaude Opus 5.5 7b1301b34f M8: redaction keeps an image a form without resources still draws
When a redaction removes or replaces an image or form under a mark, it
takes the name out of the page's resources unless the page still draws
it. It looked only at the page's own `Do` operators. A form with no
/Resources of its own draws through the page's, so when such a form
outside the mark drew the same image, the page lost the name and the
form drew nothing there.

The page now keeps a name that a form drawing through its resources
still draws, found as the cleanup of the fonts and properties these
forms use already finds them. As that cleanup does, it refuses the page
when such a form can't be read. On a page with fonts or properties to
clean up that was already the case; on a page with neither, the name
used to go.

A rewritten form had the same fault one level down. It dropped the names
it replaced before `prune_form_resources` ran. That function already
keeps what a form drawing through these resources still draws, so the
rewritten form now leaves the decision to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-06 15:50:40 +01:00
Hamza Naveedandskylar taylor-barrick 97cef90e01 Redaction: a pattern nothing paints with any more leaves the page (#3680)
* 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>
2026-11-06 10:01:42 +01:00
Dylan M. Taylor c251b3a0a7 fix(redact): read inherited forms once for all names in resource cleanup
unlink_doomed and unlink_from_forms asked, name by name, whether the
remaining content still draws an XObject, and each question parsed the
content and decoded every form without /Resources of its own again. A
holder with many aliases of one removed image, drawn by a large
inheriting form, multiplied that decoding by the number of aliases.

names_drawn now works out once per content which names it draws,
directly or through such forms (each read once), and whether any of it
could not be read; every alias is answered from that set. A name not in
the set while something could not be read still fails closed.
2026-11-03 15:28:23 +00:00
Dylan M. Taylor 33e23da8a9 Refactor redact for strict Clippy checks 2026-10-27 23:12:51 +00:00
aiongg cc9b5057bf refactor(redact): split interp.rs into interp/
New modules: interp/layers.rs (hidden-layer handling), interp/prune.rs (resource pruning and unlinking).
2026-10-26 22:18:50 +00:00