Files
drouter/_dev/t-theme.py
T
liuzhuohua cd5ef7411f fix: 清「假输入框 / 隐身字段」这批前后端缺陷,版本号 1.0.4
把全量代码(后端 9 模块 + 前端单文件 SPA,约 4.5 万行)过了一遍,用 AST 抽出
渲染器读取的每个配置键,与页面上输入框绑定的字段做双向差集,专门找两类问题:
前端发了后端从不读(假输入框)、后端读了前端没有入口(隐身字段)。

会写坏系统的:
- render_network 违反 render() 契约返回字典,调用方按二元组解包后把字典的键
  当成路径和内容 —— 迁移 networkd 会生成文件名叫 name、内容写着 content 的垃圾文件
- DMZ 规则方向写反:注释写「排除到本机自身」,条件写成反面,管理口流量也一并 DNAT

五个「填了等于没填」的输入框:
- WAN 的 MTU 与「是否接受上游 DNS」后端零消费,现真正写入 MTUBytes= / UseDNS=
- DHCPv6 的「子网 ID」绑的是不存在的键,真生效字段是 sla_id
- Samba WINS 要求一个从不回填的字段同时为真,勾了永不生效
- 网桥「启用 STP」未绑定事件,勾完保存不进去
- 主题设计器「按主色生成配色」按钮无 handler

其余:
- RA 页补齐 rdnss_life / dnssl_life 输入框(1.0.3 修了错位却没给入口)
- 迁移预览只发 lan,必报「WAN 网卡不能为空」且网桥从不出现 → 补发 wan + bridge
- dnsmasq / miniupnpd / ppp / dhcpcd IPv6 / RA 地址池 / NFS 等插值零校验
- chrony 服务器校验只认域名和 IPv4,IPv6 NTP 服务器被误拒
- v_int 改用 try/except:上标数字能通过 isdigit() 却让 int() 抛异常导致 500
- ensure_cert 删掉硬编码的开发机 IP,改为本机地址 + 默认路由推导;补 openssl 失败检测
- DDNS 面板 XSS(记录名/服务商未转义);主题 CSS 注释注入(前后端各一处)

测试:render 返回值契约断言、CSS 注释注入回归、模板孤儿控件守卫,
_dev/t-*.py 42/42、前端三查 + md 链接检查全绿。
2026-10-02 00:23:30 +08:00

626 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
theme.py 纯逻辑回归测试(不需要目标机、不需要数据库、不需要 root)
运行:python3 router-build/_dev/t-theme.py
覆盖:
* 取值校验 valid_css_value(颜色 / 数值 / 阴影 / 渐变 / var() / 各种攻击串)
* 主题结构校验 validate_theme(字段、长度、白名单、必填)
* 自定义 CSS 安全检查 _check_extra_css(@ 规则、url()、注释、残缺语法)
* 渲染 theme_to_css
* 配色生成 make_palette / builtin_themes
* ZIP 打包 → 解包往返
* ZIP 异常:坏包、无 manifest、路径穿越、超大文件、非法 JSON、非法主题
* id 生成 slugify / theme_id_from_name
"""
import sys
import os
import io
import json
import base64
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(os.path.dirname(HERE), 'backend')
sys.path.insert(0, SRC)
import theme # noqa: E402
PASS = FAIL = 0
MSG = []
def has(name, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
else:
FAIL += 1
MSG.append(' ✗ %s%s' % (name, (' → ' + str(extra)) if extra else ''))
def chk(name, hay, needle, want=True):
has(name, (needle in hay) == want, hay[:160])
def expect_ok(name, r, want=True, needle=None):
"""断言主题校验结果 ok/not ok,可选的错误关键词"""
if isinstance(r, tuple):
ok_ = r[0]
detail = r[1] if len(r) > 1 else ''
else:
ok_, detail = True, r
has(name, ok_ == want, detail)
if isinstance(r, tuple) and not want and needle:
chk(name + ' (错误文案)', str(detail), needle)
# ==================================================================
print('== 1. valid_css_value:合法取值 ==')
# ==================================================================
GOOD = [
'#fff', '#FFF', '#ffff', '#1f6feb', '#1f6febcc',
'rgb(31,111,235)', 'rgba(31,111,235,.35)', 'rgba(0,0,0,0)',
'hsl(210,90%,55%)', 'hsla(210,90%,55%,0.5)',
'12px', '1rem', '0', '2.5em', '50%',
'transparent', 'currentColor', 'white', 'steelblue',
'var(--pri)', 'var(--pri, #fff)',
'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)',
'radial-gradient(circle,#fff,#000)',
'0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
'inset 0 1px 0 #fff',
]
for g in GOOD:
ok_, why = theme.valid_css_value(g)
has('合法取值 %r' % g, ok_, why)
print('== 2. valid_css_value:危险取值必须被拒 ==')
BAD = [
('#zzz', '非十六进制'),
('#12345', '5 位色'),
('rgb(300,0,0)', '超范围'),
('rgba(1,2,3)', '缺透明度第4位却写 rgba'),
('red;background:url(x)', '注入分号'),
('#fff}', '闭合花括号'),
('#fff{', '开花括号'),
('url(http://evil.com/x.css)', '外部资源'),
('expression(alert(1))', '表达式'),
('@import url(x)', 'at 规则'),
('#fff\\', '反斜杠'),
('/* */ #fff', '注释符号'),
('var(--not-opened)', '引用未开放变量'),
('var(', '残缺 var'),
('', '空值'),
(None, 'None'),
('x' * 300, '超长'),
('#fff !important', 'important'),
('12vh', '?'), # 12vh 本身合法(只对部分变量限制),这里用作对照
]
for b, why_desc in BAD:
if b == '12vh':
# 通用校验允许 vh(仅 --r/--side/--side-c 禁止)
ok_, why = theme.valid_css_value('12vh')
has('12vh 通用校验允许', ok_, why)
continue
# rgba(1,2,3) 应该被允许(rgb 别名,alpha 可选)
if b == 'rgba(1,2,3)':
ok_, why = theme.valid_css_value('rgba(1,2,3)')
has('rgba(1,2,3) 合法(alpha 可选)', ok_, why)
continue
ok_, why = theme.valid_css_value(b)
has('拒绝危险取值 %r(%s)' % (b, why_desc), not ok_, '通过了,这是漏洞!')
# 针对性:单位限制
for var in ('--r', '--side', '--side-c'):
ok_, _ = theme.valid_css_value('12vw', var)
has('%s 拒绝 vw' % var, not ok_)
ok_, _ = theme.valid_css_value('12px', var)
has('%s 接受 px' % var, ok_)
# ==================================================================
print('== 3. validate_theme:结构校验 ==')
# ==================================================================
ok_, t = theme.validate_theme({
'name': '测试主题', 'vars': {'--pri': '#ff0000', '--bg': '#f5f5f5'},
})
has('最小合法主题通过', ok_, t)
has('vars 被规范化', ok_ and t['vars']['--pri'] == '#ff0000')
has('默认 version', ok_ and t['version'] == '1.0')
# 缺名称
ok_, why = theme.validate_theme({'vars': {'--pri': '#fff'}})
has('缺 name 被拒', not ok_)
chk('缺 name 文案', why, '主题名称')
ok_, why = theme.validate_theme({'name': ''})
has('空 name 被拒', not ok_)
# 非法名称字符
ok_, why = theme.validate_theme({'name': 'bad<script>', 'vars': {'--pri': '#fff'}})
has('名称含尖括号被拒', not ok_)
# 允许的名称
for nm in ('我的主题', 'My Theme 2', '深海蓝-v2', '主题(夜色)', 'A.B_C-D'):
ok_, _ = theme.validate_theme({'name': nm, 'vars': {'--pri': '#fff'}})
has('合法名称 %r' % nm, ok_)
# 描述过长
ok_, why = theme.validate_theme({'name': 'x', 'description': '描' * 301,
'vars': {'--pri': '#fff'}})
has('超长描述被拒', not ok_)
chk('超长描述文案', why, '描述')
# vars 缺失
ok_, why = theme.validate_theme({'name': 'x'})
has('缺 vars 被拒', not ok_)
ok_, why = theme.validate_theme({'name': 'x', 'vars': []})
has('vars 非字典被拒', not ok_)
chk('vars 类型文案', why, '对象')
# 未知变量
ok_, why = theme.validate_theme({'name': 'x', 'vars': {'--evil': '#fff'}})
has('未知变量被拒', not ok_)
chk('未知变量文案', why, '不支持的变量')
# vars 全部非法值 → 空
ok_, why = theme.validate_theme({'name': 'x', 'vars': {'--pri': 'red;'}})
has('非法值被拦下', not ok_)
chk('非法值文案', why, '变量')
# 「至少 1 个变量」检查:vars 里有空串会被剔除
ok_, why = theme.validate_theme({'name': 'x', 'vars': {'--pri': ' '}})
has('vars 全空 → 拒绝', not ok_)
chk('空 vars 文案', why, '至少')
# 带 "on": false 的字典形式
ok_, t = theme.validate_theme({'name': 'x', 'vars': {
'--pri': {'value': '#123456', 'on': True},
'--bg': {'value': '#abcdef', 'on': False},
}})
has('dict 形式 vars 支持 on 开关', ok_ and '--bg' not in t['vars'] and '--pri' in t['vars'])
# 缺 -- 前缀自动补
ok_, t = theme.validate_theme({'name': 'x', 'vars': {'pri': '#123456'}})
has('自动补 -- 前缀', ok_ and '--pri' in t['vars'])
# dark 标记
ok_, t = theme.validate_theme({'name': 'x', 'vars': {'--pri': '#fff'}, 'dark': True})
has('dark 字段生效', ok_ and t['dark'] is True)
# 非 dict
ok_, _ = theme.validate_theme(['不是字典'])
has('顶层非 dict 被拒', not ok_)
# ==================================================================
print('== 4. _check_extra_css:自定义 CSS 安全 ==')
# ==================================================================
CSS_BAD = [
('@import url("http://evil.com")', ['@']),
('@media screen{.card{color:red}}', ['@']),
('.card{background:url(x.png)}', ['外部引用']),
('.card{background:expression(1)}', ['外部引用']),
('.card{background:javascript:alert(1)}', ['外部引用']),
('.card{color:red', ['语法']),
('.card{a;b:c}', ['冒号']),
('.card < > {color:red}', ['选择器']),
('.card{color:red}}', ['语法']),
('.card{color:red;}\\', ['转义']),
]
for css, needles in CSS_BAD:
bad, why = theme._check_extra_css(css)
has('CSS 拒绝:%r' % css[:34], bad, why)
for nd in needles:
chk('CSS 拒绝文案含 %r' % nd, why, nd)
CSS_GOOD = [
'.card{border-width:2px}',
'.stat .val{font-size:22px}',
'/* 带注释 */ .card{color:red}',
'.card{color:#fff;background:var(--pri)}',
'h3{letter-spacing:.5px}',
'.a{color:red}.b{color:blue}',
'.card{box-shadow:0 1px 2px rgba(0,0,0,.1)}',
'.card{content:"x"}',
]
for css in CSS_GOOD:
bad, why = theme._check_extra_css(css)
has('CSS 接受:%r' % css[:34], not bad, why)
# 注释里藏 @import 也不能过
bad, why = theme._check_extra_css('/* 先看这里 */ @import url(x);')
has('注释后紧跟 @import 被拒', bad, why)
# 通过 validate_theme 的 css 字段
ok_, _ = theme.validate_theme({'name': 'x', 'vars': {'--pri': '#fff'},
'css': '.card{border-width:2px}'})
has('主题带合法 css 通过', ok_)
ok_, why = theme.validate_theme({'name': 'x', 'vars': {'--pri': '#fff'},
'css': '@font-face{src:url(x)}'})
has('主题带 @font-face 被拒', not ok_)
chk('@font-face 文案', why, '@')
# css 超长
ok_, why = theme.validate_theme({'name': 'x', 'vars': {'--pri': '#fff'},
'css': '.a{color:red}' * 6000})
has('超长 css 被拒', not ok_)
chk('超长 css 文案', why, '过长')
# ==================================================================
print('== 5. theme_to_css:渲染 ==')
# ==================================================================
ok_, th = theme.validate_theme({
'name': '我的主题', 'version': '2.1', 'author': 'ajeef',
'description': '一句话', 'dark': True,
'vars': {'--pri': '#ff0000', '--bg': '#101010'},
'css': '.card{border-width:2px}',
})
css = theme.theme_to_css(th)
chk('渲染:含 :root', css, ':root{')
chk('渲染:含变量名', css, '--pri:#ff0000')
chk('渲染:含自定义 CSS', css, '.card{border-width:2px}')
chk('渲染:color-scheme dark', css, 'color-scheme:dark')
chk('渲染:主题名', css, '我的主题')
for bad_ch in ('<', '>', '&amp;'):
has('渲染输出不含 %r' % bad_ch, bad_ch not in css)
ok2, th2 = theme.validate_theme({'name': '浅色', 'vars': {'--pri': '#fff'}})
chk('渲染:color-scheme light', theme.theme_to_css(th2), 'color-scheme:light')
has('渲染:无 css 时不输出自定义段',
'作者自定义' not in theme.theme_to_css(th2))
# 变量顺序稳定
has('变量按名排序输出', css.index('--bg') < css.index('--pri'))
# ==================================================================
print('== 6. 配色生成 ==')
# ==================================================================
p = theme.make_palette('#1f6feb', False)
has('浅色配色含全部变量', len(p['vars']) == len(theme.THEME_VARS), len(p['vars']))
has('主色保持', p['vars']['--pri'] == '#1f6feb')
d = theme.make_palette('#1f6feb', True)
has('深色配色 --bg 更暗', d['vars']['--bg'] == '#111827', d['vars']['--bg'])
has('深色配色 --txt 更亮', d['vars']['--txt'] == '#e8eef8')
# 非法主色回落到默认
p2 = theme.make_palette('evil', False)
has('非法主色回落 #1f6feb', p2['vars']['--pri'] == '#1f6feb')
p3 = theme.make_palette(None, False)
has('None 主色回落', p3['vars']['--pri'] == '#1f6feb')
p4 = theme.make_palette('#GGGGGG', False)
has('乱码主色回落', p4['vars']['--pri'] == '#1f6feb')
# mix / tone 边界
has('mix 端点 t=0', theme.mix('#000000', '#ffffff', 0) == '#000000')
has('mix 端点 t=1', theme.mix('#000000', '#ffffff', 1) == '#ffffff')
has('mix 中点', theme.mix('#000000', '#ffffff', .5) == '#808080')
has('tone 提亮', theme.tone('#808080', .5) != '#808080')
# 内置主题
bt = theme.builtin_themes()
has('内置主题 6 个', len(bt) == 6, len(bt))
ids = [x['id'] for x in bt]
has('内置 id 唯一', len(set(ids)) == len(ids))
has('含 default', 'default' in ids)
for x in bt:
ok_, why = theme.validate_theme(x)
has('内置主题 %s 自身合法' % x['id'], ok_, why)
has('内置主题 %s 全部变量' % x['id'], len(x['vars']) == len(theme.THEME_VARS))
has('内置主题 %s builtin 标记' % x['id'], x['builtin'] is True)
# ==================================================================
print('== 7. ZIP 打包 → 解包往返 ==')
# ==================================================================
ok_, th = theme.validate_theme({
'name': '往返测试主题', 'author': 'tester', 'description': 'round trip',
'version': '3.0', 'dark': False,
'vars': {'--pri': '#3366cc', '--bg': '#fafafa', '--r': '14px'},
'css': '.card{border-width:2px}',
})
zipbytes = theme.pack_theme_zip(th)
has('打包返回字节', isinstance(zipbytes, bytes) and len(zipbytes) > 0)
has('打包非 None', zipbytes is not None)
zf = zipfile.ZipFile(io.BytesIO(zipbytes))
names = zf.namelist()
has('包含 theme.json', 'theme.json' in names, names)
has('包含 theme.css', 'theme.css' in names, names)
has('包含 README.txt', 'README.txt' in names, names)
man = json.loads(zf.read('theme.json').decode('utf-8'))
has('manifest schema', man.get('schema') == 'drouter.theme/1')
has('manifest name', man.get('name') == '往返测试主题')
has('manifest vars', man.get('vars', {}).get('--pri') == '#3366cc')
has('manifest exported_at', bool(man.get('exported_at')))
rd = zf.read('README.txt').decode('utf-8')
chk('README 含主题名', rd, '往返测试主题')
chk('README 含变量清单', rd, '--pri')
ok2, res = theme.unpack_theme_zip(zipbytes)
has('解包成功', ok2, res)
has('解包后名称一致', res['name'] == '往返测试主题')
has('解包后变量一致', res['vars']['--pri'] == '#3366cc')
has('解包后 round trip css 一致', res['css'] == '.card{border-width:2px}')
has('解包后 dark 一致', res['dark'] is False)
has('解包记录来源文件', res.get('imported_from') == 'theme.json')
# base64 通道(前端用)
b64 = base64.b64encode(zipbytes).decode('ascii')
ok3, res3 = theme.unpack_theme_zip(base64.b64decode(b64))
has('base64 往返成功', ok3, res3)
# ==================================================================
print('== 8. ZIP 异常必须给出中文原因 ==')
# ==================================================================
ok_, why = theme.unpack_theme_zip(b'')
has('空数据被拒', not ok_)
chk('空数据文案', why, '空')
ok_, why = theme.unpack_theme_zip(b'not a zip at all')
has('非 ZIP 被拒', not ok_)
chk('非 ZIP 文案', why, 'ZIP')
ok_, why = theme.unpack_theme_zip(b'x' * (4 * 1024 * 1024 + 10))
has('超 4MB 被拒', not ok_)
chk('超 4MB 文案', why, '过大')
# 缺 manifest
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('readme.txt', '没有主题清单')
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('缺 theme.json 被拒', not ok_)
chk('缺 manifest 文案', why, 'theme.json')
# 路径穿越
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', json.dumps({'name': 'x', 'vars': {'--pri': '#fff'}}))
z.writestr('../../etc/passwd', 'root:x:0:0')
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('目录穿越被拒', not ok_)
chk('目录穿越文案', why, '不安全')
# 绝对路径
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', json.dumps({'name': 'x', 'vars': {'--pri': '#fff'}}))
z.writestr('/tmp/evil.sh', 'rm -rf /')
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('绝对路径被拒', not ok_)
chk('绝对路径文案', why, '不安全')
# 单个文件过大
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', json.dumps({'name': 'x', 'vars': {'--pri': '#fff'}}))
z.writestr('big.bin', 'A' * (300 * 1024))
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('单文件过大被拒', not ok_)
chk('单文件文案', why, '过大')
# manifest 非法 JSON
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', '{不是合法 json')
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('非法 JSON 被拒', not ok_)
chk('非法 JSON 文案', why, 'JSON')
# manifest 内容非法(缺少 name)
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', json.dumps({'vars': {'--pri': '#fff'}}))
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('manifest 缺 name 被拒', not ok_)
chk('manifest 缺 name 文案', why, '主题名称')
# manifest 含非法变量值
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', json.dumps({'name': '恶意',
'vars': {'--pri': 'red;background:url(http://evil)'}}))
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('恶意取值在导入时被拒', not ok_)
chk('恶意取值文案', why, '变量')
# manifest 非 UTF-8(用 latin-1 写)
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', '{"name":"乱码主题"}'.encode('gbk'))
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('非 UTF-8 manifest 被拒', not ok_)
# 文件数量过多
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('theme.json', json.dumps({'name': 'x', 'vars': {'--pri': '#fff'}}))
for i in range(70):
z.writestr('f%d.txt' % i, 'x')
ok_, why = theme.unpack_theme_zip(buf.getvalue())
has('文件过多被拒', not ok_)
chk('文件过多文案', why, '过多')
# 别名的 manifest 名(theme.json 小写、manifest.json)
for alias in ('Theme.JSON', 'manifest.json'):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr(alias, json.dumps({'name': '别名主题', 'vars': {'--pri': '#fff'}}))
ok_, res = theme.unpack_theme_zip(buf.getvalue())
has('接受别名清单 %s' % alias, ok_, why if not ok_ else res)
# ==================================================================
print('== 9. id / slug 工具 ==')
# ==================================================================
has('slugify 中文名', theme.slugify('我的主题') == '我的主题')
has('slugify 去符号', theme.slugify('My Theme!!') == 'my-theme')
has('slugify 空回落', theme.slugify('') == 'theme')
has('slugify 纯符号回落', theme.slugify('!!!') == 'theme')
has('slugify 合并连字符', theme.slugify('a---b') == 'a-b')
has('slugify 去首尾连字符', theme.slugify('-abc-') == 'abc')
i1 = theme.theme_id_from_name('我的主题', ())
has('id 由中文名生成', i1 in ('theme',) or bool(i1), i1)
has('id 符合规范', bool(theme._RE_ID.match(i1)), i1)
has('id 冲突时加序号', theme.theme_id_from_name('abc', ('abc',)) == 'abc-2')
has('id 多次冲突', theme.theme_id_from_name('abc', ('abc', 'abc-2')) == 'abc-3')
has('id 含 uppercase 被规整', theme.theme_id_from_name('MyTheme', ()) == 'mytheme')
has('id 纯符号回落 theme', theme.theme_id_from_name('!!!', ()) in ('theme', 'theme-2'))
# ==================================================================
print('== 10. summarize 精简结构 ==')
# ==================================================================
s = theme.summarize(th)
has('summary 有 id 字段', 'id' in s)
has('summary 有 name', s['name'] == '往返测试主题')
has('summary 变量计数', s['vars_count'] == 3, s.get('vars_count'))
has('summary has_css', s['has_css'] is True)
has('summary 有 swatch', len(s['swatch']) == 4)
has('summary 取主色', s['swatch'][0] == '#3366cc')
has('summary 不含原始 css', 'css' not in s)
s2 = theme.summarize(th2)
has('无 css 时 has_css=False', s2['has_css'] is False)
# ==================================================================
print('== 11b. 可读性保证:任意主色生成的配色都必须达 WCAG AA ==')
# ==================================================================
# 用户可以在设计器里挑任意主色,自动生成的派生色必须仍然可读,
# 否则浅色主色(如暖橙、亮黄)会让次要文字糊成一片。
SWEEP = [
'#1f6feb', '#f97316', '#ffff00', '#eeeeee', '#000000', '#ffffff',
'#10b981', '#e11d48', '#7c3aed', '#0ea5e9', '#84cc16', '#fbbf24',
'#ff0000', '#00ff00', '#0000ff', '#f0f0f0', '#101010', '#808080',
'#ff69b4', '#00ced1', '#ffd700', '#adff2f', '#4b0082', '#ffdead',
]
worst = {'v': 99, 'who': ''}
for seed in SWEEP:
for dark in (False, True):
pal = theme.make_palette(seed, dark)
v = pal['vars']
bg, panel = v['--bg'], v['--panel']
checks = [
('主文字/背景', v['--txt'], bg, 7.0),
('主文字/卡片', v['--txt'], panel, 7.0),
('次要文字/背景', v['--txt2'], bg, 5.5),
('次要文字/卡片', v['--txt2'], panel, 5.5),
('弱化文字/背景', v['--txt3'], bg, 4.5),
('弱化文字/卡片', v['--txt3'], panel, 4.5),
('主色文字/浅底', v['--pri-text'], v['--pri-l'], 4.5),
]
for label, fg, bgc, need in checks:
got = theme.contrast(fg, bgc)
has('%s %s %s ≥ %.1f' % (seed, '深' if dark else '浅', label, need),
got >= need - 0.01, '%.2f' % got)
if got < worst['v']:
worst = {'v': got, 'who': '%s %s %s=%.2f' % (seed, dark, label, got)}
# 层次感:三级文字不能被拉平到同一亮度,否则界面失去重点层级
has('%s %s 三级文字保留层次' % (seed, '深' if dark else '浅'),
theme.contrast(v['--txt'], panel) > theme.contrast(v['--txt2'], panel)
and theme.contrast(v['--txt2'], panel) > theme.contrast(v['--txt3'], panel),
'%.2f > %.2f > %.2f' % (theme.contrast(v['--txt'], panel),
theme.contrast(v['--txt2'], panel),
theme.contrast(v['--txt3'], panel)))
has('最低对比度达标 4.5+', worst['v'] >= 4.4, worst['who'])
# ensure_contrast 本身的行为
has('ensure_contrast 已达标时不动', theme.ensure_contrast('#000000', '#ffffff') == '#000000')
has('ensure_contrast 会加深浅色',
theme.ensure_contrast('#cccccc', '#ffffff', 4.5) != '#cccccc')
has('ensure_contrast 深色则提亮',
theme.ensure_contrast('#333333', '#000000', 4.5) != '#333333')
has('contrast 黑白=21', abs(theme.contrast('#000000', '#ffffff') - 21) < 0.01)
has('contrast 同色=1', abs(theme.contrast('#808080', '#808080') - 1) < 0.01)
# ==================================================================
print('== 11c. 内置主题也必须达标(硬编码 vars 不走 make_palette 兜底)==')
# ==================================================================
_builtins = theme.builtin_themes()
has('内置主题 6 个', len(_builtins) == 6, len(_builtins))
for _t in _builtins:
_v = _t['vars']
for _lb, _fg, _b, _need in [
('主文字/背景', _v.get('--txt'), _v.get('--bg'), 7.0),
('主文字/卡片', _v.get('--txt'), _v.get('--panel'), 7.0),
('次要文字/背景', _v.get('--txt2'), _v.get('--bg'), 5.5),
('次要文字/卡片', _v.get('--txt2'), _v.get('--panel'), 5.5),
('弱化文字/背景', _v.get('--txt3'), _v.get('--bg'), 4.5),
('弱化文字/卡片', _v.get('--txt3'), _v.get('--panel'), 4.5),
('主色文字/浅底', _v.get('--pri-text'), _v.get('--pri-l'), 4.5),
]:
if not (_fg and _b):
continue
_got = theme.contrast(_fg, _b)
has('内置 %s %s ≥ %.1f' % (_t['id'], _lb, _need),
_got >= _need - 0.01, '%.2f' % _got)
# ==================================================================
print('== 11. 常量一致性 ==')
# ==================================================================
has('THEME_VARS 26 项', len(theme.THEME_VARS) == 26, len(theme.THEME_VARS))
for k in theme.THEME_VARS:
chk('变量 %s 有中文说明' % k, theme.THEME_VAR_CN.get(k, ''), k, want=False)
has('变量 %s 有说明文本' % k, bool(theme.THEME_VAR_CN.get(k)))
cats = set(theme.THEME_VARS.values())
has('分类集合一致', cats == set(dict(theme.THEME_CATS).keys()), cats)
has('THEME_CATS 有中文名', all(b for a, b in theme.THEME_CATS))
# 每个变量都能通过默认值的校验
defaults = theme.make_palette('#1f6feb', False)['vars']
has('默认浅色配色变量数', len(defaults) == len(theme.THEME_VARS))
for k, v in defaults.items():
ok_, why = theme.valid_css_value(v, k)
has('默认值 %s=%r 合法' % (k, v), ok_, why)
darkdef = theme.make_palette('#1f6feb', True)['vars']
for k, v in darkdef.items():
ok_, why = theme.valid_css_value(v, k)
has('深色默认值 %s=%r 合法' % (k, v), ok_, why)
# 内置主题自动生成的 CSS 也要能解出合法结构
for x in bt:
css = theme.theme_to_css(x)
has('内置 %s 渲染非空' % x['id'], len(css) > 200)
has('内置 %s 无 < 字符' % x['id'], '<' not in css)
print()
print('== 12. 主题 meta 不得把内容注出 CSS 注释 ==')
def _strip_comments(css):
"""按 CSS 规范扫注释,返回注释之外的「有效 CSS」。"""
out, i, n = [], 0, len(css)
while i < n:
if css.startswith('/*', i):
j = css.find('*/', i + 2)
if j < 0:
break # 未闭合:剩余全是注释
i = j + 2
else:
out.append(css[i])
i += 1
return ''.join(out)
_META_ATTACKS = [
('名称里塞规则体', {'name': 'x*/}body{display:none}/*', 'version': '1.0'}, 'body{display:none}'),
('版本号里塞 @import', {'name': 'a', 'version': '1*/@import url(evil)/*'}, '@import'),
('换行 + 提前闭合', {'name': 'n\n*/}\n*{background:url(//x)}\n/*', 'version': '2'}, 'background:url'),
('中文混合注入', {'name': '中*/}html{filter:invert(1)}/*文', 'version': ''}, 'filter:invert'),
]
for _label, _meta, _needle in _META_ATTACKS:
_t = dict(_meta, vars={'--pri': '#111'}, css='', dark=False)
_eff = _strip_comments(theme.theme_to_css(_t))
has('%s → 攻击串留在注释内' % _label, _needle not in _eff, _eff[:80])
has('%s → 有效结构仍然完好' % _label,
'--pri:#111' in _eff and 'color-scheme:light' in _eff, _eff[:80])
print()
print('=' * 56)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
if MSG:
print('-' * 56)
for m in MSG[:60]:
print(m)
print('=' * 56)
sys.exit(1 if FAIL else 0)