diff --git a/package/Scripts/qemu-run.py b/package/Scripts/qemu-run.py index 4d82628..54eb81f 100755 --- a/package/Scripts/qemu-run.py +++ b/package/Scripts/qemu-run.py @@ -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): diff --git a/scripts/test-qemu-ppm.py b/scripts/test-qemu-ppm.py new file mode 100755 index 0000000..d2be30f --- /dev/null +++ b/scripts/test-qemu-ppm.py @@ -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)