#!/usr/bin/env python3 # -*- coding: utf-8 -*- """判据:概述页「检测新版本 / 一键更新」。 (原名 t-update-passwall.py —— passwall 功能已按用户要求整体废除, 本文件随之重写并改名。保留下来的只有版本检测这一块。) 覆盖三类: A. 存在性与接线(模块在、路由接上、静态资源引对) B. 供应链安全(探测多源 + 下载官方优先/失败降级镜像 + 强制 SHA256) C. 前端接线与「不出现」约束 每条关键判据都在 t-update-inject.py 里做了双向验证。 """ import ast import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) P = lambda *a: os.path.join(ROOT, *a) fails = [] n = 0 def ck(desc, cond, extra=''): global n n += 1 if not cond: fails.append('%s %s' % (desc, extra)) print('FAIL %s %s' % (desc, extra)) else: print('ok %s' % desc) def read(p): """读源码,并把 t()/T() 的包装**剥掉**。 2026-10-07:前端补 i18n 后,界面文案从裸中文 '' 变成 '' 于是判据里 `'套用预设名称' in read(APP_JS)` 这类**裸中文匹配全部失效** (t-update 一次红 63 条)。剥掉包装后,判据关心的是 「代码里有没有这段中文」,与它是否被包 t() 无关 —— 这才是这些判据本来要表达的意思。 """ with open(p, encoding='utf-8') as f: src = f.read() # ${t('x')} / ${T('x')} → x ——**只在 x 含汉字时剥** # # ⛔ 两条边界,都踩过: # ① 不剥裸 t('x'):有判据查的是「这行用了 t('nd.stale')」这种 # **代码形态**,剥掉就永远找不到(第一次改时一起剥,红 3 条)。 # ② **只剥含汉字的**:`${t('ups.xxx')}` 这种 key 是 ASCII, # 剥成 `ups.xxx` 后 `aj.count("t('ups.")` 恒为 0 —— # 「概览页已抽成 t()」这条判据因此假红。 # 判据关心的是「中文文案有没有被抽出来」, # ASCII key 本来就该保持原样。 def _strip(m): return m.group(1) if re.search(r'[一-鿿]', m.group(1)) else m.group(0) src = re.sub(r"\$\{[tT]\('((?:[^'\\]|\\.)*)'\)\}", _strip, src) return src def code_only(src, lang='py'): """剥掉注释(**还剥 docstring**),只留真正会被执行的代码。 ⚠️ 为什么非剥不可:判据经常要写「⚠️ 不能写 XXX」这类说明性注释, 而注释里正写着那个被禁的字符串 —— 不剥会把注释当成违规,基线假红。 这个坑本轮踩了**三次**(import drouter_update / 硬编码路径 / __APP_VERSION__)。 ⚠️ Python 还要剥 docstring:只剥 # 的话,`apply_update` 的 docstring 里 写着「不自动安装 —— 安装要么走 apt」,于是「无 apt 调用」那条判据永远红。 docstring 用 ast 按行号区间精确删(不重排、不填充)。 JS 要剥的是 // 和 /* */ 两层 —— 字符串里的 URL(如 https://)不能误伤, 所以只做「行内 // 之后」和整块 /* */ 的粗略处理。 """ if lang == 'js': out, in_block = [], False for ln in src.splitlines(): if in_block: if '*/' in ln: ln = ln.split('*/', 1)[1] in_block = False else: continue if '/*' in ln: if '*/' in ln: ln = ln.split('/*', 1)[0] + ln.split('*/', 1)[1] else: ln = ln.split('/*', 1)[0] in_block = True # 行内 //:跳过 http:// 这类协议里的双斜杠 i = ln.find('//') if i >= 0 and ln[max(0, i - 1):i] != ':': ln = ln[:i] out.append(re.sub(r'/\*.*?\*/', '', ln)) return '\n'.join(out) lines = src.splitlines() try: import ast tree = ast.parse(src) except SyntaxError: tree = None drop = set() if tree is not None: for node in ast.walk(tree): if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): if ast.get_docstring(node, clean=False): first = node.body[0] drop.update(range(first.lineno, first.end_lineno + 1)) out = [] for i, ln in enumerate(lines, 1): if i in drop: continue out.append(re.sub(r'#.*$', '', ln)) return '\n'.join(out) print('=== A. 存在性与接线 ===') up_py = P('backend', 'drouter-update.py') web_py = P('backend', 'drouter-web.py') ck('drouter-update.py 存在', os.path.isfile(up_py)) ck('update.js 存在', os.path.isfile(P('web', 'update.js'))) ck('update.css 存在', os.path.isfile(P('web', 'update.css'))) # ⚠️ 必须 compile() 不是 ast.parse():后者抓不到「重复关键字参数」 # 这类语义期错误(1.0.10 实测:ast.parse 通过、import 时炸)。 for f in (up_py, web_py): raw = open(f, 'rb').read() rel = os.path.relpath(f, ROOT) try: compile(raw.decode('utf-8'), rel, 'exec') ck('%s compile 通过' % rel, True) except SyntaxError as e: ck('%s compile 通过' % rel, False, 'line %s: %s' % (e.lineno, e.msg)) ck('%s 换行符为 LF' % rel, raw.count(b'\r\n') == 0, 'CRLF=%d' % raw.count(b'\r\n')) w = read(web_py) ck('web.py 接上 /api/update', "p.startswith('/api/update')" in w) ck('web.py 实现 update_api', 'def update_api(' in w) ck('web.py 注入 sys.path', 'sys.path.insert(0, _HERE)' in w) ck('web.py 用 importlib 按路径加载(连字符文件名 import 不了)', 'spec_from_file_location' in w and not re.search(r'^\s*import\s+drouter_update\b', w, re.M), '(drouter-update.py 带连字符,import 必 ModuleNotFoundError)') print() print('=== B. 供应链安全(本块是本功能的核心红线) ===') u = read(up_py) uc = code_only(u) # hv2(helper 源码)与它的剥注释版在本段就要用, # 原先定义在 F2 段(后面)→ 这里用会 NameError。提到前面,F2 段仍可复用。 hv2 = read(P('backend', 'drouter-helper.py')) # —— 探测可多源 —— _pm = re.search(r'PROBES\s*=\s*\[(.*?)\n\]', u, re.S) ck('PROBES 定义存在', _pm is not None) if _pm: body = _pm.group(1) ck('探测源 ≥ 3 个', body.count('https://') >= 3, str(body.count('https://'))) _first = re.search(r"\(\s*'([^']+)'\s*,", body) ck('官方源排第一(能直连就不碰第三方)', _first is not None and '官方' in _first.group(1), '第一个源是:%s' % (_first.group(1) if _first else '?')) ck('探测源只有 2 元组(名称, URL)', not re.search(r',\s*(True|False)\s*\)', body), '第三字段已移除 —— 下载源改由 DL_SOURCES 独立管理') # ⚠️⚠️ 1.0.10 真实踩到:把 PROBES 从 3 元组改成 2 元组后, # 判据只查了「定义处是 2 元组」,**没查解包处** —— # `for name, tmpl, _downloadable in PROBES:` 照旧解包 3 个, # 真机一调 check() 就 `ValueError: not enough values to unpack`, # 表现为「版本检测永远失败」。 # 这条要查 drouter-update.py 的代码,而它在本段只有 uc(无注释)版本, # 所以下面直接用 uc —— 但解包处可能有 # 注释,所以两条都查。 ck('check() 按 2 元组解包 PROBES(与定义一致)', re.search(r'for\s+name,\s*tmpl\s+in\s+PROBES', uc) is not None and not re.search(r'for\s+name,\s*tmpl,\s*\w+\s+in\s+PROBES', uc), '(改元组长度时必须同步解包处,否则真机才炸)') # —— 下载:官方优先,失败降级到镜像(1.0.10 实测大陆网络必需)—— # ⚠️ 判据第一版查 `ASSET_TMPL` 且要求「不含任何代理域名」—— # 那条红线在大陆网络下等于「一键更新永远不可用」 # (实测 github.com TCP 100% 丢包)。现在改成查 DL_SOURCES 降级链。 _dm = re.search(r"DL_SOURCES\s*=\s*\[(.*?)\n\]", uc, re.S) ck('DL_SOURCES 下载源链存在', _dm is not None, '(DL_SOURCES 定义在 drouter-update.py,不在 helper —— ' '判据第一版查错了文件,查的是 hv2)') if _dm: _dl = _dm.group(1) ck('下载源 ≥ 2 个(官方 + 至少一个镜像)', _dl.count('https://') >= 2, str(_dl.count('https://'))) _dfirst = re.search(r"\(\s*'([^']+)'\s*,", _dl) ck('下载源官方排第一', _dfirst is not None and '官方' in _dfirst.group(1), '第一个下载源是:%s' % (_dfirst.group(1) if _dfirst else '?')) ck('下载走降级链函数而非硬编码 URL', 'def _download_with_fallback(' in uc and re.search(r'for\s+src_name,\s*tmpl\s+in\s+DL_SOURCES', uc) is not None) ck('降级只在连接层失败时发生(内容被篡改不降级)', # ⚠️⚠️ 判据第三版才写对,原因值得记: # ① 前两版查的是**中文注释文字**(「其他错误(如 404)换源也没用」), # 但 code_only() 最后有 `re.sub(r'#.*$', '', ln)` —— 它会把 # **# 之后全部删掉**(不只 docstring),所以查注释必然 False。 # 我前两次诊断时复刻 code_only **漏了这一步**,得出「匹配成功」 # 的错误结论,绕了很大一圈。 # ② 现在只查**代码**:连接层关键词 + continue 紧跟 return 结构。 (_i0 := uc.find('def _download_with_fallback')) >= 0 and (_fn := uc[_i0:_i0 + 3000]) and all(k in _fn for k in ("'timed out' in s", "'refused' in s", "'resolve' in s", "'network' in s")) and re.search(r'continue\b[\s\S]{0,200}?return False', _fn) and re.search(r"'timed out' in s[\s\S]{0,200}?continue", _fn), '(连接层失败 → continue 降级;其他错误 → return 不降级)') ck('清单也走降级链(否则降级下载无法校验)', re.search(r"def _fetch_manifest[\s\S]{0,900}?for\s+src_name,\s*tmpl\s+" r"in\s+DL_SOURCES", uc) is not None) # ⚠️ 判据第一版查 `'src':` 字面量,但实现是用 **关键字参数** 传的: # _write_state(..., src=src_name)。所以要查 src= 形式。 ck('状态里记录实际用的源(前端要显示)', # ⚠️ 判据第二版:原来还要求 `_write_state(src=src_name)` —— # 那是实现降级链**之前**的写法(预写状态),降级后改成用函数的 # 返回值 `src=src` 写在 worker 里。旧条件一直匹配不到, # 但因为 `and` 的第二个条件成立,判据偶尔会「碰巧对」一次, # 掩盖了问题。现在只查实际生效的那个。 re.search(r'_write_state\([^)]*src=src\b', uc, re.S) is not None, '(用 _write_state(src=src) 关键字参数传,不是字典字面量)') ck('校验结果里写明下载源', re.search(r'官方 SHA256(下载源:%s)', uc) is not None) ck('未校验时特别标明走了镜像', re.search(r'未做完整性校验\*\*[\s\S]{0,80}?下载源:%s', uc) is not None, '(文案跨两行,正则要放宽)') # —— 校验 —— ck('有 SHA256 校验(比对而非仅取清单)', "want = sums.get(target['name'])" in uc and "want.lower() != actual.lower()" in uc and 'actual = _sha256(dest)' in uc) ck('校验失败会删文件', re.search(r"校验不通过[\s\S]{0,400}?os\.remove\(dest\)", uc) is not None, '(删文件必须与「校验不通过」同段,否则可能删在别处 = 看似有其实没接上)') ck('拿不到清单时明说未校验', re.search(r'未做完整性校验', u) is not None) ck('SSL 校验失败不降级', 'create_default_context' in u and not re.search(r'CERT_NONE|check_hostname\s*=\s*False', uc), '(遇到证书错误就跳过验证 = 默许 MITM,与本模块的安全底线自相矛盾)') print() print('=== C. 行为约束 ===') # ⚠️ 必须比**调用点**位置,不是 `def _do_backup` 的定义位置 —— # 定义在文件靠后,调用在 _apply_worker 内部。拿定义位置去比会得出 # 「下载在备份之前」的反结论(判据第一版就这么错的)。 _w = uc.index('def _apply_worker') ck('版本比较用元组而非字符串', 'return a > b' in uc and 'parse_ver' in u, '(字符串比较会把 1.0.10 判成比 1.0.9 旧)') ck('探测有超时(不能无限等)', 'PROBE_TIMEOUT' in u) ck('检测结果有缓存 TTL', 'CHECK_TTL' in u) ck('下载可中断', '_stop_flag.is_set()' in u) # ⚠️ 半截文件的清理现在在 **finally** 里(1.0.10 修 P1-5 时挪的)。 # 原来只有 InterruptedError 分支删 .part,网络重置/磁盘满/超时都会留下 # 完整的 169MB 文件,4GB 机器失败三次就撑满根分区。所以判据也改了: # 不再查「某个 except 分支里有没有 remove」,而是查 finally 块。 ck('半截文件在 finally 里统一清理(覆盖所有失败路径)', re.search(r'finally:[\s\S]{0,400}?os\.remove\(dest \+ \'\.part\'\)', uc) is not None, '(必须在 finally,不能只在 InterruptedError 分支 —— ' '网络/磁盘错误同样会留下 .part)') ck('清理前先判断存在(避免异常)', re.search(r"os\.path\.exists\(dest \+ '\.part'\)", uc) is not None) # ⚠️ 下面三条曾写成「关键词在文件里存在」,反向验证时全部漏网: # 同一个关键词在文件里出现 N 处,注入只改 1 处 → 判据照样绿。 # 查「关键词存在」时,必须同时要求它出现在**正确的上下文**里。 ck('先备份后下载', uc.index('_do_backup()', _w) < uc.index("phase='download'", _w) and uc.index('_do_backup()', _w) > _w, '(备份调用必须在 _apply_worker 内、且在下载步骤之前)') ck('备份失败即中止(不带风险往下走)', re.search(r'备份失败[\s\S]{0,200}?中止', u) is not None) # ⚠️ 「不自动安装」的判据**不能查文案**(docstring/注释里都写着这句话, # 剥注释也剥不掉 docstring)。必须查**行为**: # apply_update 函数体内除了起下载线程,不许出现任何包管理调用。 # 反向验证第一版就是改那句文案,判据照样绿 —— 绿灯来自查错了对象。 _au = uc[uc.index('def apply_update'):uc.index('def _apply_worker')] ck('不自动安装(apply_update 内无任何包管理调用)', not re.search(r'\b(apt|apt-get|dpkg|dpkg-deb|install_offline|' r'aptitude|systemctl)\b', _au), '检测到包管理调用:%s' % re.findall(r'\b(?:apt|dpkg|systemctl)\b', _au)) ck('不自动安装(只起下载线程)', 'threading.Thread' in _au and '_worker.start()' in _au) ck('状态写文件供前端轮询', 'state.json' in u) ck('状态原子落盘(.part + replace)', "STATE_FILE + '.part'" in uc and 'os.replace' in uc) print() print('=== D. 前端接线 ===') idx = read(P('web', 'index.html')) ck('index.html 引入 update.js', 'update.js' in idx) ck('index.html 引入 update.css', 'update.css' in idx) ck('update.js 在 app.js 之后(要用 app.js 的 api()/esc)', idx.index('app.js') < idx.index('update.js')) aj = read(P('web', 'app.js')) ck('app.js 有版本卡片容器', 'id="upd-card"' in aj) ck('app.js 触发卡片渲染', 'drouterRenderUpdateCard' in aj) uj = read(P('web', 'update.js')) # 剥注释版源码:下面多处判据要查**代码**而不是注释 —— # 注释里出现的关键词会让判据假绿/假红(本项目已栽 5 次)。 # 这里统一算好,全文件复用。 _hvc = code_only(hv2, 'py') _us = code_only(read(P('web', 'upstream.js')), 'js') _ujc = code_only(uj, 'js') ck('update.js 导出渲染函数', 'drouterRenderUpdateCard' in uj) # ⚠️ 本地版本号**不能**从 __APP_VERSION__ 读(1.0.10 修的真 bug): # 后端只对 index.html 做字符串替换,app.js 是独立静态文件不经过注入 —— # 在 app.js 里写 __APP_VERSION__ 会直接 ReferenceError,概览页整页崩。 # 离线渲染检查器 devtools/check-render.js 抓到了这个。 # 现在统一从页脚 #app-ver 读(那是服务端已注入真实版本号的元素)。 ck('app.js 里没有裸用 __APP_VERSION__(该变量只存在于 index.html)', not re.search(r'\$\{esc\(__APP_VERSION__\)\}', aj) and not re.search(r'\besc\(__APP_VERSION__\)', aj), '(app.js 不经过后端注入,用它会 ReferenceError → 概览页崩)') ck('update.js 从页脚读取本地版本', re.search(r"getElementById\('app-ver'\)", uj) is not None) # ⚠️ 判据查「update.js 代码里没用 __APP_VERSION__」,**必须剥注释** —— # 那段注释正好解释了「为什么不能用它」,里面就写着这个变量名。 # 全文搜会把自己的注释当成违规(这个坑本轮第三次踩,见 helper 的 selfcheck)。 ck('update.js 代码里不用 __APP_VERSION__', '__APP_VERSION__' not in code_only(uj, 'js'), '(注释里可以提,代码里不能出现)') # ⚠️ 下面这几条曾写成「关键词在文件里存在」,反向验证**全部漏网**: # 同一关键词在 update.js 里出现 N 处(如 `d.local` 4 处、 # `upd-cancel-task` 3 处、`备份` 6 处),注入只改 1 处 → 判据照样绿。 # 现在改成查「该关键词出现在正确的元素/上下文里」。 ck('update.js 展示本地版本(模板里渲染 d.local)', re.search(r'\$\{esc\(d\.local\)\}', uj) is not None) ck('update.js 展示最新版本(仅在有更新时)', re.search(r'\$\{esc\(d\.latest\)\}', uj) is not None) ck('update.js 有中断按钮(查元素本身,不是 querySelector)', re.search(r']*id="upd-cancel-task"', uj) is not None) ck('update.js 进度条元素存在', re.search(r'id="upd-(bar|fill|prog)"', uj) is not None) ck('update.js 措辞提到备份(弹窗说明里的具体表述)', re.search(r'先自动备份', uj) is not None) ck('update.js 措辞提到可中断', re.search(r'随时可中断', uj) is not None) ck('update.js 措辞提到下载源的取舍', # ⚠️ 1.0.10 改了策略(大陆网络实测 github.com 直连 100% 丢包, # 「下载一律走官方」等于一键更新永远不可用),所以判据不能还查旧文案。 re.search(r'优先从 GitHub 官方下载', uj) is not None and re.search(r'自动改用', uj) is not None) ck('update.js 说明下载全程显示实际用的源', re.search(r'全程都会显示实际用的是哪一个', uj) is not None) ck('update.js 在进度区显示下载源', re.search(r'下载源:', uj) is not None) ck('镜像源会被醒目标注(tag warn)', re.search(r"tag warn\">下载源", uj) is not None or re.search(r"mirror[\s\S]{0,120}?tag warn", uj) is not None) ck('update.js 强调校验一步不省', re.search(r'校验完整性一步都不省', uj) is not None) ck('update.js 说明不自动安装', re.search(r'不会自动安装', uj) is not None) # ---- 2026-10-10 用户反馈:「只有重新检查,没看到升级按钮,放哪了?」---- # 根因:「查看更新内容并升级」只在 has_update 为真时渲染,而用户自己是 # 1.0.9、线上也是 1.0.9 → 走「已是最新」分支,那里没有升级入口, # 用户合理地怀疑「按钮是不是坏了 / 藏哪了」。 # 「看看新版本发布了什么」本来就不该依赖有没有更新 → 最新版也要有入口。 ck('「已是最新」分支也提供发布说明入口', # ⚠️ 判据第二版:原来要求「按钮 id 和 openReleaseNotes 之间不超过 200 字符」, # 实际中间隔着 5 行(含一行长的 whenTxt 模板),超了 → 假红。 # 改成分别存在即可。第三版又踩了一次:判据写 `querySelector('#upd-info')`, # 而实现用的是项目通用的 `$('#upd-info')` 简写 → 又假红。 # **判据里的写法必须和实现一致**,别想当然。 # 绑定的正确性由 check-render.js 兜(点不到会因 #un-close 缺失暴露)。 re.search(r'id="upd-info"', _ujc) is not None and re.search(r"\$\('#upd-info'\)", _ujc) is not None and re.search(r'ib\.onclick\s*=\s*\(\)\s*=>\s*openReleaseNotes', _ujc) is not None, '(用户是最新版时也必须能看到「这版是什么/从哪拿/怎么装」,' '否则会以为功能坏了)') ck('发布说明窗口存在且是只读的(不触发下载流程)', re.search(r'function openReleaseNotes', _ujc) is not None and re.search(r"id=\"un-close\"", _ujc) is not None) ck('发布说明里给出三种安装方式', re.search(r'apt install', _ujc) is not None and re.search(r'install-offline\.sh', _ujc) is not None and re.search(r'docker load', _ujc) is not None) ck('发布说明里写明下载源策略(官方优先 / 降级 / 校验)', re.search(r'function openReleaseNotes[\s\S]{0,4000}?官方 SHA256 校验', _ujc) is not None) ck('后端在无更新时也返回 assets(发布说明要用)', re.search(r"r\.get\('ok'\)\s+and\s+r\.get\('latest'\)", w) is not None and re.search(r"if\s+r\.get\('ok'\)\s+and\s+r\.get\('latest'\):", w) is not None, '(原先只在 has_update 时给 assets → 发布说明窗口里没有下载地址,' '而那恰是最新版用户最想看的)') print() print('=== F8. 实时柱状流动图(1.0.10)===') rt = read(P('web', 'realtime.js')) _rtc = code_only(rt, 'js') # 剥注释版 ck('realtime.js 存在且被 index.html 引入', 'realtime.js' in idx and idx.index('app.js') < idx.index('realtime.js')) ck('概览页有 realtime-box 容器', 'id="realtime-box"' in aj) ck('三块图都用 canvas(不是 DOM 条)', rt.count('\s*MAX_POINTS\s*\*\s*2", _rtc) is not None and re.search(r"hist\.series\.forEach[\s\S]{0,200}?" r"hist\.series\[i\]\s*=\s*arr\.filter", _rtc) is not None, '(降采样必须在所有 series push 完之后统一做)') ck('Y 轴保留下限(负载 0.05 不被放大成满格)', re.search(r'function yRange\(arr,\s*hardMin\)', _rtc) is not None and re.search(r'hardMin\s*\|\|\s*0', _rtc) is not None) ck('Y 轴上限留 15% 余量(峰值不顶格)', re.search(r'\*\s*1\.15', _rtc) is not None) ck('DPR 上限为 2(高 DPI 下不做超大 canvas)', re.search(r'DPR_CAP\s*=\s*2', _rtc) is not None) ck('值为 0 时不画 1px 假柱', re.search(r'v\s*>\s*0\s*\?\s*1\s*:\s*0', _rtc) is not None) ck('重传标注为累计值(免得被当成速率)', '累计' in _rtc and 'retrans' in _rtc) print() print('=== F9. i18n 框架(1.0.10 第 1 批)===') ij = read(P('web', 'i18n.js')) ck('i18n.js 存在且被 index.html 引入', 'i18n.js' in idx) def _script_at(name): """index.html 里