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
|
|
@ -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
|
||||
|
|
|
|||
200
docs/2026-09-18-filterkjeden-og-backstoppen.md
Normal file
200
docs/2026-09-18-filterkjeden-og-backstoppen.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue