Coverage for .github / scripts / validate_svg_assets.py: 100.00%

137 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2"""Validate the tracked SVG assets against the reviewed content policy. 

3 

4SVG is XML with a scripting model. A file can carry ``<script>``, ``on*`` event 

5handlers, ``<foreignObject>`` (arbitrary HTML), and references that make the 

6renderer fetch remote resources (``<image>``, ``<use>``, ``xlink:href``, CSS 

7``url()``). GitHub and the wiki embed the repository's SVGs as ``<img>``, where 

8none of that runs — but each SVG is also published as its own document on the 

9Pages origin (``scripts/build_wiki.py`` stages ``diagrams/api_specs/*.svg`` into 

10the wiki's source tree as ``/api/`` and ``images/*.svg`` as ``/assets/images/``), 

11and the interaction diagram's links only work when it is opened that way. 

12Opened as a document, an SVG runs whatever it carries, on an origin every 

13project site in the GitHub organisation shares. 

14 

15``diagrams/api_specs/generate.py --check`` proves the committed bytes are what 

16the renderer produces; it cannot say whether what the renderer produces is safe, 

17and it says nothing about an SVG someone exports from a drawing tool. This 

18validator pins the content itself, independent of how the file was made: 

19 

20* an explicit allowlist of SVG paths with a byte ceiling each — a new SVG fails 

21 until a reviewer adds it here, exactly like the demo GIFs; 

22* an element and attribute *allowlist* (drawing primitives, text, ``<a>``) 

23 rather than a denylist of known-dangerous names; 

24* no DOCTYPE, processing instructions or CDATA sections (entity expansion, 

25 external entities, ``<?xml-stylesheet?>``); 

26* the default SVG namespace only — no ``xlink`` or other foreign namespaces; 

27* every ``href`` is a same-document ``#id`` or a page under the ``site_url`` 

28 declared in ``zensical.toml``; every ``url(#id)`` paint or marker reference and 

29 every ``aria-labelledby`` token resolves to an ``id`` the file defines. 

30 

31Parsing uses the stdlib expat parser directly, with handlers that reject the 

32DOCTYPE before any entity could be declared, so the amplification and external 

33entity vectors are closed by policy rather than left to parser defaults. 

34""" 

35 

36from __future__ import annotations 

37 

38import os 

39import re 

40import subprocess 

41import sys 

42import tomllib 

43from collections.abc import Callable 

44from dataclasses import dataclass 

45from pathlib import Path 

46 

47# The semgrep rule suppressed below warns about entity expansion and external 

48# entities. _PolicyParser installs a StartDoctypeDeclHandler that raises, so no 

49# DTD — and therefore no entity declaration of either kind — is ever accepted, 

50# and the input is a tracked repository file rather than network data. 

51# defusedxml would add a dependency to guard a vector this policy already 

52# refuses. 

53from xml.parsers import expat # nosemgrep: python.lang.security.use-defused-xml.use-defused-xml 

54 

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

56KIB = 1024 

57 

58SVG_NAMESPACE = "http://www.w3.org/2000/svg" 

59 

60 

61@dataclass(frozen=True) 

62class SvgPolicy: 

63 """Maximum accepted size for one intentionally tracked SVG.""" 

64 

65 max_bytes: int 

66 

67 

68# Any new SVG, or an intentional increase, requires a review of this list. 

69SVG_POLICIES = { 

70 # The API interaction diagram, ~25 KiB today. It grows with the route 

71 # inventory — one text line per new route prefix — not with rendering. 

72 Path("diagrams/api_specs/api-topology.svg"): SvgPolicy(96 * KIB), 

73 # The project logo (images/README.md#project-logo): the mark for light and 

74 # for dark backgrounds, shown in the README header, and the tile the wiki 

75 # uses as its header logo and favicon. Four or five filled shapes each, 

76 # under 1 KiB today; a redraw should not need more than this. 

77 Path("images/gco-logo.svg"): SvgPolicy(4 * KIB), 

78 Path("images/gco-logo-dark.svg"): SvgPolicy(4 * KIB), 

79 Path("images/gco-icon.svg"): SvgPolicy(4 * KIB), 

80} 

81 

82# Drawing primitives, text and links: what the interaction diagram is drawn 

83# from, and nothing that executes, embeds foreign content or fetches. Widening 

84# this set is a reviewed change; ``tests/test_validate_svg_assets.py`` refuses 

85# the names that would reintroduce any of those capabilities. 

86ALLOWED_ELEMENTS = frozenset( 

87 {"svg", "defs", "marker", "path", "rect", "text", "title", "desc", "a"} 

88) 

89 

90# Geometry and presentation only. No ``style`` (CSS ``url()``/``@import``), no 

91# ``on*`` handlers, no ``class`` (nothing to select it). ``href`` and every 

92# ``url(…)`` value are checked individually below. 

