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