fix(assets): bound every link of the filter chain, and cover the backstop

The two findings of the 18.09 PM checkpoint of `0f308c1`. Red tests landed
first in `3b587ea`; this is what turns them green.

BLOCKER -- `_check_inflated` read `filters[0]`, measured that one link and
returned, which is not a bound: a PDF decodes a stream through a LIST of
filters. Measured in paired subprocesses from two pinned trees, idle machine:

  [/FlateDecode]        400 MB  408 516 B   59 232 256 ->    62 017 536 B
  [/FlateDecode x2]     400 MB    1 636 B  886 554 624 ->    52 367 360 B
  [/FlateDecode x3]     400 MB    1 070 B  889 393 152 ->    61 390 848 B
  [/FlateDecode x2]     1,2 GB    2 927 B  2 567 204 864 ->  60 403 712 B

542 000x the file at two links, and the picture WAS refused at the end -- by
`check_payload` after `get_data()`, once the memory was spent. The single-link
row is the control and does not move. It also left the 16 corpus objects behind
an `[/ASCII85Decode /FlateDecode]` chain unmeasured, since `filters[0]` is not
`FlateDecode` there.

`_check_stream_cost` walks every link. THREE CLASSES and no fourth
(`extract.bounded_pdf_filters`, pinned by a test): `FlateDecode` MEASURED, a
link with another expanding link behind it inflated under the same bound and
handed on; `ASCII85Decode`/`ASCIIHexDecode` bounded by their own input because
they SHRINK; `DCTDecode`/`JPXDecode`/`JBIG2Decode` PASS THROUGH. Everything
else -- `LZWDecode`, `RunLengthDecode`, `CCITTFaxDecode`, `/Crypt`, anything
written later -- is refused UNREAD with a new code `asset_pdf_unbounded`,
decided before the FIRST link is decoded so a document cannot make the run pay
for the links in front of the one we cannot bound. An encrypted stream is
deciphered and then measured, where `stream.decipher is not None` used to
return unmeasured; 0 of 5 142 objects here are in an encrypted document, which
is why nothing caught it.

NOT ONE PICTURE CHANGES HANDS, AND IT IS MEASURED BY NAME. Every PDF on this
machine -- 78 documents, K2 in both trinn1 and trinn2, the shipped fixtures and
R761 -- run through `_pdf_images` page by page from both pinned trees:

  images carried          9 356 -> 9 356
  documents losing one              0 of 78
  documents gaining one             0 of 78
  asset_pdf_unsupported     322 -> 314
  asset_pdf_unbounded         0 -> 8

The 8 are the 4 `CCITTFaxDecode` stencil masks (`/ImageMask true`,
`/BitsPerComponent 1`), counted twice because trinn1 and trinn2 hold the same
document. They were refused before and are refused now, one step earlier and
under a code that says why.

MAJOR -- `check_payload(len(data))` after `get_data()` is the counted refusal
four documentation surfaces point at, and deleting exactly that line passed all
2 132 tests. It is reachable through a stream pdfminer has ALREADY decoded
(`decode()` sets `rawdata` to `None`), which is now the ONLY case outside the
bound and has a test.

Eight mutations, one line each, every one DEAD, with the unmutated tree run
first as the control: first-link-only, loop dropped, inequality reversed,
encrypted skipped, backstop deleted, unknown filter passed through,
intermediate link not carried forward, whole check removed.

`tools/okf_accounting_gate.py` gains one line, the new code in
`REJECTION_CODES` -- what a rejection code requires and nothing more. Gate
unchanged: exit 1, GATE RED rows 2, 3, 6. Version stays 0.10.1, untagged.

Suite after `git add` against a clean tree: `uv run pytest -q` ->
2152 passed, 1 skipped (226 s). ruff, ruff format --check, mypy --strict clean.

Report: docs/2026-09-18-filterkjeden-og-backstoppen.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 15:34:07 +02:00
commit 0c3c4904ee
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
11 changed files with 564 additions and 38 deletions

View file

@ -118,10 +118,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
CARRIED, with no rejection, at 892 MB of peak RSS. With the bound: 0
carried, `asset_too_large`, 54 MB — and 62 MB where the old path cost
2 436 MB, so the cost no longer scales with the bomb.
- The measurement runs where `FlateDecode` is the first filter and the
document is not encrypted. Any other chain is caught by a check on
`len(data)` AFTER `get_data()`, which is a counted refusal and not a
bounded one. The difference is stated in the code rather than implied.
- **Every link of the chain is measured, not only the first.** A PDF
decodes a stream through a list of filters, and the first fix read
`filters[0]`: `/Filter [/FlateDecode /FlateDecode]` therefore cost
886 554 624 bytes of peak RSS from 1 636 bytes of file, and three links
cost the same from 1 070 — about 542 000x, with the picture still refused
at the end, after the memory had been spent. It also left the 16 corpus
image objects behind an `[/ASCII85Decode /FlateDecode]` chain unmeasured,
because `filters[0]` is not `FlateDecode` there. Bounded, measured idle in paired subprocesses: 52 367 360 bytes at two
links, 61 390 848 at three, and 60 403 712 where the old path cost
2 567 204 864.
- **A filter whose output cannot be measured before it is produced is
refused unread**, with its own code `asset_pdf_unbounded`. `FlateDecode`
is measured; `ASCII85Decode` and `ASCIIHexDecode` are bounded by their own
input because they shrink; `DCTDecode`, `JPXDecode` and `JBIG2Decode` pass
through unchanged. `LZWDecode`, `RunLengthDecode`, `CCITTFaxDecode`,
`/Crypt` and anything unknown are refused. Over the 5 142 image objects of
the 78 PDFs measured, the refused class is 4 `CCITTFaxDecode` objects,
which are 1-bit stencil masks and were already refused one step later by
the encoder. Measured by name over the same 78 documents, carried images
are 9 356 before and 9 356 after: no document loses a picture, and the 8
objects that move code (4 masks, counted twice) were refused on both sides.
- **An encrypted stream is deciphered and then measured.** Deciphering does
not change a stream's length, so this does what pdfminer's own `decode()`
does; before, `stream.decipher is not None` returned without measuring,
which made "the document declares encryption" a way past the bound.
- What remains outside the bound is a stream something else has already
decoded, where the memory is spent before this package is asked. That one
is caught by a check on `len(data)` AFTER `get_data()`, which is a counted
refusal and not a bounded one. The difference is stated in the code rather
than implied — and, since this change, held by a test: deleting exactly
that line passed all 2 132 tests before it.
- **A declared size that is not a size is refused with its own code
(0.10.1).** `/Width -1 /Height 40000000000` multiplies to a NEGATIVE pixel
count, under which every bound read as satisfied: the check returned