mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
a045f5479f
commit
b9714fda45
2 files changed
+78
-11
No files matched your search
@@ -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('<HH', 640, 360) + b'\0' * 20 # gameplay GIFs: first frame drawn
|
||||
self.assertEqual(art.image_type(gif), 'gif')
|
||||
with self.assertRaises(ValueError):
|
||||
art.image_type(b'GIF89a' + struct.pack('<HH', 0, 360) + b'\0' * 20)
|
||||
with self.assertRaises(ValueError):
|
||||
art.image_type(data[:30])
|
||||
|
||||
FRAME = b'\x21\xf9\x04\x01\x00\x00\x00\x00' + b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x02\x02\x44\x01\x00'
|
||||
|
||||
def gif(self, frames=1, screen=(1, 1), frame=None):
|
||||
head = b'GIF89a' + struct.pack('<HHBBB', *screen, 0x80, 0, 0) + b'\xff\xff\xff\x00\x00\x00'
|
||||
return head + (frame or self.FRAME) * frames + b'\x3b'
|
||||
|
||||
def test_gif_first_frame_is_bounded_and_re_emitted(self):
|
||||
one = self.gif()
|
||||
self.assertEqual(art.image_type(one), 'gif')
|
||||
self.assertEqual(art.gif_frame(one), one) # already minimal: unchanged
|
||||
self.assertEqual(art.fetch(self.gif(frames=3)), ('gif', one)) # animation: first frame only
|
||||
start = time.monotonic()
|
||||
self.assertEqual(art.gif_frame(self.gif(frames=500000)), one) # ~10 MB of frames, never parsed
|
||||
self.assertLess(time.monotonic() - start, 1)
|
||||
big = b'\x2c' + struct.pack('<HHHHB', 0, 0, 8192, 8192, 0) + b'\x02\x02\x44\x01\x00'
|
||||
for bomb in (self.gif(frame=big), self.gif(screen=(8192, 8192), frame=big), self.gif(screen=(5000, 10)),
|
||||
self.gif(frame=b'\x2c' + struct.pack('<HHHHB', 1, 0, 1, 1, 0) + b'\x02\x02\x44\x01\x00'),
|
||||
b'GIF89a' + struct.pack('<HHBBB', 1, 1, 0, 0, 0) + self.FRAME + b'\x3b', # no colour table
|
||||
self.gif(frame=b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x0c\x02\x44\x01\x00'),
|
||||
self.gif(frame=b'\x99')):
|
||||
with self.subTest(bomb=bomb[:40]), self.assertRaises(ValueError):
|
||||
art.image_type(bomb)
|
||||
for cut in range(len(one) - 1): # every truncation before the image's last block
|
||||
with self.subTest(cut=cut), self.assertRaises(ValueError):
|
||||
art.gif_frame(one[:cut])
|
||||
|
||||
def test_png_variants_left_to_chromium_and_limits(self):
|
||||
def png(w, h, depth, color, interlace):
|
||||
return art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \
|
||||
|
||||
+52
-7
@@ -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('<HHB', data, 6)
|
||||
if not sw or not sh or sw * sh > 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('<HHHHB', data, pos + 1)
|
||||
if not w or not h or x + w > 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('<HH', data, 6)
|
||||
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
|
||||
return 'gif'
|
||||
raise ValueError('artwork GIF has unsupported dimensions')
|
||||
if data[:6] in (b'GIF87a', b'GIF89a'):
|
||||
gif_frame(data)
|
||||
return 'gif'
|
||||
if data.startswith(b'\xff\xd8'):
|
||||
# Check JPEG SOF dimensions without depending on an image library; trailing padding is fine.
|
||||
pos = 2
|
||||
@@ -66,7 +110,8 @@ def fetch(value, deadline=None):
|
||||
from apk_sources import _images
|
||||
# Public addresses only, at most three redirects, within the overall deadline.
|
||||
value = _images.fetch(value, deadline=deadline, limit=MAX_IMAGE)[0]
|
||||
return image_type(value), value
|
||||
kind = image_type(value)
|
||||
return kind, gif_frame(value) if kind == 'gif' else value
|
||||
|
||||
|
||||
def prepare(label, icon_png=None, artwork=None, budget=90):
|
||||
|
||||
Reference in new issue
Block a user