[verified] add semantic patterns and accessible motion
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -14,6 +15,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LINTER = ROOT / "scripts/lint-skin.py"
|
||||
BUILD_ICONS = ROOT / "scripts/build-icons.py"
|
||||
MOTION_TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
|
||||
|
||||
VALID_SVG = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" role="img"
|
||||
@@ -62,6 +64,19 @@ def require_pass(name: str, html: str, directory: Path) -> None:
|
||||
print(f"OK: {name} accepted")
|
||||
|
||||
|
||||
def require_lint_finding(
|
||||
name: str, html: str, expected: str, directory: Path
|
||||
) -> None:
|
||||
path = directory / f"{name}.html"
|
||||
path.write_text(html, encoding="utf-8")
|
||||
result = run_linter(path)
|
||||
if result.returncode != 1 or expected not in result.stdout:
|
||||
raise AssertionError(
|
||||
f"{name}: expected finding {expected!r}\n{result.stdout}{result.stderr}"
|
||||
)
|
||||
print(f"OK: {name} rejected — {expected}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="lint-a11y-") as temp_dir:
|
||||
directory = Path(temp_dir)
|
||||
@@ -147,6 +162,207 @@ def main() -> int:
|
||||
'<path d="M0 0h1v1z"/></svg>\n',
|
||||
directory,
|
||||
)
|
||||
template_source = MOTION_TEMPLATE.read_text(encoding="utf-8")
|
||||
controller_match = re.search(
|
||||
r"<script\b[^>]*data-diagram-controls[^>]*>(.*?)</script\s*>",
|
||||
template_source,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if controller_match is None:
|
||||
raise AssertionError("canonical motion controller could not be extracted")
|
||||
controller_body = controller_match.group(1)
|
||||
canonical_script = f"<script data-diagram-controls>{controller_body}</script>\n"
|
||||
motion_svg = VALID_SVG.replace("fixture-title", "motion-controls-title").replace(
|
||||
"fixture-desc", "motion-controls-desc"
|
||||
)
|
||||
require_pass(
|
||||
"motion-controls",
|
||||
motion_svg + canonical_script,
|
||||
directory,
|
||||
)
|
||||
duplicate_svg = VALID_SVG.replace(
|
||||
"fixture-title", "duplicate-controller-title"
|
||||
).replace("fixture-desc", "duplicate-controller-desc")
|
||||
require_lint_finding(
|
||||
"duplicate-controller",
|
||||
duplicate_svg + canonical_script + canonical_script,
|
||||
"script: only one canonical motion controller is allowed",
|
||||
directory,
|
||||
)
|
||||
unscoped_svg = VALID_SVG.replace("fixture-title", "unscoped-script-title").replace(
|
||||
"fixture-desc", "unscoped-script-desc"
|
||||
)
|
||||
require_lint_finding(
|
||||
"unscoped-script",
|
||||
unscoped_svg + "<script>void 0;</script>\n",
|
||||
"script: only the canonical data-diagram-controls attribute is allowed",
|
||||
directory,
|
||||
)
|
||||
controller_attacks = {
|
||||
"network-controller": "fetch('/collect');",
|
||||
"dynamic-html-controller": "document.body.innerHTML = '<p>changed</p>';",
|
||||
"import-controller": "import('./remote.js');",
|
||||
"eval-controller": "eval('1 + 1');",
|
||||
"navigation-controller": "location.assign('https://example.invalid');",
|
||||
"modified-controller": "void 0;",
|
||||
}
|
||||
for name, injected in controller_attacks.items():
|
||||
attack_svg = VALID_SVG.replace(
|
||||
"fixture-title", f"{name}-title"
|
||||
).replace("fixture-desc", f"{name}-desc")
|
||||
modified = controller_body.replace("(() => {", f"(() => {{\n {injected}", 1)
|
||||
require_lint_finding(
|
||||
name,
|
||||
attack_svg + f"<script data-diagram-controls>{modified}</script>\n",
|
||||
"script: motion controller must exactly match template-motion.html",
|
||||
directory,
|
||||
)
|
||||
unclosed_svg = VALID_SVG.replace("fixture-title", "unclosed-script-title").replace(
|
||||
"fixture-desc", "unclosed-script-desc"
|
||||
)
|
||||
require_lint_finding(
|
||||
"unclosed-motion-script",
|
||||
unclosed_svg + f"<script data-diagram-controls>{controller_body}\n",
|
||||
"script: motion controller must have a closing script tag",
|
||||
directory,
|
||||
)
|
||||
remote_svg = VALID_SVG.replace("fixture-title", "remote-script-title").replace(
|
||||
"fixture-desc", "remote-script-desc"
|
||||
)
|
||||
require_lint_finding(
|
||||
"remote-script",
|
||||
remote_svg
|
||||
+ f'<script data-diagram-controls src="https://example.invalid/c.js">{controller_body}</script>\n',
|
||||
"script: only the canonical data-diagram-controls attribute is allowed",
|
||||
directory,
|
||||
)
|
||||
type_svg = VALID_SVG.replace("fixture-title", "script-type-title").replace(
|
||||
"fixture-desc", "script-type-desc"
|
||||
)
|
||||
require_lint_finding(
|
||||
"script-type-override",
|
||||
type_svg
|
||||
+ f'<script data-diagram-controls type="application/json">{controller_body}</script>\n',
|
||||
"script: only the canonical data-diagram-controls attribute is allowed",
|
||||
directory,
|
||||
)
|
||||
|
||||
inter_svg = VALID_SVG.replace("fixture-title", "inter-tight-title").replace(
|
||||
"fixture-desc", "inter-tight-desc"
|
||||
)
|
||||
require_pass(
|
||||
"inter-tight",
|
||||
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&display=swap">\n'
|
||||
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
|
||||
+ inter_svg,
|
||||
directory,
|
||||
)
|
||||
lookalike_svg = VALID_SVG.replace(
|
||||
"fixture-title", "google-fonts-lookalike-title"
|
||||
).replace("fixture-desc", "google-fonts-lookalike-desc")
|
||||
require_lint_finding(
|
||||
"google-fonts-lookalike",
|
||||
'<link rel="stylesheet" href="https://fonts.googleapis.com.evil.test/css2?family=Inter+Tight">\n'
|
||||
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
|
||||
+ lookalike_svg,
|
||||
"external-asset: external HTTP(S) <link> is not allowed",
|
||||
directory,
|
||||
)
|
||||
bad_path_svg = VALID_SVG.replace(
|
||||
"fixture-title", "google-fonts-bad-path-title"
|
||||
).replace("fixture-desc", "google-fonts-bad-path-desc")
|
||||
require_lint_finding(
|
||||
"google-fonts-bad-path",
|
||||
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2.evil?family=Inter+Tight">\n'
|
||||
+ bad_path_svg,
|
||||
"external-asset: external HTTP(S) <link> is not allowed",
|
||||
directory,
|
||||
)
|
||||
protocol_relative_svg = VALID_SVG.replace(
|
||||
"fixture-title", "protocol-relative-font-title"
|
||||
).replace("fixture-desc", "protocol-relative-font-desc")
|
||||
require_lint_finding(
|
||||
"protocol-relative-font",
|
||||
'<link rel="stylesheet" href="//fonts.googleapis.com/css2?family=Inter+Tight">\n'
|
||||
+ protocol_relative_svg,
|
||||
"external-asset: external HTTP(S) <link> is not allowed",
|
||||
directory,
|
||||
)
|
||||
unquoted_remote_svg = VALID_SVG.replace(
|
||||
"fixture-title", "unquoted-remote-title"
|
||||
).replace("fixture-desc", "unquoted-remote-desc")
|
||||
require_lint_finding(
|
||||
"unquoted-remote-link",
|
||||
'<link rel=stylesheet href=https://evil.example/x.css>\n'
|
||||
+ unquoted_remote_svg,
|
||||
"external-asset: external resource in <link> href is not allowed",
|
||||
directory,
|
||||
)
|
||||
bypass_svg = VALID_SVG.replace("fixture-title", "resource-bypass-title").replace(
|
||||
"fixture-desc", "resource-bypass-desc"
|
||||
)
|
||||
require_lint_finding(
|
||||
"srcset-second-candidate",
|
||||
'<img srcset="local.png 1x, https://evil.example/remote.png 2x">\n'
|
||||
+ bypass_svg,
|
||||
"external-asset: external resource in <img> srcset is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"protocol-relative-css-url",
|
||||
'<style>.x { background: url(//evil.example/x.png); }</style>\n'
|
||||
+ bypass_svg,
|
||||
"external-asset: non-fragment CSS url() is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"event-handler",
|
||||
'<div onclick="alert(1)"></div>\n' + bypass_svg,
|
||||
"script: executable attribute onclick on <div> is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"javascript-url",
|
||||
'<iframe src="javascript:alert(1)"></iframe>\n' + bypass_svg,
|
||||
"script: executable attribute src on <iframe> is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"javascript-control-character",
|
||||
'<iframe src="jav	ascript:alert(1)"></iframe>\n' + bypass_svg,
|
||||
"script: executable attribute src on <iframe> is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"base-href",
|
||||
'<base href="https://evil.example/">\n' + bypass_svg,
|
||||
"script: executable attribute href on <base> is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"iframe-srcdoc",
|
||||
'<iframe srcdoc="<script>alert(1)</script>"></iframe>\n'
|
||||
+ bypass_svg,
|
||||
"script: executable attribute srcdoc on <iframe> is not allowed",
|
||||
directory,
|
||||
)
|
||||
require_lint_finding(
|
||||
"css-escaped-scheme",
|
||||
'<style>.x { background: url(https\\3a //evil.example/x); }</style>\n'
|
||||
+ bypass_svg,
|
||||
"external-asset: non-fragment CSS url() is not allowed",
|
||||
directory,
|
||||
)
|
||||
arbitrary_font_svg = VALID_SVG.replace(
|
||||
"fixture-title", "arbitrary-font-title"
|
||||
).replace("fixture-desc", "arbitrary-font-desc")
|
||||
require_lint_finding(
|
||||
"arbitrary-font",
|
||||
'<style>text { font-family: "Unapproved Sans", sans-serif; }</style>\n'
|
||||
+ arbitrary_font_svg,
|
||||
"font-family: unsupported font family: Unapproved Sans",
|
||||
directory,
|
||||
)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("build_icons", BUILD_ICONS)
|
||||
if spec is None or spec.loader is None:
|
||||
@@ -186,6 +402,34 @@ def main() -> int:
|
||||
ROOT / "skills/diagram-design/references/style-guide.md",
|
||||
project / "skills/diagram-design/references/style-guide.md",
|
||||
)
|
||||
project_style = project / "skills/diagram-design/references/style-guide.md"
|
||||
project_style.write_text(
|
||||
project_style.read_text(encoding="utf-8").replace(
|
||||
"| `title` | Instrument Serif |",
|
||||
"| `title` | Inter Tight |",
|
||||
1,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
brand_font_path = project / "style-guide-font.html"
|
||||
brand_font_path.write_text(
|
||||
VALID_SVG.replace("fixture-title", "style-guide-font-title")
|
||||
.replace("fixture-desc", "style-guide-font-desc")
|
||||
.replace("</svg>", '<text style="font-family: Inter Tight;">Brand</text></svg>'),
|
||||
encoding="utf-8",
|
||||
)
|
||||
brand_font_result = subprocess.run(
|
||||
[sys.executable, str(project / "scripts/lint-skin.py"), str(brand_font_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if brand_font_result.returncode != 0:
|
||||
raise AssertionError(
|
||||
"style-guide font: expected pass\n"
|
||||
f"{brand_font_result.stdout}{brand_font_result.stderr}"
|
||||
)
|
||||
print("OK: exact customized style-guide typography family accepted")
|
||||
(project / "scripts/lint-skin-baseline.txt").write_text(
|
||||
"example-baseline.html\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user