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:
parent
3b587ea567
commit
0c3c4904ee
11 changed files with 564 additions and 38 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue