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:
parent
c2080c4b34
commit
ad532f3c6d
1 changed files with 32 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue