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

@ -36,6 +36,21 @@ not the stream is bounded.
## What changed: three numbers are bounded, and the limit is stated
> **CORRECTION, 2026-09-18 (same day, later) — point 3 below bounded ONE LINK
> of a filter chain, not the chain.** A PM checkpoint of `0f308c1` measured
> `/Filter [/FlateDecode /FlateDecode]`: 1 636 bytes of file, 889 573 376
> bytes of peak RSS, still refused at the end by the backstop after the memory
> was spent. The sentence below that the measurement "runs before
> `get_data()`" is true; the sentence in the section after it, that the
> measurement covers the case where "`FlateDecode` is the first filter", was a
> bound on the first link and was therefore not a bound. The round that closes
> it, with the three classes of filter and the refusal for the ones no chunked
> measurement can reach, is
> [`docs/2026-09-18-filterkjeden-og-backstoppen.md`](2026-09-18-filterkjeden-og-backstoppen.md).
> That round also found the backstop this paragraph leans on had **no test at
> all**: deleting it passed all 2 132 tests.
1. **What the container DECLARES** — unchanged from the first round.
2. **What a carried FILE measures** — new. `read_image` now checks the size it
sniffs out of the header. This package never decodes such a file, so it pays

View file

@ -0,0 +1,200 @@
# The chain, not its first link — and a backstop nothing held
A PM checkpoint of `0f308c1` — the commit that was to make `v0.10.1` true —
read the fix for the deflate bomb and found the bound still reachable, through
a shape the fix had not considered: a PDF decodes a stream through a **list**
of filters, and the fix measured `filters[0]`.
This report records what was measured, what changed, and what the new rule
costs on real documents. The two rounds it follows are
[`docs/2026-09-17-bildestien-0-10-1.md`](2026-09-17-bildestien-0-10-1.md) and
[`docs/2026-09-18-bildestien-holder-0-10-1.md`](2026-09-18-bildestien-holder-0-10-1.md).
## BLOCKER — the bound measured one link of a chain
`_check_inflated` did this:
```python
filters = stream.get_filters()
if not filters or filters[0][0] not in LITERALS_FLATE_DECODE:
return
inflated_size(raw, name=name)
```
Two holes, and the second was invisible because the first looked like the
whole rule.
1. **A chain of two `FlateDecode` links passes the check.** The first link of
`/Filter [/FlateDecode /FlateDecode]` inflates 795 bytes to 407 685 — well
under the bound — and the check returns. `get_data()` then applies **both**
links and produces 400 MB.
2. **A chain whose first link is not `FlateDecode` is not measured at all.**
`[/ASCII85Decode /FlateDecode]` returns on the first line.
Measured on `0f308c1` in its own interpreter (peak RSS is `RUSAGE_SELF` of a
subprocess, not the high-water mark of the test session):
| chain | file | peak RSS | carried |
|---|---:|---:|---|
| `[/FlateDecode]`, 400 MB | 408 516 B | 59 232 256 B | 0, `asset_too_large` |
| `[/FlateDecode /FlateDecode]`, 400 MB | 1 636 B | **886 554 624 B** | 0, `asset_too_large` |
| `[/FlateDecode /FlateDecode /FlateDecode]`, 400 MB | 1 070 B | **889 393 152 B** | 0, `asset_too_large` |
| `[/FlateDecode /FlateDecode]`, 1,2 GB | 2 927 B | **2 567 204 864 B** | 0, `asset_too_large` |
About 543 000x the file size at two links. Note the last column: the picture
**is** refused — by `check_payload` after `get_data()`, which is the counted
refusal, not the bounded one. A test reading only the rejection code is green
on this defect, which is why two of the tests written here assert **which**
check fired, by its message.
The pre-fix figures were measured from a `git archive` of `3b587ea` on
`PYTHONPATH`, not from the editable tree, and the census below prints the
imported module's `__file__` as its own control.
## The chain is not a hypothetical
Every image XObject of the 78 PDFs on this machine, by filter chain
(2026-09-18, 5 142 objects, `get_filters()` as pdfminer resolves it):
| chain | objects |
|---|---:|
| `[/DCTDecode]` | 1 654 |
| `[/FlateDecode]` | 2 236 |
| `[/FlateDecode /DCTDecode]` | 596 |
| `[/FlateDecode /ASCII85Decode]` | 580 |
| (no filter) | 40 |
| `[/ASCII85Decode /FlateDecode]` | 16 |
| `[/JPXDecode]` | 16 |
| `[/CCITTFaxDecode]` | 4 |
1 192 real pictures are reached through a chain, so refusing every chain was
not available. 16 of them sit behind an `ASCII85Decode` and were unmeasured.
## What the rule is now
`_check_stream_cost` walks **every** link, in order, and the filters fall in
three classes (`extract.bounded_pdf_filters`, pinned by a test):
- `FlateDecode` is **measured** — inflated a chunk at a time, output
discarded, refused the moment the running total crosses the bound. A link
with another expanding link behind it is inflated **under the same bound**
and handed on, so what is held is never more than the bound.
- `ASCII85Decode` and `ASCIIHexDecode` **shrink** by construction (five
characters to four bytes, two to one), so their output is bounded by their
input, which is already in memory as part of the file. They are decoded here
so a `FlateDecode` behind one can be measured.
- `DCTDecode`, `JPXDecode` and `JBIG2Decode` are **pass-through** in pdfminer:
it hands the compressed image on for the reader to sniff, and the size does
not change.
Everything else — `LZWDecode`, `RunLengthDecode`, `CCITTFaxDecode`, `/Crypt`,
and any filter written after this — expands by an amount pdfminer will only
reveal by producing the whole output. On this machine that class is **4 of
5 142** objects, all `CCITTFaxDecode`, and all four are 1-bit stencil masks
(`/ImageMask true`, `/BitsPerComponent 1`) that the encoder already refused one
step later, twice over — so no picture anyone holds changes hands. An image behind one is refused
**unread**, with its own code `asset_pdf_unbounded`, and the refusal is
decided **before the first link is decoded**, so a document cannot make this
package pay for the links in front of the one it cannot bound. Refusing an
unknown name rather than passing it through is the same decision
`corpus.resolve_gate` takes for an unknown gate name: a fallback reproduces
the defect with an extra step.
An **encrypted** stream is deciphered and then measured. Deciphering does not
change a stream's length, so this is exactly what pdfminer's own `decode()`
does; before, `stream.decipher is not None` returned unmeasured, which made
"the document declares encryption" a way past the bound. That one is a
guarantee about the code and not a measured gain: **0 of the 5 142** image
objects on this machine sit in an encrypted document, so nobody here has ever
walked that path — which is exactly why nothing caught it.
Bounded, same fixtures, same machine:
| chain | file | peak RSS before | peak RSS after |
|---|---:|---:|---:|
| `[/FlateDecode]`, 400 MB | 408 516 B | 59 232 256 B | 62 017 536 B |
| `[/FlateDecode /FlateDecode]`, 400 MB | 1 636 B | 886 554 624 B | **52 367 360 B** |
| `[/FlateDecode x3]`, 400 MB | 1 070 B | 889 393 152 B | **61 390 848 B** |
| `[/FlateDecode /FlateDecode]`, 1,2 GB | 2 927 B | 2 567 204 864 B | **60 403 712 B** |
Both columns were measured on an otherwise idle machine, in paired
subprocesses, from the two pinned trees. The single-link row is the control:
it was already bounded and does not move. The cost no longer scales with the
bomb — tripling the stream leaves the bounded run where it was, because what
grows is the compressed input, which was already in memory.
An earlier pass of the same measurement, taken while two corpus censuses were
saturating this machine's memory, read 889 573 376 / 888 401 920 /
2 579 718 144 before and 94 748 672 / 91 258 880 / 107 921 408 after. The
unbounded column is the same number either way; the bounded one is not, which
is what a peak-RSS figure taken under load is worth. The figures published
above are the idle ones, and the test's own bar (`PEAK_RSS_BOUND`, 256 MiB)
sits above both.
## MAJOR — the backstop was uncovered
`check_payload(len(data), name=name)` after `get_data()` is the counted
refusal that four documentation surfaces point at. Deleting exactly that line
passed all 2 132 tests on `0f308c1`.
It is reachable: pdfminer's `decode()` sets `rawdata` to `None`, so a stream
something else has already decoded leaves nothing to measure, and the memory
is spent before this package is asked anything. That is now the **only** case
outside the bound, and it has a test. Under the deletion the refusal becomes
`asset_pdf_unsupported` — a code about a sample buffer, for a document that is
simply too large — which is what the test asserts against.
## The cost on real documents
Every PDF on this machine — 78 documents, the K2 reference corpus in both
`trinn1` and `trinn2`, the shipped fixtures and R761 Prosesskoden:2025 — was
run through `_pdf_images` page by page on both sides, and each side's census
was run from a **pinned tree** (`git archive 3b587ea` for before, a copy for
after) with the imported module's own `__file__` printed as the control. The
editable install was not on either path.
| | before | after |
|---|---:|---:|
| documents | 78 | 78 |
| images carried | **9 356** | **9 356** |
| `asset_pdf_unsupported` | 322 | 314 |
| `asset_pdf_unbounded` | 0 | **8** |
| `asset_samples_invalid` | 40 | 40 |
| documents losing an image they carried | — | **0** |
| documents gaining one | — | **0** |
Not one document loses a picture, and the comparison is by NAME rather than by
count, so a swap would show. The eight that move code are the four
`CCITTFaxDecode` stencil masks, 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. No other rejection moves.
## Mutants
Eight mutations, one line each, in the class the order named. The harness runs
the unmutated tree first and asserts it passes, because a harness whose
command is broken reports every mutant dead and has measured nothing.
| mutation | verdict |
|---|---|
| only the first link is measured (the `0.10.1` rule restored) | DEAD |
| the link loop is dropped: only `filters[0]` is looked at | DEAD |
| the running total is compared the other way round | DEAD |
| an encrypted stream is skipped again instead of deciphered | DEAD |
| the backstop after `get_data()` is deleted | DEAD |
| a filter the bound cannot measure is passed through | DEAD |
| the intermediate link is measured but not carried forward | DEAD |
| the whole cost check is removed from the image path | DEAD |
## What this round did not do
- **No tag, no push.** The version stays `0.10.1` and untagged; re-measuring
is the PM's.
- **The accounting gate gained one line**`asset_pdf_unbounded` in
`REJECTION_CODES` — which is what a new rejection code requires and nothing
more.
- **The 43-document reference corpus was not rebuilt.** The image census above
is per document and per image object, which is what this change can move;
whether any concept text moves is `render_missing`'s open question from the
previous round and is unchanged here.