93ALLOWED_ATTRIBUTES = frozenset( 

94 { 

95 "aria-labelledby", 

96 "d", 

97 "fill", 

98 "font-family", 

99 "font-size", 

100 "font-weight", 

101 "height", 

102 "href", 

103 "id", 

104 "marker-end", 

105 "markerHeight", 

106 "markerWidth", 

107 "orient", 

108 "refX", 

109 "refY", 

110 "role", 

111 "rx", 

112 "stroke", 

113 "stroke-dasharray", 

114 "stroke-width", 

115 "text-anchor", 

116 "viewBox", 

117 "width", 

118 "x", 

119 "y", 

120 } 

121) 

122 

123_LOCAL_URL_RE = re.compile(r"^url\(#([A-Za-z_][\w.-]*)\)$") 

124 

125 

126class ValidationError(ValueError): 

127 """A tracked SVG violates the reviewed content policy.""" 

128 

129 

130class _PolicyParser: 

131 """expat handlers that reject anything outside the policy as it is parsed.""" 

132 

133 def __init__(self, relative_path: Path, site_url: str) -> None: 

134 self.relative_path = relative_path 

135 self.site_url = site_url 

136 self.element_count = 0 

137 self.link_count = 0 

138 self.defined_ids: set[str] = set() 

139 self.referenced_ids: set[str] = set() 

140 self._depth = 0 

141 # ``namespace_separator`` makes every element and attribute name arrive 

142 # as ``"<namespace URI> <local name>"`` when it is namespaced, so a 

143 # foreign namespace cannot hide behind a familiar-looking prefix. 

144 parser = expat.ParserCreate(namespace_separator=" ") 

145 parser.StartElementHandler = self._start_element 

146 parser.EndElementHandler = self._end_element 

147 parser.StartNamespaceDeclHandler = self._start_namespace 

148 # Entities can only be declared inside a DOCTYPE, so refusing the 

149 # DOCTYPE itself closes entity expansion and external entities in one 

150 # place; ``<?xml-stylesheet?>`` is the processing instruction that 

151 # would load a stylesheet, and CDATA is how script bodies are wrapped. 

152 parser.StartDoctypeDeclHandler = self._reject("a DOCTYPE declaration") 

153 parser.ProcessingInstructionHandler = self._reject("a processing instruction") 

154 parser.StartCdataSectionHandler = self._reject("a CDATA section") 

155 self._parser = parser 

156 

157 def _reject(self, what: str) -> Callable[..., None]: 

158 def handler(*_args: object) -> None: 

159 raise ValidationError(f"{self.relative_path}: {what} is not allowed") 

160 

161 return handler 

162 

163 def _start_namespace(self, prefix: str | None, uri: str | None) -> None: 

164 if prefix is not None or uri != SVG_NAMESPACE: 

165 raise ValidationError( 

166 f"{self.relative_path}: namespace {uri!r} (prefix {prefix!r}) is not " 

167 f"allowed; only the default {SVG_NAMESPACE} namespace is" 

168 ) 

169 

170 def _start_element(self, name: str, attributes: dict[str, str]) -> None: 

171 namespace, _, local = name.rpartition(" ") 

172 if namespace != SVG_NAMESPACE: 

173 raise ValidationError(f"{self.relative_path}: <{local}> is not in the SVG namespace") 

174 if local not in ALLOWED_ELEMENTS: 

175 raise ValidationError(f"{self.relative_path}: <{local}> is not an allowed element") 

176 if self._depth == 0 and local != "svg": 

177 raise ValidationError( 

178 f"{self.relative_path}: the root element must be <svg>, not <{local}>" 

179 ) 

180 self._depth += 1 

181 self.element_count += 1 

182 for attribute, value in attributes.items(): 

183 self._check_attribute(local, attribute, value) 

184 

185 def _end_element(self, _name: str) -> None: 

186 self._depth -= 1 

187 

188 def _check_attribute(self, element: str, attribute: str, value: str) -> None: 

189 if " " in attribute: 

190 # The namespace handler has already refused every declared foreign 

191 # namespace; this catches the implicit ``xml:`` one (``xml:space``, 

192 # ``xml:base`` — the latter rebases every relative reference). 

193 namespace, _, local = attribute.rpartition(" ") 

194 raise ValidationError( 

195 f"{self.relative_path}: <{element}> attribute {local!r} in namespace " 

196 f"{namespace!r} is not allowed" 

197 ) 

198 if attribute not in ALLOWED_ATTRIBUTES: 

199 raise ValidationError( 

200 f"{self.relative_path}: <{element} {attribute}> is not an allowed attribute" 

201 ) 

202 if attribute == "id": 

203 if value in self.defined_ids: 

204 raise ValidationError(f"{self.relative_path}: duplicate id {value!r}") 

205 self.defined_ids.add(value) 

206 elif attribute == "href": 

207 self._check_href(element, value) 

208 elif attribute == "aria-labelledby": 

209 self.referenced_ids.update(value.split()) 

