#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""反向验证:把每条关键判据对应的实现改坏,确认判据真的会红。
这是防「恒绿假阳性」的关键一步 —— 判据自己也要被验。
每个用例:注入 → 跑判据 → 必须因为**这一条**而红 → 还原 → 恢复绿。
⚠️ 注入点必须打在**语义层**,不能打在被检查的那个名字上。
早先踩过:判据写 `'_sha256' in u`,反向验证就去删 `_sha256` 函数
定义 —— 而正确做法是判据查「真的拿 want 跟 actual 比」,
注入点也要跟着改成「把 want 改成 None」。
"""
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = sys.executable
JUDGE = os.path.join(ROOT, '_dev', 't-update.py')
# (说明, 目标文件, 旧串, 新串, 期望变红的判据关键词, 是否替换全部出现)
# ⚠️ 第 6 个字段默认 False(只替换第一处)。**判据查的是「文件里任何一处
# 存在某串」时,必须设 True 全替换** —— 只改第一处,文件里还剩 N-1 处,
# 判据照样绿。反向验证第一版栽在这:`${esc(d.local)}` 有 4 处,
# 只改第 1 处 → 判据仍绿 → 误判成「判据不灵」,其实是注入不彻底。
CASES = [
# 1.0.10 改成 DL_SOURCES 降级链(大陆网络实测 github.com 直连
# 100% 丢包,「下载只走官方」等于一键更新永远不可用)。
# 所以「混入代理」不再是红线,取而代之的是「必须走降级链且逐步记录来源」。
('下载源链里官方被摘掉(会直接用镜像,失去官方优先)',
'backend/drouter-update.py',
" ('GitHub 官方', 'https://github.com/{repo}/releases/download/"
"v{ver}/{name}'),",
" ('占位', 'https://example.invalid/{repo}/{ver}/{name}'),",
'下载源官方排第一'),
# 注入:把**镜像源那一项整条删掉** —— 只留官方。
# ⚠️ 不能只在 `DL_SOURCES = [` 后面加注释:判据数的是 `https://` 出现次数,
# 加注释不影响数量 → 注入不生效、判据不红(我第一版就这么写的,白跑一轮)。
('降级链被写死成只用官方(大陆网络不可用)',
'backend/drouter-update.py',
(" ('ghproxy 镜像', 'https://gh-proxy.com/https://github.com/{repo}'\n"
" '/releases/download/v{ver}/{name}'),\n", ""),
None,
'下载源 ≥ 2 个'),
('把校验结果改成 None(等于不校验)',
'backend/drouter-update.py',
" want = sums.get(target['name'])",
" want = None",
'有 SHA256 校验'),
('SSL 校验失败就降级(默许 MITM)',
'backend/drouter-update.py',
" return ssl.create_default_context()",
" c = ssl.create_default_context()\n"
" c.check_hostname = False\n"
" c.verify_mode = ssl.CERT_NONE\n"
" return c",
'SSL 校验失败不降级'),
('下载不可中断',
'backend/drouter-update.py',
" if _stop_flag.is_set():\n"
" raise InterruptedError('用户已取消')",
" pass",
'下载可中断'),
('版本比较改回字符串语义(1.0.10 < 1.0.9 的坑)',
'backend/drouter-update.py',
" return a > b",
" return '%s' % (a,) > '%s' % (b,)",
'版本比较用元组而非字符串'),
('先下载后备份(顺序反了)',
'backend/drouter-update.py',
" ok, bmsg = _do_backup()",
" ok, bmsg = (True, '')",
'先备份后下载'),
# ⚠️ 注入点必须是**真实的包管理调用**,不能改那句 docstring 文案 ——
# 判据查的是「apply_update 函数体内有无 apt/dpkg 调用」,
# 只改文案它照样绿。第一版就栽在这,误判成「判据不灵」。
# (锚点是 run 里加的那行 subprocess,不含中文,避免编码问题)
('自动安装(越过用户决定)',
'backend/drouter-update.py',
" _stop_flag.clear()\n"
" _write_state(running=True,",
" subprocess.run(['apt-get', 'install', '-y', 'drouter'])\n"
" _stop_flag.clear()\n"
" _write_state(running=True,",
'不自动安装(apply_update 内无任何包管理调用)'),
('用 import 加载带连字符的模块(必 ModuleNotFoundError)',
'backend/drouter-web.py',
" U = self._load_mod('drouter-update')",
" import drouter_update as U",
'web.py 用 importlib'),
# backupd call() 去掉 sudo 前缀:drouter 身份(更新前备份)写不进
# /opt/drouter/backups → [Errno 13] Permission denied(2026-10-10 真机修)。
('backupd call() 去掉 sudo(drouter 身份写不进备份目录)',
'backend/drouter-backupd.py',
" ['sudo', '-n', '/usr/bin/python3', HELPER, action,",
" ['/usr/bin/python3', HELPER, action,",
'backupd call() 用 sudo -n 提权'),
('/api/update 路由被摘掉',
'backend/drouter-web.py',
" if p.startswith('/api/update'):",
" if False:",
'web.py 接上 /api/update'),
# —— 已废除的 passwall 残留(用户要求彻底清除)——
('passwall 路由复活',
'backend/drouter-web.py',
" # ========== Web 终端(#18) ==========",
" if p.startswith('/api/pw'):\n"
" if not self.auth():\n"
" return\n"
" return self.json({'ok': False})\n"
" # ========== Web 终端(#18) ==========",
'web.py 不含 passwall 路由'),
('update.js 不再展示本地版本(4 处全替换)',
'web/update.js',
"${esc(d.local)}",
"无",
'update.js 展示本地版本(模板里渲染'),
# ⚠️ 锚点要写**当前源码里的原文**。i18n 之后文案被包成了 ${t('中断')},
# 旧锚点(裸 '中断')再也匹配不上 → 注入静默跳过 → 反向验证
# 「看起来跑过」,其实这条判据根本没被验(2026-10-08 实测)。
# 判据本身走 read(),会剥掉 t() 包装,所以只影响**注入锚点**。
('update.js 去掉中断按钮',
'web/update.js',
'id="upd-cancel-task"',
'id="upd-cancel-task-gone"',
'update.js 有中断按钮(查元素本身'),
('update.js 去掉备份说明',
'web/update.js',
"
${t('先自动备份')}${t(':调用面板自带的备份能力,把当前配置存一份。 备份不成功就')}"
"${t('直接中止更新')}${t(',不会带着风险往下走。')}",
'${t(\'随便更新一下。\')}',
'update.js 措辞提到备份(弹窗说明里'),
('update.css 引用不存在的变量且无 fallback',
'web/update.css',
'.upd-err { color:var(--txt3); font-size:12px;',
'.upd-err { color:var(--no-such-var); font-size:12px;',
'无未定义变量'),
# ↓ 下面 6 个是 1.0.10 审查发现并修掉的 P0/P1,
# 注入点打在**语义层**,验证新加的防线判据真的灵敏
('P0-1 模块缓存退化成每次重新加载(中断将失效)',
'backend/drouter-web.py',
" hit = _MOD_CACHE.get(name)\n"
" if hit and hit[0] == mt:\n"
" return hit[1]",
" pass",
'_load_mod 真的查了缓存'),
('P0-2 running 写入移出锁外(并发可起两个 worker)',
'backend/drouter-update.py',
" if read_state().get('running'):\n"
" return {'ok': False, 'msg_cn': '已有更新任务在进行中'}\n"
" _stop_flag.clear()\n"
" _write_state(running=True,",
" if read_state().get('running'):\n"
" return {'ok': False, 'msg_cn': '已有更新任务在进行中'}\n"
" _stop_flag.clear()\n"
" _write_state(running=True,",
'写 running 与检查在同一缩进层'),
('P0-3 备份只信 returncode(什么都没备份也报成功)',
'backend/drouter-update.py',
" if r.returncode == 0 and 'BK_CREATED' in out:",
" if r.returncode == 0:",
'_do_backup 校验 BK_CREATED'),
('P0-4 apply 白名单移到写库之后(数据已落库才报错)',
'backend/drouter-web.py',
" if module not in APPLY_MODULES:\n"
" return self.json({'ok': False, 'code': 'BADMODULE',\n"
" 'msg_cn': '未知配置模块'})\n"
" # 预检绝不写配置库",
" # 预检绝不写配置库",
'apply_module 的白名单检查在写库之前'),
('P1-8 holds 用 .get("data", {}) 导致 None 上再 .get() → 500',
'backend/drouter-web.py',
"out['holds'] = (helper('pkg', {'op': 'holds'}).get('data')\n"
" or {}).get('holds', [])",
"out['holds'] = helper('pkg', {'op': 'holds'}).get('data', {}).get('holds', [])",
'holds 读取用 or {} 兜底'),
('P1-2 _verify 的清理移出 finally(chrony 临时文件永久残留 /etc)',
'backend/drouter-helper.py',
" finally:\n"
" # 这里才是「任何路径都一定会走到」的位置\n"
" try:\n"
" os.remove(tmp)\n"
" except Exception:\n"
" pass",
" if False:\n pass",
'_verify 的临时文件清理在 finally 里'),
('P1-3 _verify 临时文件名退回固定(并发互相覆盖)',
'backend/drouter-helper.py',
" tmp = '%s.%d.%d.tmp' % (_vt, os.getpid(), _tid)",
" tmp = _vt",
'_verify 临时文件名唯一'),
# ↓ 1.0.10 下载源降级链的防线
# 注入:把降级链的 for 循环改成空 —— 等于永远只用第一个源,
# 且一旦第一个源失败就直接 return False(没有任何降级)。
('下载不再走降级链(改回硬编码单个 URL)',
'backend/drouter-update.py',
" for src_name, tmpl in DL_SOURCES:",
" for src_name, tmpl in []:",
'下载走降级链函数'),
('清单不再走降级链(降级下载将无法校验)',
'backend/drouter-update.py',
" for src_name, tmpl in DL_SOURCES:\n"
" url = tmpl.format(repo=REPO, ver=ver.lstrip('v'), name=name)",
" for src_name, tmpl in []:\n"
" url = tmpl.format(repo=REPO, ver=ver.lstrip('v'), name=name)",
'清单也走降级链'),
('状态里不再记录用的源(前端无法显示)',
'backend/drouter-update.py',
" _write_state(src=src, url=info,\n"
" msg_cn='正在从 %s 下载…' % src)",
" _write_state(url=info,",
'状态里记录实际用的源'),
# 注入:把**整个镜像分支删掉**(含 tag warn 那个 span)。
# ⚠️ 只把 `mirror` 改成 `false` 不够 —— tag warn 的字符串还在,
# 判据照样匹配得到(第一版就这么写的,白跑一轮)。
('前端不再显示下载源(用户不知道走的是镜像)',
'web/update.js',
# 2026-10-06:三段都包了 t(),锚点要跟着改成新形态
(" if (s.src) {\n"
" const mirror = /镜像|proxy/i.test(s.src);\n"
" meta += (mirror\n"
" ? `${t('下载源:')}${esc(updSrc(s.src))}`\n"
" + `${t(' 官方直连不通,已自动改用镜像;')}`\n"
" + t('文件完整性仍会按官方 SHA256 校验')\n"
" : `${t('下载源:')}${esc(updSrc(s.src))}`);\n"
" }\n", ""),
None,
'update.js 在进度区显示下载源'),
('P2-6 版本号校验退回前缀匹配(路径穿越)',
'backend/drouter-update.py',
" if not re.fullmatch(r'\\d+\\.\\d+(\\.\\d+)?([-+~][\\w.]+)?', ver):",
" if not re.match(r'^\\d+\\.\\d+', ver):",
None), # 这条由真跑套件覆盖,静态判据暂不查
# ↓ 1.0.10 用户反馈后新增的两组
# ⚠️ 判据查「bindForce 与 bindRetry **两处都有** toast」,
# 注入也必须**两处都删**。
# ⚠️⚠️ 元组的格式是「**若干个 (锚点, 替换) 对**」,
# 我第一版误写成「(old, new)」两个元素 —— 结果注入把 A2 当成了替换文本,
# 实际什么也没删,判据当然绿。手工删两处验证过:判据会如实变红(rc=1)。
('去掉「重新检查」的 toast 反馈(用户提的原问题回归)',
'web/update.js',
((" await renderCard();\n"
" // ③ toast 让用户确知结果 —— 这一条是这次改进的关键\n"
" toast(ok ? msg : msg, ok ? 'ok' : 'err', 4200);",
" await renderCard();\n void msgA;"),
(" toast(msg, ok ? 'ok' : 'err', 4200);",
" void msgB;")),
None, # old 是元组时 new 不使用
'重新检查与失败重试都有 toast 反馈'),
('去掉「检查中」提示(按钮文字会被 innerHTML 重建冲掉)',
'web/update.js',
# 2026-10-06:已包 t()
" if (w) w.textContent = t('正在连接 GitHub 查询最新版本…');",
" if (w) w.textContent = '';",
'重新检查有「进行中」提示'),
('去掉「上次检查」时间戳',
'web/update.js',
# 2026-10-06:已包 t()(形态是 ${t('上次检查:')}${esc(whenTxt)})
" ${t('上次检查:')}${esc(whenTxt)}",
"