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:
parent
ed8d9d709f
commit
0f308c1f56
15 changed files with 842 additions and 81 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -728,6 +728,38 @@ def test_asset_samples_invalid() -> None:
|
|||
assert excinfo.value.code == "asset_samples_invalid"
|
||||
|
||||
|
||||
def test_asset_too_large() -> None:
|
||||
"""One code, three ways to be over the bound: a DECLARED size, a file, and
|
||||
a stream that decompresses past it. The third arrived in 0.10.1 after an
|
||||
independent review measured a 408 516-byte PDF declaring 1x1 being carried
|
||||
at 892 MB of peak RSS."""
|
||||
import zlib
|
||||
|
||||
from llm_ingestion_okf import assets
|
||||
|
||||
with pytest.raises(ExtractionError) as excinfo:
|
||||
assets.check_size(20_000, 20_000, name="declared")
|
||||
assert excinfo.value.code == "asset_too_large"
|
||||
|
||||
with pytest.raises(ExtractionError) as excinfo:
|
||||
assets.check_payload(assets.MAX_IMAGE_BYTES + 1, name="file")
|
||||
assert excinfo.value.code == "asset_too_large"
|
||||
|
||||
with pytest.raises(ExtractionError) as excinfo:
|
||||
assets.inflated_size(zlib.compress(b"\x00" * 4096, 9), name="stream", limit=16)
|
||||
assert excinfo.value.code == "asset_too_large"
|
||||
|
||||
|
||||
def test_asset_size_invalid() -> None:
|
||||
"""A declared size that is not a size. Its own code because it says
|
||||
something different about the document than `asset_too_large` does."""
|
||||
from llm_ingestion_okf import assets
|
||||
|
||||
with pytest.raises(ExtractionError) as excinfo:
|
||||
assets.check_size(-1, 40_000_000_000, name="negative")
|
||||
assert excinfo.value.code == "asset_size_invalid"
|
||||
|
||||
|
||||
def test_asset_remote() -> None:
|
||||
from llm_ingestion_okf.extract import extract_document
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ py.typed marker mypy degrades every imported symbol to Any.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -106,3 +107,71 @@ def test_operational_tooling_stays_out_of_the_wheel() -> None:
|
|||
packages = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"]
|
||||
assert packages == ["src/llm_ingestion_okf"]
|
||||
assert (PROJECT_ROOT / "tools" / "okf_watch.py").is_file(), "the test must have a subject"
|
||||
|
||||
|
||||
def test_every_place_that_publishes_a_version_names_the_packaged_one() -> None:
|
||||
"""A tag is a promise about bytes, and nine places here repeat it.
|
||||
|
||||
`test_the_declared_version_agrees_with_the_packaged_one` binds two of
|
||||
them. An independent review of 0.10.1 found the other seven unbound and
|
||||
all of them stale: four README install lines telling a consumer to install
|
||||
`@v0.10.0`, two prose lines about what that tag declares, the "current tag"
|
||||
entry, and a CHANGELOG whose 0.10.1 content sat under `[Unreleased]`. A
|
||||
`v0.10.1` tag cut from that tree would have shipped a package reporting
|
||||
0.10.0 and a README installing the release before it -- and the suite was
|
||||
green, because nothing looked.
|
||||
|
||||
The guard tag is bound the same way and for the same reason: the README
|
||||
tells a plain-pip user to install a specific guard tag first, and that
|
||||
instruction is wrong the moment `[tool.uv.sources]` moves without it.
|
||||
"""
|
||||
tomllib = pytest.importorskip("tomllib")
|
||||
pyproject = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
version = llm_ingestion_okf.__version__
|
||||
guard = pyproject["tool"]["uv"]["sources"]["llm-ingestion-guard"]["tag"]
|
||||
readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
install = re.findall(r"llm-ingestion-okf\.git@(v[0-9][^\"\s]*)", readme)
|
||||
assert install, "the test must have a subject"
|
||||
assert set(install) == {f"v{version}"}, f"install lines name {sorted(set(install))}"
|
||||
|
||||
guard_lines = re.findall(r"llm-ingestion-pipeline-security\.git@(v[0-9][^\"\s]*)", readme)
|
||||
assert guard_lines, "the test must have a subject"
|
||||
assert set(guard_lines) == {guard}, f"guard install lines name {sorted(set(guard_lines))}"
|
||||
|
||||
current = re.search(r"^- `(v[^`]+)` — the current tag", readme, re.MULTILINE)
|
||||
assert current is not None, "the test must have a subject"
|
||||
assert current.group(1) == f"v{version}"
|
||||
|
||||
# The prose between "## Install in detail" and the history list explains
|
||||
# what THIS tag declares and which guard tag it is paired to. A stale
|
||||
# number there is an instruction that fails, not a historical note.
|
||||
detail = readme.split("## Install in detail", 1)[1].split("### Earlier tags, as history", 1)[0]
|
||||
named = set(re.findall(r"`(v\d+\.\d+\.\d+[^`]*)`", detail))
|
||||
assert named, "the test must have a subject"
|
||||
assert named <= {f"v{version}", guard}, f"stale tags in the install prose: {sorted(named)}"
|
||||
|
||||
# The tenth place, which uv rewrites on its own and which is therefore the
|
||||
# easiest of all to commit stale.
|
||||
lock = (PROJECT_ROOT / "uv.lock").read_text(encoding="utf-8")
|
||||
locked = re.search(r'name = "llm-ingestion-okf"\nversion = "([^"]+)"', lock)
|
||||
assert locked is not None, "the test must have a subject"
|
||||
assert locked.group(1) == version
|
||||
|
||||
|
||||
def test_the_changelog_heads_with_the_packaged_version() -> None:
|
||||
"""The release notes for the version being shipped are not `[Unreleased]`.
|
||||
|
||||
`[Unreleased]` is the right place for work in flight and the wrong place
|
||||
for the content of a tag someone is about to cut: a reader arriving at
|
||||
`v0.10.1` would find its own entry under a heading saying it had not been
|
||||
released. Found by an independent review of 0.10.1.
|
||||
"""
|
||||
changelog = (PROJECT_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
heading = re.search(r"^## \[([^\]]+)\](?: — (\d{4}-\d{2}-\d{2}))?$", changelog, re.MULTILINE)
|
||||
assert heading is not None, "the test must have a subject"
|
||||
assert heading.group(1) == llm_ingestion_okf.__version__, (
|
||||
f"the changelog heads with [{heading.group(1)}], the package is "
|
||||
f"{llm_ingestion_okf.__version__}"
|
||||
)
|
||||
assert heading.group(2), "a released section carries its date"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue