Coverage for .github / scripts / validate_demo_gifs.py: 100.00%
149 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1#!/usr/bin/env python3
2"""Safely validate the tracked demo GIF allowlist.
4GIFs are untrusted binary input even though they cannot contain scripts. This
5validator bounds decoder work, rejects disguised or malformed files, and fully
6decodes every frame with Pillow inside the read-only security CI job.
7"""
9from __future__ import annotations
11import os
12import struct
13import subprocess
14import sys
15import warnings
16from dataclasses import dataclass
17from pathlib import Path
19from PIL import Image, ImageFile
21PROJECT_ROOT = Path(__file__).resolve().parents[2]
22MIB = 1024 * 1024
25@dataclass(frozen=True)
26class GifPolicy:
27 """Maximum accepted resource use for one intentionally tracked GIF."""
29 max_bytes: int
30 max_width: int
31 max_height: int
32 max_frames: int
35# These ceilings leave modest re-recording headroom while keeping decoder work
36# bounded. Any new GIF or intentional increase requires a review of this list.
37GIF_POLICIES = {
38 Path("demo/autopilot-codex.gif"): GifPolicy(2 * MIB, 1024, 700, 800),
39 Path("demo/autopilot-claude-code.gif"): GifPolicy(4 * MIB, 1024, 700, 800),
40 Path("demo/autopilot-opencode.gif"): GifPolicy(2 * MIB, 1024, 700, 800),
41 Path("demo/deploy.gif"): GifPolicy(75 * MIB, 1360, 803, 1000),
42 # Raised from 2 MiB / 150 frames after reviewing the full-feature teardown
43 # re-recording: it deletes FSx, Valkey, Aurora, the vector-store replica and
44 # every chart's resources, so CloudFormation repaints 158 distinct screens
45 # over 76 minutes. Frame count and byte size are set by that repaint cadence,
46 # not by render settings, so neither shrinks with playback speed.
47 Path("demo/destroy.gif"): GifPolicy(4 * MIB, 1024, 744, 200),
48 Path("demo/live_demo.gif"): GifPolicy(8 * MIB, 1024, 744, 250),
49}
50MAX_CANVAS_PIXELS = max(policy.max_width * policy.max_height for policy in GIF_POLICIES.values())
53class ValidationError(ValueError):
54 """A tracked media asset violates the reviewed GIF policy."""
57def _consume_sub_blocks(data: bytes, offset: int, relative_path: Path) -> int:
58 """Return the byte after a GIF data-sub-block sequence."""
59 while True:
60 if offset >= len(data):
61 raise ValidationError(f"{relative_path}: truncated data-sub-block sequence")
62 block_size = data[offset]
63 offset += 1
64 if block_size == 0:
65 return offset
66 offset += block_size
67 if offset > len(data):
68 raise ValidationError(f"{relative_path}: data sub-block exceeds file boundary")
71def _validate_gif_structure(data: bytes, relative_path: Path) -> tuple[int, int, int]:
72 """Parse GIF block boundaries and reject missing trailers or appended data."""
73 if len(data) < 14 or data[:6] not in {b"GIF87a", b"GIF89a"}:
74 raise ValidationError(f"{relative_path}: missing GIF87a/GIF89a signature")
76 width, height = struct.unpack_from("<HH", data, 6)
77 if width <= 0 or height <= 0:
78 raise ValidationError(f"{relative_path}: invalid {width}x{height} canvas")
80 offset = 13 # signature + logical screen descriptor
81 logical_packed = data[10]
82 if logical_packed & 0x80:
83 offset += 3 * (1 << ((logical_packed & 0x07) + 1))
84 if offset > len(data):
85 raise ValidationError(f"{relative_path}: truncated global color table")
87 frame_count = 0
88 while offset < len(data):
89 marker = data[offset]
90 offset += 1
91 if marker == 0x3B: # GIF trailer
92 if offset != len(data):
93 raise ValidationError(
94 f"{relative_path}: {len(data) - offset:,} trailing bytes after GIF trailer"
95 )
96 if frame_count == 0:
97 raise ValidationError(f"{relative_path}: GIF contains no image frames")
98 return width, height, frame_count
100 if marker == 0x21: # extension
101 if offset >= len(data):
102 raise ValidationError(f"{relative_path}: truncated extension label")
103 offset += 1
104 offset = _consume_sub_blocks(data, offset, relative_path)
105 continue
107 if marker != 0x2C: # image descriptor
108 raise ValidationError(f"{relative_path}: invalid GIF block marker 0x{marker:02x}")
109 if offset + 9 > len(data):
110 raise ValidationError(f"{relative_path}: truncated image descriptor")
112 left, top, frame_width, frame_height = struct.unpack_from("<HHHH", data, offset)
113 image_packed = data[offset + 8]
114 offset += 9
115 if frame_width <= 0 or frame_height <= 0:
116 raise ValidationError(f"{relative_path}: frame has an empty image rectangle")
117 if left + frame_width > width or top + frame_height > height:
118 raise ValidationError(f"{relative_path}: frame rectangle exceeds logical canvas")
119 if image_packed & 0x80:
120 offset += 3 * (1 << ((image_packed & 0x07) + 1))
121 if offset > len(data):
122 raise ValidationError(f"{relative_path}: truncated local color table")
123 if offset >= len(data):
124 raise ValidationError(f"{relative_path}: missing LZW code size")
125 lzw_code_size = data[offset]
126 offset += 1
127 if not 2 <= lzw_code_size <= 8:
128 raise ValidationError(f"{relative_path}: invalid LZW minimum code size {lzw_code_size}")
129 offset = _consume_sub_blocks(data, offset, relative_path)
130 frame_count += 1
132 raise ValidationError(f"{relative_path}: missing GIF trailer")
135def _tracked_gifs() -> set[Path]:
136 result = subprocess.run(
137 ["git", "ls-files", "-z"],
138 cwd=PROJECT_ROOT,
139 check=True,
140 capture_output=True,
141 )
142 return {
143 Path(os.fsdecode(raw_path))
144 for raw_path in result.stdout.split(b"\0")
145 if raw_path and Path(os.fsdecode(raw_path)).suffix.lower() == ".gif"
146 }
149def _validate_allowlist() -> None:
150 tracked = _tracked_gifs()
151 expected = set(GIF_POLICIES)
152 missing = sorted(expected - tracked)
153 unexpected = sorted(tracked - expected)
154 if missing or unexpected:
155 details = []
156 if missing:
157 details.append("missing: " + ", ".join(map(str, missing)))
158 if unexpected:
159 details.append("not allowlisted: " + ", ".join(map(str, unexpected)))
160 raise ValidationError("tracked GIF allowlist mismatch (" + "; ".join(details) + ")")
163def _validate_gif(relative_path: Path, policy: GifPolicy) -> tuple[int, tuple[int, int], int]:
164 path = PROJECT_ROOT / relative_path
165 if path.is_symlink() or not path.is_file():
166 raise ValidationError(f"{relative_path}: expected a regular file")
168 file_size = path.stat().st_size
169 if file_size > policy.max_bytes:
170 raise ValidationError(
171 f"{relative_path}: {file_size:,} bytes exceeds {policy.max_bytes:,}-byte limit"
172 )
174 data = path.read_bytes()
175 width, height, parsed_frame_count = _validate_gif_structure(data, relative_path)
176 del data
177 if width > policy.max_width or height > policy.max_height:
178 raise ValidationError(
179 f"{relative_path}: {width}x{height} exceeds "
180 f"{policy.max_width}x{policy.max_height} limit"
181 )
182 if parsed_frame_count > policy.max_frames:
183 raise ValidationError(
184 f"{relative_path}: {parsed_frame_count} frames exceeds {policy.max_frames}-frame limit"
185 )
186 decoded_pixels = width * height * parsed_frame_count
187 pixel_budget = policy.max_width * policy.max_height * policy.max_frames
188 # Defence in depth: each factor has just been bounded by its own ceiling,
189 # so their product cannot exceed the product of the ceilings and this branch
190 # is unreachable today. It stays so that loosening any one check above
191 # cannot silently unbound the total decoder work.
192 if decoded_pixels > pixel_budget: # pragma: no cover - implied by the three checks above
193 raise ValidationError(
194 f"{relative_path}: decoded pixel budget {decoded_pixels:,} exceeds {pixel_budget:,}"
195 )
197 # Pillow normally tolerates truncated images for some callers. Keep strict
198 # decoding here and turn its decompression-bomb warning into a hard failure.
199 ImageFile.LOAD_TRUNCATED_IMAGES = False
200 Image.MAX_IMAGE_PIXELS = MAX_CANVAS_PIXELS
201 with warnings.catch_warnings():
202 warnings.simplefilter("error", Image.DecompressionBombWarning)
204 with Image.open(path) as image:
205 if image.format != "GIF":
206 raise ValidationError(
207 f"{relative_path}: decoder identified {image.format!r}, not GIF"
208 )
209 if image.size != (width, height):
210 raise ValidationError(
211 f"{relative_path}: parser/decoder canvas mismatch "
212 f"({width}x{height} versus {image.size[0]}x{image.size[1]})"
213 )
214 # Autopilot GIFs are embedded as product demos, and static clients
215 # use frame zero as their preview. Reject the near-empty PTY frame
216 # agg produces when a cast's first visible output starts after t=0.
217 if relative_path in {
218 Path("demo/autopilot-codex.gif"),
219 Path("demo/autopilot-claude-code.gif"),
220 Path("demo/autopilot-opencode.gif"),
221 }:
222 first_frame = image.convert("RGB")
223 colors = first_frame.getcolors(maxcolors=width * height)
224 if colors is None: # pragma: no cover - maxcolors is the pixel count
225 raise ValidationError(
226 f"{relative_path}: could not measure first-frame color coverage"
227 )
228 dominant_pixels = max(count for count, _color in colors)
229 visible_ratio = (width * height - dominant_pixels) / (width * height)
230 if visible_ratio < 0.05:
231 raise ValidationError(
232 f"{relative_path}: first frame is effectively blank "
233 f"({visible_ratio:.2%} non-background pixels; require 5.00%)"
234 )
235 image.verify()
237 # verify() intentionally invalidates the decoder, so reopen and load
238 # every frame. This catches malformed LZW streams hidden after frame 1.
239 with Image.open(path) as image:
240 frame_count = getattr(image, "n_frames", 1)
241 if frame_count != parsed_frame_count:
242 raise ValidationError(
243 f"{relative_path}: parser found {parsed_frame_count} frames but "
244 f"decoder found {frame_count}"
245 )
246 for frame_number in range(frame_count):
247 image.seek(frame_number)
248 image.load()
250 return file_size, (width, height), frame_count
253def main() -> int:
254 try:
255 _validate_allowlist()
256 for relative_path, policy in GIF_POLICIES.items():
257 file_size, dimensions, frame_count = _validate_gif(relative_path, policy)
258 print(
259 f"PASS {relative_path}: {file_size:,} bytes, "
260 f"{dimensions[0]}x{dimensions[1]}, {frame_count} frames"
261 )
262 except (OSError, subprocess.SubprocessError, ValidationError, Warning) as exc:
263 print(f"GIF validation failed: {exc}", file=sys.stderr)
264 return 1
265 return 0
268if __name__ == "__main__":
269 raise SystemExit(main())