From b9714fda4519eff7a599de331f6d921629ddfaa1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:37:42 +1000 Subject: [PATCH] Artwork GIFs: parse the blocks and pass on the first frame only, bounded The screen and first frame must be at most 4096x4096 and the frame inside the screen; anything malformed or truncated is rejected. Only a minimal single-frame GIF (header, screen, colour table, graphic control, first image) reaches the Frame's Chromium, however many frames the source has. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_frame_android_library.py | 30 +++++++++++++-- ui/frame_artwork.py | 59 +++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index 1caa7c7..6d235b5 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -222,13 +222,35 @@ class ArtworkTests(unittest.TestCase): data = (FIXTURES / 'icon.jpg').read_bytes() self.assertEqual(art.image_type(data), 'jpg') self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI - gif = b'GIF89a' + struct.pack('IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \ diff --git a/ui/frame_artwork.py b/ui/frame_artwork.py index d1fcb1e..ce1db52 100644 --- a/ui/frame_artwork.py +++ b/ui/frame_artwork.py @@ -28,18 +28,62 @@ def png_size(data): return w, h +def _gif_blocks(data, pos): + """Position after a run of GIF data sub-blocks and its terminator.""" + while True: + if pos >= len(data): + raise ValueError('truncated GIF') + size = data[pos] + pos += 1 + size + if not size: + return pos + + +def gif_frame(data): + """A GIF's first frame as a minimal single-frame GIF, so no frame count or oversized frame + reaches the Frame's Chromium. Raises ValueError for anything malformed or out of bounds.""" + if data[:6] not in (b'GIF87a', b'GIF89a') or len(data) < 13: + raise ValueError('invalid GIF') + sw, sh, flags = struct.unpack_from(' MAX_PIXELS or max(sw, sh) > 4096: + raise ValueError('artwork GIF has unsupported dimensions') + pos = 13 + (3 << ((flags & 7) + 1) if flags & 0x80 else 0) + head, control = data[:pos], b'' + if len(head) != pos: + raise ValueError('truncated GIF') + while True: + if pos >= len(data): + raise ValueError('truncated GIF') + if data[pos] == 0x21 and pos + 1 < len(data): # extension: keep the frame's graphic control + end = _gif_blocks(data, pos + 2) + if data[pos + 1] == 0xf9: + control = data[pos:end] + pos = end + elif data[pos] == 0x2c and pos + 10 <= len(data): # the first image + x, y, w, h, local = struct.unpack_from(' sw or y + h > sh: + raise ValueError('artwork GIF frame exceeds its screen') + if not flags & 0x80 and not local & 0x80: + raise ValueError('GIF has no colour table') + start = pos + pos += 10 + (3 << ((local & 7) + 1) if local & 0x80 else 0) + if pos >= len(data) or not 2 <= data[pos] <= 8: # the LZW minimum code size + raise ValueError('invalid GIF image data') + end = _gif_blocks(data, pos + 1) + return head + control + data[start:end] + b'\x3b' + else: + raise ValueError('invalid GIF block') + + def image_type(data): if not isinstance(data, bytes) or len(data) > MAX_IMAGE: raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB') if data.startswith(PNG): png_size(data) return 'png' - if data[:6] in (b'GIF87a', b'GIF89a') and len(data) >= 10: - # Gameplay GIFs are common source screenshots; the Frame's Chromium draws their first frame. - w, h = struct.unpack_from('