fix(assets): bound what the run pays, not what the document claims (0.10.1)

A second independent review read `230d1cb` -- the commit that closed the
`v0.10.0` review's two MAJOR findings -- and found one of them open. The
bound read `/Width` and `/Height`, which an untrusted document writes, while
`get_data()` pays for the stream beside them; `/Length` is the COMPRESSED
length and the two numbers are independent.

Re-measured here on `ed8d9d7` before anything changed, in its own
interpreter: a 408 516-byte PDF declaring 1x1 and carrying 400 MB of deflated
zeros was CARRIED, no rejection, 891 904 000 B peak RSS. After: 0 carried,
`asset_too_large`, 57 065 472 B. At 1,2 GB of zeros, 2 436 MB -> 64 569 344 B
-- the cost no longer scales with the bomb. End to end through the CLI with
the shipped defaults: 838 000 640 B and an asset written -> exit 0,
79 650 816 B, `0 carried of 1 found`, no `assets/`.

Three numbers are bounded now, not one: what a container DECLARES, what a
carried FILE measures (`read_image`, so a 49 MP PNG of 47 705 bytes is not
passed on to a consumer), and what a PDF stream DECOMPRESSES to
(`assets.inflated_size`, a chunk at a time, output discarded, before
`get_data()`). The limit is stated rather than implied: the stream
measurement runs where `FlateDecode` is the first filter and the document is
not encrypted; every other chain is a check on the decoded length AFTER the
decode, a counted refusal and not a bounded one.

A non-positive declared dimension is `asset_size_invalid`, its own code,
raised before the stream is read. `-1 x 40000000000` is a NEGATIVE pixel
count, under which every `>` bound read as satisfied, so the check returned
silently and the refusal arrived from `encode_png` as
`asset_samples_invalid`. Its own code because a publisher shipping a picture
bigger than this package carries and a dictionary written to be read wrong
are different facts about a document.

Two smaller findings in the line that says what is missing, both introduced
by the first fix: the address was written twice, once bare, and a linkifying
renderer autolinks a bare URL -- written once now, in one code span; and
`label` became a dead parameter, so the figure's caption was dropped, a
regression against 0.10.0. It is written again in the `-- <label>` form a
carried pointer uses.

Version bumped to 0.10.1 across all ten places. Nine were unbound and stale:
four README install lines naming the previous release, two prose lines, the
"current tag" entry, `uv.lock`, and a CHANGELOG whose 0.10.1 content sat
under `[Unreleased]`. Two new packaging tests bind them to `__version__`, and
the README's guard tag to `[tool.uv.sources]`.

Every test was red first. The fate of every image is identical with and
without the new bound on three K2 PDFs carrying 800 images (464/464, 311/311
with the same 12 rejections, 25/25), and the second inflate is below the
noise floor there. 0 shipped artifacts move: no bundle under `examples/`,
`skills/` or `tests/fixtures/` carries an image pointer at all, measured
against a known-positive control.

`asset_too_large` was undocumented in the error registry; both codes are
there now. `tools/okf_accounting_gate.py` gains the new code in its closed
list -- one string, no behaviour change, stated because that file belongs to
another order.

Suite 2141 passed / 1 skipped, ruff + format + mypy --strict clean.
Report: docs/2026-09-18-bildestien-holder-0-10-1.md

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

View file

