fix(assets): an RLE8 stream that never reaches end-of-bitmap is refused

The four cuts now raise `asset_samples_invalid`, the code the UNCOMPRESSED
path already raises for a body that stops short. No pixel is guessed: the loop
may end at an explicit end-of-bitmap escape and nowhere else, and running out
of bytes -- mid encoded run, mid absolute block, or one opcode before the
terminator -- is a refusal with a row instead of a partly blank PNG.

WHY THE TERMINATOR AND NOT `biSizeImage` OR A COVERAGE COUNT. `biSizeImage` is
a claim by the same untrusted header, and a coverage count would refuse the
delta escape the format defines. Read off the corpus instead: over the 19 real
RLE8 assets of the frozen R761 delivery, 19 of 19 end at an explicit
end-of-bitmap, on 19 of 19 it is the stream's LAST two bytes, and on 19 of 19
`biSizeImage` equals the available bytes. A whole stream that omits the
terminator is refused alongside a cut one, because from a reader's side they
are the same bytes and the measurement says no real writer here produces the
first.

An absolute run shorter than its own declared count also stops the loop rather
than painting what arrived, so the refusal names the cut and not the frame.

KNOWN-POSITIVE, re-measured on the frozen delivery with stdlib on BOTH sides
(an independent BMP reader and an independent `zlib` + filter-reversal PNG
decoder, no Pillow anywhere): 19 of 19 still convert losslessly, 2 366 365
pixels compared, 0 refused, and a one-byte control confirms the comparison can
fail. 92 passed over the three asset test files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 09:21:13 +02:00
commit ad532f3c6d
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q

View file

@ -671,7 +671,7 @@ def _bmp_flat_rows(data: bytes, head: _BmpHeader, *, per_pixel: int, name: str)
return b"".join(rows)
def _bmp_rle8_rows(data: bytes, head: _BmpHeader) -> bytes:
def _bmp_rle8_rows(data: bytes, head: _BmpHeader, *, name: str) -> bytes:
"""The RLE8 opcode stream painted into a frame of the DECLARED size.
Five opcodes, and a decoder implementing only the first is wrong on real
@ -685,6 +685,25 @@ def _bmp_rle8_rows(data: bytes, head: _BmpHeader) -> bytes:
and each run is written as one CLIPPED slice. Painting pixel by pixel would
leave the memory bounded and the CPU unbounded: a megabyte of `FF` runs is
a hundred million paint steps against a frame of 32 pixels.
A STREAM THAT NEVER SAYS IT IS FINISHED IS REFUSED, and that is the
difference between a skipped pixel and a missing one. Running out of bytes
leaves the rest of the frame at index 0 -- indistinguishable, in the
output, from a delta that skipped it, which is why every decoder agrees on
the wrong picture: measured 2026-09-19 on a real 352x548 R761 asset, a
stream cut to 90 % was carried with 13 923 pixels wrong, to 50 % with
95 890, to 10 % with 166 525, with no code and no row. So the loop may end
HERE and nowhere else, and `_bmp_flat_rows` refuses the same shape with the
same code.
The rule is the terminator rather than `biSizeImage`, which is a claim by
the same untrusted header, and rather than a coverage count, which would
refuse the delta the format defines. Read off the corpus: over the 19 real
RLE8 assets of the frozen R761 delivery, 19 of 19 end at an explicit
end-of-bitmap, on 19 of 19 it is the stream's LAST two bytes, and on 19 of
19 `biSizeImage` equals the available bytes -- so requiring it costs
nothing measured here, and a whole stream that omits it is refused
alongside a cut one because from the reader's side they are the same bytes.
"""
width, height = head.width, head.height
rows = [bytearray(width) for _ in range(height)]
@ -692,6 +711,7 @@ def _bmp_rle8_rows(data: bytes, head: _BmpHeader) -> bytes:
end = len(data)
x = 0
y = 0
finished = False
while position + 1 < end:
count = data[position]
value = data[position + 1]
@ -706,6 +726,7 @@ def _bmp_rle8_rows(data: bytes, head: _BmpHeader) -> bytes:
x = 0
y += 1
elif value == 1:
finished = True
break
elif value == 2:
if position + 2 > end:
@ -716,10 +737,19 @@ def _bmp_rle8_rows(data: bytes, head: _BmpHeader) -> bytes:
else:
run = data[position : position + value]
position += value + (value & 1)
if len(run) < value:
break
if 0 <= y < height and x < width:
stop = min(x + len(run), width)
rows[y][x:stop] = run[: stop - x]
x += value
if not finished:
raise ExtractionError(
f"the RLE8 stream in {name!r} ends after {position - head.offbits} of "
f"{len(data) - head.offbits} bytes without an end-of-bitmap escape "
"-- refusing to carry a frame whose remaining pixels were never decoded",
code="asset_samples_invalid",
)
if not head.top_down:
rows.reverse()
return b"".join(bytes(row) for row in rows)
@ -759,7 +789,7 @@ def bmp_to_png(data: bytes, *, name: str) -> bytes:
"does not define",
code="asset_bmp_unsupported",
)
samples = _bmp_rle8_rows(data, head)
samples = _bmp_rle8_rows(data, head, name=name)
else:
samples = _bmp_flat_rows(data, head, per_pixel=1, name=name)
limit = len(palette) // 3