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

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

50 

51 

52class ValidationError(ValueError): 

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

54 

55 

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

68 

69 

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

74 

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

78 

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

85 

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 

98 

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 

105 

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

110 

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 

130 

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

132 

133 

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 } 

146 

147 

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

160 

161 

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

166 

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 ) 

172 

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 ) 

195 

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) 

202 

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

234 

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

247 

248 return file_size, (width, height), frame_count 

249 

250 

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 

264 

265 

266if __name__ == "__main__": 

267 raise SystemExit(main())