@ -29,6 +29,8 @@ killed build.
from __future__ import annotations
import re
import subprocess
import sys
import zlib
from pathlib import Path
@ -126,11 +128,27 @@ def test_a_built_bundle_carries_no_foreign_image_link(tmp_path: Path) -> None:
# --- finding 2: a declared size that is too large is refused, not decoded ---
def _bomb(dimension: int, content: bytes | None = None) -> bytes:
def _zeros_stream(total: int) -> bytes:
"""`total` bytes of zeros, deflated WITHOUT ever holding them.
The generator has to stay cheaper than the bomb it builds, or the test
measures its own fixture instead of the code under test.
"""
compressor = zlib.compressobj(9)
chunk = b"\x00" * (1 << 20)
parts = [compressor.compress(chunk) for _ in range(total >> 20)]
parts.append(compressor.flush())
return b"".join(parts)
def _bomb(dimension: int, content: bytes | None = None, payload: bytes | None = None) -> bytes:
"""A tiny PDF declaring one `dimension` x `dimension` grayscale image of
compressed zeros -- the review's own repro, built here. `content` replaces
the page's content stream, for a page that draws an INLINE image instead."""
payload = zlib.compress(b"\x00" * (dimension * dimension), 9)
the page's content stream, for a page that draws an INLINE image instead.
`payload` replaces the image stream, for a document whose DECLARED size and
whose actual stream are two different numbers."""
if payload is None:
payload = zlib.compress(b"\x00" * (dimension * dimension), 9)
if content is None:
content = b"BT /F1 12 Tf 20 100 Td (bomb) Tj ET\nq 100 0 0 100 20 20 cm /Im0 Do Q\n"
objects = [
@ -247,3 +265,198 @@ def test_an_inline_pdf_image_gets_a_stable_name() -> None:
again = [image.name for image in second.images] + [r.name for r in second.rejected]
assert names == again != []
assert not any(part.isdigit() and len(part) > 6 for name in names for part in name.split("-"))
# --- BLOCKER-1 of the 18.09 review: the bound must bind what the run PAYS ----
#
# `check_size` reads `/Width` and `/Height` out of the image dictionary, which
# is a CLAIM by an untrusted document, and the claim and the cost are two
# independent numbers: `/Length` is the COMPRESSED length and nothing in the
# dictionary states what `get_data()` will return. Measured on `230d1cb` by an
# independent review: a 389 626-byte PDF declaring 1x1 and carrying 400 MB of
# deflated zeros was CARRIED, with no rejection, at 834 MB of peak RSS -- and
# 1,2 GB of zeros at 2 436 MB, linear, about 2 100x the file size. The four
# mutations that suite already kills do not separate declared from actual, so
# they were all green while this stood.
#: What a bounded run of the 400 MB bomb may cost, in bytes of peak RSS.
#: Measured 2026-09-18 on this machine, same commit, same fixture: 892 MB
#: without the bound and 54 MB with it, and the bounded figure barely moves
#: when the stream triples (62 MB at 1,2 GB) because what grows is the
#: COMPRESSED input, which was already in memory. The bar sits between the
#: two, far enough above the bounded run that the interpreter's own footprint
#: on another machine cannot reach it.
PEAK_RSS_BOUND = 256 * 1024 * 1024
#: The stream the bomb inflates to. Over `MAX_IMAGE_BYTES` (256 MiB), so it is
#: refused at the real bound rather than at a monkeypatched one.
BOMB_STREAM_BYTES = 400 * 1024 * 1024
_CHILD = """
import resource, sys
sys.path.insert(0, {tests!r})
from test_asset_limits import _bomb, _zeros_stream
from llm_ingestion_okf.extract import extract_document
document = _bomb(1, payload=_zeros_stream({total}))
extracted = extract_document("bomb.pdf", document, assets=True)
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(
len(document),
len(extracted.images),
",".join(rejection.code for rejection in extracted.rejected) or "-",
peak if sys.platform == "darwin" else peak * 1024,
)
"""
def _run_bomb(total: int) -> tuple[int, int, str, int]:
"""The bomb in its own interpreter, so peak RSS is ITS peak and not the
high-water mark of every test that ran before it."""
completed = subprocess.run(
[sys.executable, "-c", _CHILD.format(tests=str(Path(__file__).parent), total=total)],
capture_output=True,
text=True,
check=True,
)
size, carried, codes, peak = completed.stdout.split()
return int(size), int(carried), codes, int(peak)
def test_a_declared_size_of_one_pixel_does_not_licence_an_unbounded_stream() -> None:
"""The review's repro, at the shipped bound: 1x1 declared, 400 MB paid."""
pytest.importorskip("pdfplumber")
size, carried, codes, peak = _run_bomb(BOMB_STREAM_BYTES)
assert size < 2 * 1024 * 1024, "the fixture must stay a small file, or it proves nothing"
assert carried == 0, "a 400 MB stream was carried as a 1x1 picture"
assert codes == "asset_too_large"
assert peak < PEAK_RSS_BOUND, f"peak RSS {peak} bytes for a {size}-byte file"
def test_the_refusal_reads_the_stream_and_not_only_the_declaration() -> None:
"""The mutation the shipped suite could not kill.
An honest 20000x20000 declaration is refused by `check_size` alone, so a
test built on one is green whether or not the actual stream is bounded.
This document declares a size WITHIN the bound, which is the only shape
that separates the two checks.
"""
pytest.importorskip("pdfplumber")
small = zlib.compress(b"\x00" * 64, 9)
within = extract_document("small.pdf", _bomb(8, payload=small), assets=True)
assert [rejection.code for rejection in within.rejected] == [], "the control must be carried"
assert len(within.images) == 1
def test_a_stream_over_the_bound_is_refused_with_a_patched_bound() -> None:
"""The same rule, cheap, so it runs on every machine and every suite."""
pytest.importorskip("pdfplumber")
monkey = pytest.MonkeyPatch()
try:
monkey.setattr(assets, "MAX_IMAGE_BYTES", 4096)
extracted = extract_document(
"bomb.pdf", _bomb(1, payload=zlib.compress(b"\x00" * 1_000_000, 9)), assets=True
)
finally:
monkey.undo()
assert extracted.images == ()
assert [rejection.code for rejection in extracted.rejected] == ["asset_too_large"]
# --- MAJOR of the 18.09 review: a non-positive declaration is not a size -----
def test_a_non_positive_declared_size_is_refused_before_the_stream_is_read() -> None:
"""`-1 * 40_000_000_000` is NEGATIVE, so `pixels > MAX_IMAGE_PIXELS` was
false and `check_size` returned silently; 400 MB was then decompressed and
the refusal came from `encode_png` with `asset_samples_invalid` -- a code
about the sample buffer for a defect in the declaration.
The stream here is CORRUPT, so the order is observable: reading first gives
`asset_pdf_unsupported`, reading the declaration first gives the new code.
"""
pytest.importorskip("pdfplumber")
document = _bomb(4, payload=b"\xff" * 512).replace(
b"/Width 4 /Height 4", b"/Width -1 /Height 40000000000"
)
assert b"/Width -1" in document
extracted = extract_document("negative.pdf", document, assets=True)
assert extracted.images == ()
assert [rejection.code for rejection in extracted.rejected] == ["asset_size_invalid"]
def test_check_size_refuses_every_non_positive_pair_and_keeps_unknown_unknown() -> None:
for width, height in ((-1, 40_000_000_000), (0, 10), (10, 0), (-2, -2)):
with pytest.raises(ExtractionError) as excinfo:
assets.check_size(width, height, name="n")
assert excinfo.value.code == "asset_size_invalid"
# A size the container never declared is UNKNOWN, not invalid: there is no
# number to bound and inventing one would refuse a legitimate picture.
assets.check_size(None, None, name="n")
assets.check_size(None, 10, name="n")
# --- MINOR-3: the bound holds for a file carried verbatim, too ---------------
def _png_header(width: int, height: int) -> bytes:
"""A PNG whose IHDR declares `width` x `height` and whose body is a stub.
`read_image` sniffs and reads the header; it never decodes."""
def chunk(kind: bytes, payload: bytes) -> bytes:
return (
len(payload).to_bytes(4, "big")
+ kind
+ payload
+ zlib.crc32(kind + payload).to_bytes(4, "big")
)
ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + bytes([8, 0, 0, 0, 0])
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IEND", b"")
def test_an_image_file_over_the_bound_is_refused_although_it_is_never_decoded() -> None:
"""A 7 000 x 7 000 PNG is 49 MP in 47 705 bytes. This package does not
decode a carried file, so it pays nothing -- but writing it into a bundle
hands the consumer the same bomb with `7000x7000 px` printed beside it,
and the README's first sentence says such an image is refused."""
with pytest.raises(ExtractionError) as excinfo:
assets.read_image(_png_header(7000, 7000), name="big.png")
assert excinfo.value.code == "asset_too_large"
def test_an_image_file_under_the_bound_is_still_read() -> None:
image = assets.read_image(_png_header(4515, 4128), name="drawing.png")
assert (image.width, image.height) == (4515, 4128)
# --- MINOR-1 and MINOR-2 of the 18.09 review --------------------------------
def test_a_remote_address_is_never_written_as_a_bare_url() -> None:
"""`inert` was only half true: the address was written TWICE, once in a
code span and once bare, and a GFM/linkify renderer autolinks the bare
one into `<a href="...">`. Measured with `markdown_it('gfm-like')`."""
line = render_missing(REMOTE, reason="the source is off this machine", href=REMOTE)
assert "](" not in line
assert REMOTE in line
for position in range(len(line)):
if line.startswith(REMOTE, position):
assert line[position - 1] == "`" and line[position + len(REMOTE)] == "`", (
f"a bare occurrence of the address at {position}: {line!r}"
)
def test_the_caption_of_a_remote_reference_is_still_stated() -> None:
"""`label` became a dead parameter in 0.10.1, so the alt text or figure
caption of an image the bundle does not carry was DROPPED -- a regression
against 0.10.0 and against this module's own reason for writing the line:
a reader cannot weigh an absence they were never shown."""
line = render_missing(
"p.gif", reason="the source is off this machine", label="Figur 84-1 Tverrprofil", href=None
)
assert "Figur 84-1 Tverrprofil" in line
with_href = render_missing(
REMOTE, reason="the source is off this machine", label="Figur 84-1 Tverrprofil", href=REMOTE
)
assert "Figur 84-1 Tverrprofil" in with_href