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
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r scripts/requirements-readme-visuals.txt
|
||||
python3 scripts/render-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
|
||||
|
||||
|
||||
@@ -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
|
||||
"""Generate the RuView README visual system. Deterministic, stdlib, no network."""
|
||||
"""Generate the RuView README visual system. Deterministic, no network."""
|
||||
from pathlib import Path
|
||||
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)
|
||||
C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5'
|
||||
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>'''
|
||||
manifest=[]
|
||||
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.
|
||||
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)
|
||||
@@ -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)
|
||||
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')
|
||||
(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')
|
||||
@@ -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
|
||||
"""Verify the generated SVGs and GitHub-compatible image link wrappers."""
|
||||
from pathlib import Path
|
||||
import json,re,xml.etree.ElementTree as ET
|
||||
import json,re
|
||||
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}'
|
||||
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']:
|
||||
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.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))
|
||||
@@ -19,11 +20,10 @@ for asset in manifest['assets']:
|
||||
target=asset['target'];parsed=urlparse(target)
|
||||
if parsed.scheme:
|
||||
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)
|
||||
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))
|
||||
assert linked,('Missing clickable wrapper',asset['file'])
|
||||
assert file.stat().st_size<20000,'Unexpected asset growth'
|
||||
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.')
|
||||
Reference in new issue
Block a user