做的什么:
1. 判决并修正 collect 结构: 不是「整体一次加密」, 而是
chunk1 / trajectory / chunk2 / sd **四段独立加密**, 前 3 段
空格 pad 到 24 字节对齐、sd 不 pad, 4 段 base64 直接拼接。
2. 按案例 §5.3 实现字符串+结构指纹的模块识别器, 把 37 个采集器
全部识别且跨构建唯一。
3. 修 reorder 的恒等映射 bug, 并补强断言。
4. 在线抓取全新样本作为唯一数据源, 清理探索性脚本。
为什么:
* 旧「整体加密 + \x00 补齐」与真机数据矛盾 —— 导致多轮实验
建立在结构错误的 collect 上(这正是此前反复"乱碰"的根因)。
* 模块 id 每份构建重排, 不能当跨构建锚点; 但模块的**代码**
(字符串集合、结构)不变 —— 这是案例指明的不变量。
* reorder 旧实现用「目标槽下标」切「源数组」, 顺序不同即全盘错位;
原测试只断言"是置换", 恒等函数也满足, 所以 bug 被放过了。
怎么验证:
* 四段结构(3 条独立证据, 其中一条不需密钥):
- 真机明文 3 处连续空格串 [+5,+10,+24], 且 pad == 24-len%24 全中
- 密文长度算术 480+96+416+56 = 1048 == 真实 1048 字符
- '=' 只出现在 1046/1048, 对应 sd 段 33B→补 7→40B
* 指纹: 4 份全新在线构建 37/37 识别、0 unknown、跨构建语义名集合
完全一致、单构建内 0 重名
* 配对正确性: 独立证据 37/37 一致; 负对照(清单下标 / 模块 id /
随机配对)均只有 2-3/37 —— 证明归一化没有过拟合
* 端到端重排 3/3 正确且非恒等
* selftest 全绿; 强断言自查: 旧实现过不了新断言
新增:
tools/tdc_modules.py 字符串+结构指纹识别(案例 §5.3)
scripts/fetch_online_samples.py 在线抓取全新 tdc.js
scripts/verify/*.py 7 个可复现的验证脚本
docs/tdc-案例.md 案例原文(看雪, 作者执着的猫)
修改:
tcaptcha/crypto.py +build_collect / split_collect_segments(四段)
tools/tdc_collectgen.py reorder 改为跨构建锚点对齐 + spec_from_seq
tools/tdc_layout.py +static_fingerprint / opcode_histogram
tools/tdc_program.py 修符号求值插值类型崩溃
tcaptcha/tests/test_collectgen.py 补「非恒等」强断言 + 在线新样本端到端
tcaptcha/client.py debug 目录改到系统临时目录(不再写仓库内 scratch/)
har/1ms_dianxuan/PROGRESS.md 新增已知 #41~#49, 显式修正被推翻的旧结论
.gitignore +scratch/ +har/online/
删除:
scratch/(含 10-11 遗留的一次性脚本, 已确认不可恢复后清理)
注意:
* 账本记录了一次自我纠错: 曾据案例 §5.5 误判 kind=2「不进 cd」,
实测(cd 38 槽、轨迹在第 10 槽)否定后已撤回。
* 仍未打通: 纯离线生成需要每个语义名的取值来源(已知 #38)。
181 lines
7.6 KiB
Python
181 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
按构建生成 collect 的回归测试(承 PROGRESS.md #34/#35/#36;AGENTS.md §5.9)。
|
||
|
||
固化的性质:
|
||
1. `outer_commas`: 只数**括号外**的逗号 —— 方括号内的逗号不构成顶层分隔
|
||
('[]' / '[360,360]' / '["zh-CN","zh"]' 拼回去仍是一个值);
|
||
2. `SlotSpec` 推算出的槽数 == 真机明文 cd 的实际槽数;
|
||
3. 重排是**置换**:值集合不变(排序后逐项相同);
|
||
4. 加密/解密**回环一致**(明文逐字节还原);
|
||
5. 槽数不匹配的样本**必须报错**,不能静默错位。
|
||
|
||
样本:
|
||
* tcaptcha/tests/fixtures/tdc_slotmap_case.json —— 合成用例(已知正确答案)
|
||
* har/online/<时间戳>/tdc_*.js —— **在线抓取的全新构建**(真机数据)
|
||
|
||
运行:
|
||
.venv/bin/python -m tcaptcha.tests.test_collectgen
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
if str(ROOT / "tools") not in sys.path:
|
||
sys.path.insert(0, str(ROOT / "tools"))
|
||
|
||
from tcaptcha.crypto import xtea_decrypt_bytes # noqa: E402
|
||
|
||
import tdc_collectgen as cg # noqa: E402
|
||
|
||
FIX = ROOT / "tcaptcha" / "tests" / "fixtures" / "tdc_slotmap_case.json"
|
||
# 固定密钥,仅用于回环自洽(不代表任何真实构建)
|
||
KEY = [1497711441, 1213874789, 1799903316, 1835232858]
|
||
|
||
_fails: list = []
|
||
|
||
|
||
def check(name: str, cond: bool, detail: str = "") -> None:
|
||
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
|
||
if not cond:
|
||
_fails.append(name)
|
||
|
||
|
||
def main() -> int:
|
||
if not FIX.exists():
|
||
print(f"缺少 fixture: {FIX}")
|
||
return 1
|
||
case = json.loads(FIX.read_text(encoding="utf-8"))
|
||
seq, kinds, v0s, sample_cd = case["seq"], case["kinds"], case["v0s"], case["sample_cd"]
|
||
print(f"fixture: 项 {len(seq)} / kind {len(kinds)} / v0 {len(v0s)} / 真机 cd {len(sample_cd)}")
|
||
|
||
print("\n[1] outer_commas: 只数括号外的逗号")
|
||
check("'[]' -> 0", cg.outer_commas("[]") == 0)
|
||
check("'[360,360]' -> 0", cg.outer_commas("[360,360]") == 0)
|
||
check("'[\"zh-CN\",\"zh\"]' -> 0", cg.outer_commas('["zh-CN","zh"]') == 0)
|
||
check("'641547489,1791732001' -> 1", cg.outer_commas("641547489,1791732001") == 1)
|
||
check("'a,b,c' -> 2", cg.outer_commas("a,b,c") == 2)
|
||
|
||
print("\n[2] 槽数推算 == 真机明文槽数")
|
||
spec = cg.SlotSpec(seq, kinds, v0s)
|
||
print(f" {spec.describe()}")
|
||
check("槽数一致", spec.n_slots == len(sample_cd),
|
||
f"{spec.n_slots} vs {len(sample_cd)}")
|
||
check("fixture 覆盖 kind=1", any(k == 1 for k in kinds))
|
||
check("fixture 覆盖 kind=2", any(k == 2 for k in kinds))
|
||
|
||
print("\n[3] 重排是置换(值集合不变)")
|
||
out = cg.reorder(sample_cd, spec)
|
||
key = lambda v: json.dumps(v, sort_keys=True, ensure_ascii=False)
|
||
check("长度不变", len(out) == len(sample_cd), f"{len(out)} vs {len(sample_cd)}")
|
||
check("值集合相同(排序后逐项相等)",
|
||
sorted(map(key, out)) == sorted(map(key, sample_cd)))
|
||
|
||
print("\n[4] 加密/解密回环")
|
||
plain = cg.build_plain(out)
|
||
b64 = cg.encrypt_plain(plain, KEY)
|
||
back = xtea_decrypt_bytes(base64.b64decode(b64), KEY).decode("latin1").rstrip("\x00").strip()
|
||
check("明文逐字节还原", back == plain)
|
||
back_obj = json.loads(back)
|
||
check("cd 还原", back_obj["cd"] == out)
|
||
check("sd 还原", back_obj["sd"] == {"od": "C", "ft": "qf_7Pf__H"})
|
||
|
||
print("\n[5] 槽数不匹配必须报错")
|
||
raised = False
|
||
try:
|
||
cg.reorder(sample_cd[:-1], spec) # src=None: 契约是"同序"
|
||
except ValueError:
|
||
raised = True
|
||
check("少一槽 -> ValueError", raised)
|
||
|
||
# ---------------------------------------------------------------- #
|
||
# [6] 强断言:重排**必须真的重排**(旧实现的 bug 就是被"是置换"放过的)
|
||
# ---------------------------------------------------------------- #
|
||
print("\n[6] reorder 不是恒等映射(抓旧 bug)")
|
||
print(" 旧实现的循环是 sample[slot:slot+n],其中 slot 是**目标**槽号,")
|
||
print(" 两份构建顺序不同时全盘错位;而'值集合不变'这个弱断言抓不住它。")
|
||
# 把清单顺序打乱,构造"另一份构建"
|
||
import random
|
||
idx = list(range(len(seq)))
|
||
random.Random(11).shuffle(idx)
|
||
seq2 = [seq[i] for i in idx]
|
||
kinds2 = [kinds[i] for i in idx]
|
||
v0s2 = [v0s[i] for i in idx]
|
||
# 给每项一个唯一语义名(真实场景由 tdc_modules 的指纹提供)
|
||
names1 = ["n%d" % i for i in range(len(seq))]
|
||
names2 = ["n%d" % i for i in idx]
|
||
src = cg.spec_from_seq(seq, kinds, names1, sample_cd=sample_cd, v0s=v0s)
|
||
dst = cg.spec_from_seq(seq2, kinds2, names2, v0s=v0s2)
|
||
# 正确答案:按 src 的 assign 把每项的值搬到目标顺序
|
||
truth: list = []
|
||
for i in idx:
|
||
_i, _m, sl, nn = src.assign[i]
|
||
truth.extend(sample_cd[sl:sl + nn])
|
||
try:
|
||
out2 = cg.reorder(sample_cd, dst, src)
|
||
check("重排结果 == 已知正确答案", out2 == truth)
|
||
check("重排结果 != 原样(证明真的重排了)", out2 != list(sample_cd))
|
||
except cg.UnmappedSlotsError as exc:
|
||
check("重排结果 == 已知正确答案", False, f"抛错: {str(exc)[:80]}")
|
||
|
||
# ---------------------------------------------------------------- #
|
||
# [7] 用在线抓取的**全新构建**做端到端重排(真机数据,非合成)
|
||
# ---------------------------------------------------------------- #
|
||
online = sorted((ROOT / "har" / "online").glob("*/tdc_*.js")) if (
|
||
ROOT / "har" / "online").exists() else []
|
||
print("\n[7] 在线新构建 + 语义名锚点的端到端重排")
|
||
if len(online) < 2:
|
||
print(" (跳过: 需要 ≥2 份 har/online/*/tdc_*.js;"
|
||
"用 scripts/fetch_online_samples.py --n 4 抓取)")
|
||
else:
|
||
try:
|
||
import tdc_modules
|
||
except Exception as exc:
|
||
check("导入 tdc_modules", False, str(exc)[:80])
|
||
online = []
|
||
if len(online) >= 2:
|
||
def names_of(f):
|
||
return [r["name"] for r in tdc_modules.analyze(f)]
|
||
na = names_of(online[0])
|
||
n = len(na)
|
||
check("新构建清单 37 项", n == 37, "实际 %d" % n)
|
||
check("语义名全部唯一(37/37)", len(set(na)) == n,
|
||
"唯一 %d/%d" % (len(set(na)), n))
|
||
check("无 unknown", not any(x == "unknown" for x in na))
|
||
# 给每个语义名一个假值,按 A 顺序排成 cd_A,重排到 B 顺序
|
||
fake = {nm: "V<%s>" % nm for nm in na}
|
||
cd_a = [fake[nm] for nm in na]
|
||
seqa = [r["module"] for r in tdc_modules.analyze(online[0])]
|
||
srca = cg.spec_from_seq(seqa, [None] * n, na, sample_cd=cd_a)
|
||
all_ok = True
|
||
for f in online[1:]:
|
||
nb = names_of(f)
|
||
seqb = [r["module"] for r in tdc_modules.analyze(f)]
|
||
dstb = cg.spec_from_seq(seqb, [None] * len(nb), nb)
|
||
truthb = [fake[nm] for nm in nb]
|
||
try:
|
||
got = cg.reorder(cd_a, dstb, srca)
|
||
good = got == truthb and got != cd_a
|
||
except Exception:
|
||
good = False
|
||
all_ok &= good
|
||
print(" %-28s 重排正确=%s" % (f.parent.name + "/" + f.name[:12], good))
|
||
check("全部新构建重排正确且非恒等", all_ok)
|
||
|
||
print(f"\n{'=' * 62}")
|
||
if _fails:
|
||
print(f"❌ collectgen 测试失败: {_fails}")
|
||
return 1
|
||
print("✅ collectgen 测试全部通过")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|