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
« 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.
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.
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:
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.
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"""
36from __future__ import annotations
38import os
39import re
40import subprocess
41import sys
42import tomllib
43from collections.abc import Callable
44from dataclasses import dataclass
45from pathlib import Path
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
55PROJECT_ROOT = Path(__file__).resolve().parents[2]
56KIB = 1024
58SVG_NAMESPACE = "http://www.w3.org/2000/svg"
61@dataclass(frozen=True)
62class SvgPolicy:
63 """Maximum accepted size for one intentionally tracked SVG."""
65 max_bytes: int
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}
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)
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)
123_LOCAL_URL_RE = re.compile(r"^url\(#([A-Za-z_][\w.-]*)\)$")
126class ValidationError(ValueError):
127 """A tracked SVG violates the reviewed content policy."""
130class _PolicyParser:
131 """expat handlers that reject anything outside the policy as it is parsed."""
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
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")
161 return handler
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 )
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)
185 def _end_element(self, _name: str) -> None:
186 self._depth -= 1
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))
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
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 )
247def _tracked_svgs() -> set[Path]:
248 """Every ``*.svg`` git sees: tracked, plus untracked files it does not ignore.
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 }
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) + ")")
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("/") + "/"
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")
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 )
305 parser = _PolicyParser(relative_path, site_url)
306 parser.feed(path.read_bytes())
307 return file_size, parser.element_count, parser.link_count
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
326if __name__ == "__main__":
327 raise SystemExit(main())