mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
fix(docs): harden README visual XML and path validation
This commit is contained in:
1 parent
4696804b23
commit
8a140c1906
7 files changed
+271
-10
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
name: README visual asset validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'README.md'
|
||||||
|
- 'assets/readme/**'
|
||||||
|
- 'docs/readme-visual-system.md'
|
||||||
|
- 'scripts/*readme*'
|
||||||
|
- 'scripts/tests/test_readme_visuals_xml.py'
|
||||||
|
- '.github/workflows/readme-visuals.yml'
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'README.md'
|
||||||
|
- 'assets/readme/**'
|
||||||
|
- 'docs/readme-visual-system.md'
|
||||||
|
- 'scripts/*readme*'
|
||||||
|
- 'scripts/tests/test_readme_visuals_xml.py'
|
||||||
|
- '.github/workflows/readme-visuals.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Safe and reproducible README visuals
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
|
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Install visual tooling dependency
|
||||||
|
run: python -m pip install -r scripts/requirements-readme-visuals.txt
|
||||||
|
- name: Test XML input boundaries
|
||||||
|
run: python -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py
|
||||||
|
- name: Verify reproducible and accessible assets
|
||||||
|
run: |
|
||||||
|
python scripts/render-readme-visuals.py
|
||||||
|
python scripts/verify-readme-visuals.py
|
||||||
|
git diff --exit-code -- assets/readme
|
||||||
|
test -z "$(git ls-files --others --exclude-standard -- assets/readme)"
|
||||||
@@ -5,11 +5,23 @@ The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradient
|
|||||||
## Rebuild and check
|
## Rebuild and check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
python3 -m pip install -r scripts/requirements-readme-visuals.txt
|
||||||
python3 scripts/render-readme-visuals.py
|
python3 scripts/render-readme-visuals.py
|
||||||
python3 scripts/verify-readme-visuals.py
|
python3 scripts/verify-readme-visuals.py
|
||||||
|
python3 -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The generator uses only Python's standard library and makes no network calls. The committed SVGs are the deployment artifacts. The manifest in `assets/readme/manifest.json` lists every destination.
|
Install the tooling dependency in a virtual environment. The generator and verifier
|
||||||
|
use `defusedxml` to reject DTDs, entities and external XML references. SVG inputs
|
||||||
|
must be smaller than 20,000 bytes; the verifier reads at most that limit and
|
||||||
|
rejects oversized inputs before parsing. Both tools use UTF-8, and generated
|
||||||
|
files use LF line endings for reproducibility across platforms. They make no
|
||||||
|
network calls. This dependency is only for visual maintenance, not the runtime.
|
||||||
|
Manifest paths must be relative: SVG files stay inside `assets/readme`, and
|
||||||
|
local link targets stay inside the repository, including after symlink resolution.
|
||||||
|
|
||||||
|
The committed SVGs are the deployment artifacts. The manifest in
|
||||||
|
`assets/readme/manifest.json` lists every destination.
|
||||||
|
|
||||||
## Links and rendering
|
## Links and rendering
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Bounded, hardened XML parsing for the README visual maintenance tools."""
|
||||||
|
|
||||||
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
|
|
||||||
|
from defusedxml import ElementTree
|
||||||
|
|
||||||
|
|
||||||
|
# Keep the visual verifier's existing exclusive per-asset size limit.
|
||||||
|
MAX_SVG_BYTES = 20_000
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_file(root: Path, relative: str, *, asset: bool = False) -> Path:
|
||||||
|
"""Resolve a portable manifest path inside its trusted repository boundary."""
|
||||||
|
if (
|
||||||
|
not isinstance(relative, str)
|
||||||
|
or not relative
|
||||||
|
or "\\" in relative
|
||||||
|
or ":" in relative
|
||||||
|
or "\x00" in relative
|
||||||
|
or PurePosixPath(relative).is_absolute()
|
||||||
|
or PureWindowsPath(relative).drive
|
||||||
|
or ".." in relative.split("/")
|
||||||
|
):
|
||||||
|
raise ValueError("Expected a repository-relative path without traversal")
|
||||||
|
repository = root.resolve()
|
||||||
|
boundary = repository / "assets" / "readme" if asset else repository
|
||||||
|
resolved = (repository / relative).resolve()
|
||||||
|
if not resolved.is_relative_to(boundary):
|
||||||
|
raise ValueError("Manifest path escapes its allowed repository directory")
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise ValueError("Manifest path must name an existing file")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def parse_svg(raw: bytes):
|
||||||
|
"""Reject oversized input and DTD/entity declarations before building a tree."""
|
||||||
|
if not isinstance(raw, bytes):
|
||||||
|
raise TypeError("SVG input must be bytes")
|
||||||
|
if len(raw) >= MAX_SVG_BYTES:
|
||||||
|
raise ValueError(f"SVG must be smaller than {MAX_SVG_BYTES} bytes")
|
||||||
|
return ElementTree.fromstring(
|
||||||
|
raw, forbid_dtd=True, forbid_entities=True, forbid_external=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_svg(path: Path):
|
||||||
|
"""Read at most the rejection boundary, including when a file grows mid-read."""
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
raw = stream.read(MAX_SVG_BYTES)
|
||||||
|
return raw, parse_svg(raw)
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate the RuView README visual system. Deterministic, stdlib, no network."""
|
"""Generate the RuView README visual system. Deterministic, no network."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from html import escape
|
from html import escape
|
||||||
import math,json,xml.etree.ElementTree as ET
|
import math,json
|
||||||
|
from readme_visuals_xml import parse_svg
|
||||||
ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True)
|
ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True)
|
||||||
C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5'
|
C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5'
|
||||||
def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''):
|
def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''):
|
||||||
@@ -46,7 +47,7 @@ def frame(w,h,title,desc,body):
|
|||||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-labelledby="title desc"><title id="title">{escape(title)}</title><desc id="desc">{escape(desc)}</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{{font-family:Arial,Helvetica,sans-serif}}.mono{{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}}.flow{{stroke-dasharray:5 12;animation:flow 12s linear infinite}}.breathe{{animation:breathe 6s ease-in-out infinite}}.trace{{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}}.float{{animation:float 9s ease-in-out infinite}}@keyframes flow{{to{{stroke-dashoffset:-136}}}}@keyframes breathe{{50%{{opacity:.4}}}}@keyframes trace{{0%,100%{{stroke-dashoffset:0}}50%{{stroke-dashoffset:90}}}}@keyframes float{{50%{{transform:translateY(-6px)}}}}@media(prefers-reduced-motion:reduce){{*{{animation:none!important}}}}</style><rect x=".5" y=".5" width="{w-1}" height="{h-1}" rx="18" fill="url(#wash)" stroke="#245360"/>{body}</svg>'''
|
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-labelledby="title desc"><title id="title">{escape(title)}</title><desc id="desc">{escape(desc)}</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{{font-family:Arial,Helvetica,sans-serif}}.mono{{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}}.flow{{stroke-dasharray:5 12;animation:flow 12s linear infinite}}.breathe{{animation:breathe 6s ease-in-out infinite}}.trace{{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}}.float{{animation:float 9s ease-in-out infinite}}@keyframes flow{{to{{stroke-dashoffset:-136}}}}@keyframes breathe{{50%{{opacity:.4}}}}@keyframes trace{{0%,100%{{stroke-dashoffset:0}}50%{{stroke-dashoffset:90}}}}@keyframes float{{50%{{transform:translateY(-6px)}}}}@media(prefers-reduced-motion:reduce){{*{{animation:none!important}}}}</style><rect x=".5" y=".5" width="{w-1}" height="{h-1}" rx="18" fill="url(#wash)" stroke="#245360"/>{body}</svg>'''
|
||||||
manifest=[]
|
manifest=[]
|
||||||
def save(name,w,h,title,desc,body,target):
|
def save(name,w,h,title,desc,body,target):
|
||||||
s=frame(w,h,title,desc,body);ET.fromstring(s);(OUT/(name+'.svg')).write_text(s+'\n');manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h})
|
s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h})
|
||||||
# Main title panel: a radio field passes through an illustrated room.
|
# Main title panel: a radio field passes through an illustrated room.
|
||||||
s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18)
|
s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18)
|
||||||
s+='<rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/>'+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2)
|
s+='<rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/>'+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2)
|
||||||
@@ -91,5 +92,5 @@ s+='<rect x="44" y="224" width="256" height="46" rx="11" fill="url(#accent)"/>'+
|
|||||||
s+='<circle cx="947" cy="151" r="173" fill="url(#halo)"/>'+icon('seed',945,150,2.4)
|
s+='<circle cx="947" cy="151" r="173" fill="url(#halo)"/>'+icon('seed',945,150,2.4)
|
||||||
for j in range(3):s+=f'<ellipse cx="945" cy="150" rx="{140+j*20}" ry="{30+j*13}" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/>'
|
for j in range(3):s+=f'<ellipse cx="945" cy="150" rx="{140+j*20}" ry="{30+j*13}" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/>'
|
||||||
save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one')
|
save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one')
|
||||||
(OUT/'manifest.json').write_text(json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n')
|
(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8'))
|
||||||
print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes')
|
print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes')
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Development-only dependency for README SVG generation and verification.
|
||||||
|
defusedxml==0.7.1
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Security regressions for the README visual tools' shared XML boundary."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from defusedxml.common import DTDForbidden
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import readme_visuals_xml as svg_xml
|
||||||
|
|
||||||
|
|
||||||
|
class ReadmeVisualXmlTests(unittest.TestCase):
|
||||||
|
def test_manifest_paths_reject_absolute_and_traversal_before_resolution(self):
|
||||||
|
for relative in [
|
||||||
|
'', '/tmp/outside.svg', '//server/share/outside.svg',
|
||||||
|
'C:/outside.svg', 'C:outside.svg', r'\\server\share\outside.svg',
|
||||||
|
r'\outside.svg', r'\\?\C:\outside.svg', 'file.svg:stream',
|
||||||
|
'../outside.svg', 'assets/readme/../../../outside.svg',
|
||||||
|
r'assets\readme\..\outside.svg', 'assets/readme/\x00.svg',
|
||||||
|
]:
|
||||||
|
with self.subTest(relative=relative):
|
||||||
|
with patch.object(Path, 'resolve') as resolve:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
svg_xml.resolve_repo_file(Path('repository'), relative)
|
||||||
|
resolve.assert_not_called()
|
||||||
|
|
||||||
|
def test_manifest_paths_keep_assets_and_targets_in_separate_boundaries(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory).resolve()
|
||||||
|
asset = repository / 'assets' / 'readme' / 'ordinary.svg'
|
||||||
|
asset.parent.mkdir(parents=True)
|
||||||
|
asset.write_bytes(b'<svg/>')
|
||||||
|
target = repository / 'README.md'
|
||||||
|
target.write_bytes(b'# Readme\n')
|
||||||
|
self.assertEqual(svg_xml.resolve_repo_file(repository, 'assets/readme/ordinary.svg', asset=True), asset)
|
||||||
|
self.assertEqual(svg_xml.resolve_repo_file(repository, 'README.md'), target)
|
||||||
|
with patch.object(Path, 'is_file') as is_file:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
svg_xml.resolve_repo_file(repository, 'README.md', asset=True)
|
||||||
|
is_file.assert_not_called()
|
||||||
|
|
||||||
|
def test_manifest_paths_reject_symlink_escape_before_file_inspection(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
base = Path(directory)
|
||||||
|
repository = base / 'repository'
|
||||||
|
assets = repository / 'assets' / 'readme'
|
||||||
|
assets.mkdir(parents=True)
|
||||||
|
outside = base / 'outside.svg'
|
||||||
|
outside.write_bytes(b'<svg/>')
|
||||||
|
try:
|
||||||
|
(assets / 'escape.svg').symlink_to(outside)
|
||||||
|
(repository / 'escaped-directory').symlink_to(base, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError) as error:
|
||||||
|
if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314):
|
||||||
|
self.skipTest('Windows symlink privilege unavailable')
|
||||||
|
raise
|
||||||
|
for relative, asset in [('assets/readme/escape.svg', True), ('escaped-directory/outside.svg', False)]:
|
||||||
|
with self.subTest(relative=relative):
|
||||||
|
with patch.object(Path, 'is_file') as is_file:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
svg_xml.resolve_repo_file(repository, relative, asset=asset)
|
||||||
|
is_file.assert_not_called()
|
||||||
|
|
||||||
|
def test_asset_directory_symlink_cannot_redefine_trusted_boundary(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
base = Path(directory)
|
||||||
|
repository = base / 'repository'
|
||||||
|
(repository / 'assets').mkdir(parents=True)
|
||||||
|
outside = base / 'outside'
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / 'escape.svg').write_bytes(b'<svg/>')
|
||||||
|
try:
|
||||||
|
(repository / 'assets' / 'readme').symlink_to(outside, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError) as error:
|
||||||
|
if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314):
|
||||||
|
self.skipTest('Windows symlink privilege unavailable')
|
||||||
|
raise
|
||||||
|
with patch.object(Path, 'is_file') as is_file:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
svg_xml.resolve_repo_file(repository, 'assets/readme/escape.svg', asset=True)
|
||||||
|
is_file.assert_not_called()
|
||||||
|
|
||||||
|
def test_plain_utf8_svg_parses(self):
|
||||||
|
root = svg_xml.parse_svg('<svg><title>Présence</title></svg>'.encode('utf-8'))
|
||||||
|
self.assertEqual(root.tag, 'svg')
|
||||||
|
self.assertEqual(root.find('title').text, 'Présence')
|
||||||
|
|
||||||
|
def test_dtd_and_entity_payloads_are_rejected(self):
|
||||||
|
for raw in [
|
||||||
|
b'<!DOCTYPE svg><svg/>',
|
||||||
|
b'<!DOCTYPE svg [<!ENTITY payload "expanded">]><svg>&payload;</svg>',
|
||||||
|
b'<!DOCTYPE svg [<!ENTITY payload SYSTEM "file:///private.txt">]><svg>&payload;</svg>',
|
||||||
|
b'<!DOCTYPE svg SYSTEM "https://example.invalid/remote.dtd"><svg/>',
|
||||||
|
]:
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
with self.assertRaises(DTDForbidden):
|
||||||
|
svg_xml.parse_svg(raw)
|
||||||
|
|
||||||
|
def test_size_limit_is_enforced_before_parser_runs(self):
|
||||||
|
for size in [svg_xml.MAX_SVG_BYTES, svg_xml.MAX_SVG_BYTES + 1]:
|
||||||
|
with self.subTest(size=size):
|
||||||
|
with patch.object(svg_xml.ElementTree, 'fromstring') as parser:
|
||||||
|
with self.assertRaisesRegex(ValueError, 'smaller than 20000 bytes'):
|
||||||
|
svg_xml.parse_svg(b' ' * size)
|
||||||
|
parser.assert_not_called()
|
||||||
|
|
||||||
|
def test_limit_counts_utf8_bytes_and_allows_last_valid_size(self):
|
||||||
|
valid = b'<svg/>' + b' ' * (svg_xml.MAX_SVG_BYTES - 7)
|
||||||
|
self.assertEqual(len(valid), svg_xml.MAX_SVG_BYTES - 1)
|
||||||
|
self.assertEqual(svg_xml.parse_svg(valid).tag, 'svg')
|
||||||
|
text = '<svg>' + 'é' * 10000 + '</svg>'
|
||||||
|
self.assertLess(len(text), svg_xml.MAX_SVG_BYTES)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
svg_xml.parse_svg(text.encode('utf-8'))
|
||||||
|
|
||||||
|
def test_reader_never_requests_unbounded_input(self):
|
||||||
|
class BoundedReader(io.BytesIO):
|
||||||
|
requested = None
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
self.requested = size
|
||||||
|
if size < 0 or size > svg_xml.MAX_SVG_BYTES:
|
||||||
|
raise AssertionError('unbounded read')
|
||||||
|
return super().read(size)
|
||||||
|
|
||||||
|
stream = BoundedReader(b' ' * (svg_xml.MAX_SVG_BYTES * 2))
|
||||||
|
with patch.object(Path, 'open', return_value=stream):
|
||||||
|
with patch.object(svg_xml.ElementTree, 'fromstring') as parser:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
svg_xml.read_svg(Path('oversized.svg'))
|
||||||
|
parser.assert_not_called()
|
||||||
|
self.assertEqual(stream.requested, svg_xml.MAX_SVG_BYTES)
|
||||||
|
|
||||||
|
def test_file_reader_uses_same_hardened_parser(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'fixture.svg'
|
||||||
|
raw = b'<svg><title>ordinary</title></svg>'
|
||||||
|
path.write_bytes(raw)
|
||||||
|
read, root = svg_xml.read_svg(path)
|
||||||
|
self.assertEqual(read, raw)
|
||||||
|
self.assertEqual(root.find('title').text, 'ordinary')
|
||||||
|
path.write_bytes(b'<!DOCTYPE svg><svg/>')
|
||||||
|
with self.assertRaises(DTDForbidden):
|
||||||
|
svg_xml.read_svg(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Verify the generated SVGs and GitHub-compatible image link wrappers."""
|
"""Verify the generated SVGs and GitHub-compatible image link wrappers."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json,re,xml.etree.ElementTree as ET
|
import json,re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from readme_visuals_xml import read_svg,resolve_repo_file
|
||||||
ROOT=Path(__file__).resolve().parents[1];NS='{http://www.w3.org/2000/svg}'
|
ROOT=Path(__file__).resolve().parents[1];NS='{http://www.w3.org/2000/svg}'
|
||||||
manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text());readme=(ROOT/'README.md').read_text();total=0
|
manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text(encoding='utf-8'));readme=(ROOT/'README.md').read_text(encoding='utf-8');total=0
|
||||||
for asset in manifest['assets']:
|
for asset in manifest['assets']:
|
||||||
file=ROOT/asset['file'];raw=file.read_text();total+=file.stat().st_size;root=ET.fromstring(raw)
|
file=resolve_repo_file(ROOT,asset['file'],asset=True);data,root=read_svg(file);raw=data.decode('utf-8');total+=len(data)
|
||||||
assert root.tag==NS+'svg' and root.get('viewBox'),asset['file']
|
assert root.tag==NS+'svg' and root.get('viewBox'),asset['file']
|
||||||
assert root.find(NS+'title') is not None and root.find(NS+'desc') is not None
|
assert root.find(NS+'title') is not None and root.find(NS+'desc') is not None
|
||||||
ids=[e.get('id') for e in root.iter() if e.get('id')];assert len(ids)==len(set(ids))
|
ids=[e.get('id') for e in root.iter() if e.get('id')];assert len(ids)==len(set(ids))
|
||||||
@@ -19,11 +20,10 @@ for asset in manifest['assets']:
|
|||||||
target=asset['target'];parsed=urlparse(target)
|
target=asset['target'];parsed=urlparse(target)
|
||||||
if parsed.scheme:
|
if parsed.scheme:
|
||||||
assert parsed.scheme=='https' and parsed.hostname in ['cognitum.one','huggingface.co']
|
assert parsed.scheme=='https' and parsed.hostname in ['cognitum.one','huggingface.co']
|
||||||
else:assert (ROOT/target.split('#')[0]).is_file(),target
|
else:resolve_repo_file(ROOT,target.split('#')[0])
|
||||||
escaped=re.escape(asset['file']);destination=re.escape(target)
|
escaped=re.escape(asset['file']);destination=re.escape(target)
|
||||||
linked=bool(re.search(r'\[!\[[^\]]*\]\('+escaped+r'\)\]\('+destination+r'\)',readme))
|
linked=bool(re.search(r'\[!\[[^\]]*\]\('+escaped+r'\)\]\('+destination+r'\)',readme))
|
||||||
linked=linked or bool(re.search(r'<a href="'+destination+r'">\s*<img src="'+escaped+r'"',readme))
|
linked=linked or bool(re.search(r'<a href="'+destination+r'">\s*<img src="'+escaped+r'"',readme))
|
||||||
assert linked,('Missing clickable wrapper',asset['file'])
|
assert linked,('Missing clickable wrapper',asset['file'])
|
||||||
assert file.stat().st_size<20000,'Unexpected asset growth'
|
|
||||||
assert total<100000
|
assert total<100000
|
||||||
print(f'PASS: {len(manifest["assets"])} accessible animated SVGs, link targets and README wrappers; {total:,} bytes. No scripts, remote resources or SVG-internal links.')
|
print(f'PASS: {len(manifest["assets"])} accessible animated SVGs, link targets and README wrappers; {total:,} bytes. No scripts, remote resources or SVG-internal links.')
|
||||||
Reference in new issue
Block a user