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

1#!/usr/bin/env python3 

2"""Safely validate the tracked demo GIF allowlist. 

3 

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""" 

8 

9from __future__ import annotations 

10 

11import os 

12import struct 

13import subprocess 

14import sys 

15import warnings 

16from dataclasses import dataclass 

17from pathlib import Path 

18 

19from PIL import Image, ImageFile 

20 

21PROJECT_ROOT = Path(__file__).resolve().parents[2] 

22MIB = 1024 * 1024 

23 

24 

25@dataclass(frozen=True) 

26class GifPolicy: 

27 """Maximum accepted resource use for one intentionally tracked GIF.""" 

28 

29 max_bytes: int 

30 max_width: int 

31 max_height: int 

32 max_frames: int 

33 

34 

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()) 

51 

52 

53class ValidationError(ValueError): 

54 """A tracked media asset violates the reviewed GIF policy.""" 

55 

56 

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") 

69 

70 

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") 

75 

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") 

79 

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") 

86 

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 

99 

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 

106 

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") 

111 

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 

131 

132 raise ValidationError(f"{relative_path}: missing GIF trailer") 

133 

134 

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 } 

147 

148 

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) + ")") 

161 

162 

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") 

167 

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 ) 

173 

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 ) 

196 

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) 

203 

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() 

236 

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() 

249 

250 return file_size, (width, height), frame_count 

251 

252 

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 

266 

267 

268if __name__ == "__main__": 

269 raise SystemExit(main())