210 elif "url(" in value: 

211 match = _LOCAL_URL_RE.fullmatch(value) 

212 if match is None: 

213 raise ValidationError( 

214 f"{self.relative_path}: <{element} {attribute}={value!r}> must be a " 

215 "same-document url(#id) reference" 

216 ) 

217 self.referenced_ids.add(match.group(1)) 

218 

219 def _check_href(self, element: str, value: str) -> None: 

220 if element != "a": 

221 raise ValidationError( 

222 f"{self.relative_path}: href is only allowed on <a>, not on <{element}>" 

223 ) 

224 if value.startswith("#"): 

225 self.referenced_ids.add(value[1:]) 

226 elif not value.startswith(self.site_url): 

227 raise ValidationError( 

228 f"{self.relative_path}: <a href={value!r}> must be a same-document #id or a " 

229 f"page under {self.site_url}" 

230 ) 

231 self.link_count += 1 

232 

233 def feed(self, data: bytes) -> None: 

234 """Parse the whole document, then resolve every same-document reference.""" 

235 try: 

236 self._parser.Parse(data, True) 

237 except expat.ExpatError as exc: 

238 raise ValidationError(f"{self.relative_path}: not well-formed XML ({exc})") from exc 

239 dangling = sorted(self.referenced_ids - self.defined_ids) 

240 if dangling: 

241 raise ValidationError( 

242 f"{self.relative_path}: references to ids the file does not define: " 

243 + ", ".join(dangling) 

244 ) 

245 

246 

247def _tracked_svgs() -> set[Path]: 

248 """Every ``*.svg`` git sees: tracked, plus untracked files it does not ignore. 

249 

250 Including untracked files means a freshly generated diagram is validated 

251 before it is committed, and a stray export dropped into the tree fails 

252 locally rather than on the PR. CI checkouts are clean, so there the two 

253 sets coincide. 

254 """ 

255 result = subprocess.run( 

256 ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], 

257 cwd=PROJECT_ROOT, 

258 check=True, 

259 capture_output=True, 

260 ) 

261 return { 

262 Path(os.fsdecode(raw_path)) 

263 for raw_path in result.stdout.split(b"\0") 

264 if raw_path and Path(os.fsdecode(raw_path)).suffix.lower() == ".svg" 

265 } 

266 

267 

268def _validate_allowlist() -> None: 

269 tracked = _tracked_svgs() 

270 expected = set(SVG_POLICIES) 

271 missing = sorted(expected - tracked) 

272 unexpected = sorted(tracked - expected) 

273 if missing or unexpected: 

274 details = [] 

275 if missing: 

276 details.append("missing: " + ", ".join(map(str, missing))) 

277 if unexpected: 

278 details.append("not allowlisted: " + ", ".join(map(str, unexpected))) 

279 raise ValidationError("SVG allowlist mismatch (" + "; ".join(details) + ")") 

280 

281 

282def _site_url() -> str: 

283 """The published site's URL prefix, from the one place it is declared.""" 

284 with (PROJECT_ROOT / "zensical.toml").open("rb") as handle: 

285 project = tomllib.load(handle).get("project") 

286 site_url = project.get("site_url") if isinstance(project, dict) else None 

287 if not isinstance(site_url, str) or not site_url.startswith("https://"): 

288 raise ValidationError( 

289 "zensical.toml: [project] site_url must be declared as an https:// URL" 

290 ) 

291 return site_url.rstrip("/") + "/" 

292 

293 

294def _validate_svg(relative_path: Path, policy: SvgPolicy, site_url: str) -> tuple[int, int, int]: 

295 path = PROJECT_ROOT / relative_path 

296 if path.is_symlink() or not path.is_file(): 

297 raise ValidationError(f"{relative_path}: expected a regular file") 

298 

299 file_size = path.stat().st_size 

300 if file_size > policy.max_bytes: 

301 raise ValidationError( 

302 f"{relative_path}: {file_size:,} bytes exceeds {policy.max_bytes:,}-byte limit" 

303 ) 

304 

305 parser = _PolicyParser(relative_path, site_url) 

306 parser.feed(path.read_bytes()) 

307 return file_size, parser.element_count, parser.link_count 

308 

309 

310def main() -> int: 

311 try: 

312 _validate_allowlist() 

313 site_url = _site_url() 

314 for relative_path, policy in SVG_POLICIES.items(): 

315 file_size, element_count, link_count = _validate_svg(relative_path, policy, site_url) 

316 print( 

317 f"PASS {relative_path}: {file_size:,} bytes, " 

318 f"{element_count} elements, {link_count} links" 

319 ) 

320 except (OSError, subprocess.SubprocessError, ValidationError, tomllib.TOMLDecodeError) as exc: 

321 print(f"SVG validation failed: {exc}", file=sys.stderr) 

322 return 1 

323 return 0 

324 

325 

326if __name__ == "__main__": 

327 raise SystemExit(main())