fix(qemu): validate PPM headers and raster length (#18)

Parse the P6 header explicitly, keep every raster byte, and reject
non-positive dimensions, maxval other than 255, and mismatched raster
lengths. Add scripts/test-qemu-ppm.py.
This commit is contained in:
LiangKam authored and GitHub committed 2026-10-22 01:11:15 +08:00
1 parent ffa88fe34e
commit 61e9f1312a
2 files changed
+74 -8

No files matched your search

+13 -8
View File
@@ -78,14 +78,19 @@ class Qmp:
def ppm_to_png(ppm: Path, png: Path) -> None:
data = ppm.read_bytes()
parts = data.split(maxsplit=4)
if parts[0] != b"P6":
raise ValueError("not a binary PPM")
width, height = int(parts[1]), int(parts[2])
# The pixels are the last bytes of the file. Splitting them off the header
# would drop leading bytes that happen to be ASCII whitespace (0x09-0x0D,
# 0x20), and every channel after them would shift.
pixels = data[len(data) - width * height * 3:]
# Comments and whitespace separate header tokens, but raster bytes must
# never be stripped: every byte value is valid in an RGB channel.
separator = rb"(?:[ \t\r\n\v\f]|#[^\r\n]*(?:\r\n|[\r\n]))+"
header = re.match(rb"P6" + separator + rb"([0-9]+)" + separator
+ rb"([0-9]+)" + separator + rb"([0-9]+)[ \t\r\n\v\f]", data)
if header is None:
raise ValueError("invalid binary PPM header")
width, height, maxval = map(int, header.groups())
if width < 1 or height < 1 or maxval != 255:
raise ValueError("PPM requires positive dimensions and maxval 255")
pixels = data[header.end():]
if len(pixels) != width * height * 3:
raise ValueError("PPM raster length does not match dimensions")
raw = b"".join(b"\x00" + pixels[y * width * 3:(y + 1) * width * 3] for y in range(height))
def chunk(tag, body):
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Check that ppm_to_png keeps every raster byte and rejects malformed PPM files."""
import importlib.util
from pathlib import Path
import struct
import sys
import tempfile
import zlib
source = Path(__file__).resolve().parents[1] / 'package' / 'Scripts' / 'qemu-run.py'
spec = importlib.util.spec_from_file_location('qemu_run', source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
valid = [
(b'P6\n1 1\n255\n', b'\x01\x02\x03', 1, 1),
(b'P6\n2 1\n255\n', b'\x09\x0a\x0b\x0c\x0d\x20', 2, 1),
(b'P6\n1 1\n255\n', b'#\x00\xff', 1, 1),
(b'P6\n# size\n1 1\n255\n', b'abc', 1, 1),
(b'P6 # magic\n1 1\n255\n', b'abc', 1, 1),
(b'P6\n1 # width\n1\n255\n', b'abc', 1, 1),
(b'P6\n1 2\n255\n', b'abcdef', 1, 2),
]
invalid = [b'', b'P3\n1 1\n255\nabc', b'P6\n0 1\n255\n',
b'P6\n-1 1\n255\nabc', b'P6\n1 1\n65535\nabcdef',
b'P6\n1 1\n255\nab', b'P6\n1 1\n255\nabcd']
passed = rejected = 0
with tempfile.TemporaryDirectory() as work:
ppm, png = Path(work) / 'in.ppm', Path(work) / 'out.png'
for index, (header, pixels, width, height) in enumerate(valid):
ppm.write_bytes(header + pixels)
try:
module.ppm_to_png(ppm, png)
data = png.read_bytes()
assert data[:8] == b'\x89PNG\r\n\x1a\n'
offset, compressed = 8, b''
while offset < len(data):
size = struct.unpack_from('>I', data, offset)[0]
tag = data[offset + 4:offset + 8]
body = data[offset + 8:offset + 8 + size]
crc = struct.unpack_from('>I', data, offset + 8 + size)[0]
assert crc == zlib.crc32(tag + body) & 0xffffffff
if tag == b'IHDR':
assert struct.unpack('>IIBBBBB', body) == (width, height, 8, 2, 0, 0, 0)
if tag == b'IDAT':
compressed += body
offset += size + 12
expected = b''.join(b'\0' + pixels[y*width*3:(y+1)*width*3] for y in range(height))
assert zlib.decompress(compressed) == expected
passed += 1
except Exception as error:
print('valid case', index + 1, 'FAIL', type(error).__name__)
for index, data in enumerate(invalid):
ppm.write_bytes(data)
try:
module.ppm_to_png(ppm, png)
except ValueError:
rejected += 1
except Exception as error:
print('invalid case', index + 1, 'wrong error', type(error).__name__)
print(f'valid={passed}/{len(valid)} invalid_rejected={rejected}/{len(invalid)}')
sys.exit(0 if passed == len(valid) and rejected == len(invalid) else 1)