合入期 5 验收修复 M3:可验证报告核对正文显示的文字与引用的值一致——文字取值逐字比对,数字按服务端的显示规则(千分位、小数位、百分数、万 / 亿换算、单位)核对,认不出的格式判无法核对而不是通过;紧挨数字的不可见字符、缩进挤出屏幕的列表一并判不通过

This commit is contained in:
ThearchyHelios
2026-10-12 11:42:06 +08:00
5 changed files with 1961 additions and 163 deletions
File diff suppressed because it is too large Load Diff
+83 -28
View File
@@ -23,6 +23,12 @@
快照随文件附带,数字核对得过)和选出了号码的那份会员消费(只给哈希)
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
- stray:报告自查记下的文档是另一次运行写的
- formats:经营月报,每种显示格式各引用一处:口径卡的千分位加货币单位、换算成万 / 亿、比率显示成百分数、负数、不分组、
小数位与 |.N / |int、pct;流水快照里的文字取值(地区)、DECIMAL 落成的数字文本、负数、换算成万、超过 2^53 的订单号、
ISO 日期时间(文档记下 2 版显示规则)。check-bundle 在它上面只改显示文字做伪造,tests/test_bundle_shown.py 在 node 里
逐个格式核一遍
- formats-unit:同经营月报,销售额的单位带尾随空白(「元 」:单位输入框不去空白,指标节点照存),正文显示「74,613.65元 」,
原样导出照样全部通过
"""
from __future__ import annotations
@@ -62,6 +68,34 @@ WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计*
"**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n"
"[[table:Q1 cols=store,amount rows=0-7]]")
MONTHLY = "## 门店月报\n\n本月销售额 [[m:gmv]],青石店流水 [[v:Q1.r0.amount]]。[[see:Q1]]"
#: 经营月报的口径卡:每个指标一种显示格式(千分位加货币单位、不分组、比率显示成百分数、负数、小数位、不写小数位)
FORMATS_CARD = {"kind": "metric_set", "caliber": "经营月报口径", "caliber_version": "v1", "metrics": [
{"id": "gmv", "name": "销售额", "value": 74613.65, "unit": "元", "decimals": 2, "format": "thousands"},
{"id": "annual", "name": "年度销售额", "value": 1234567890.5, "unit": "元", "decimals": 2, "format": "thousands"},
{"id": "refund", "name": "退款", "value": -3120.5, "unit": "元", "decimals": 2, "format": "thousands"},
{"id": "conv_rate", "name": "转化率", "value": 0.0235, "unit": "", "decimals": 4, "format": "percent_of_ratio"},
{"id": "growth", "name": "环比", "value": -0.0815, "unit": "", "decimals": 3, "format": "percent_of_ratio"},
{"id": "orders", "name": "订单数", "value": 12840, "unit": "单", "decimals": 0, "format": "plain"},
{"id": "avg", "name": "客单价", "value": 5.8113, "unit": "元", "decimals": 2, "format": "thousands"},
{"id": "share", "name": "线上占比", "value": 0.346, "unit": "", "decimals": None, "format": "thousands"},
{"id": "export", "name": "出口额", "value": 15230.4, "unit": "美元", "decimals": 1, "format": "thousands"},
]}
#: 同上,销售额的单位带尾随空白:服务端照原样接在数后面
FORMATS_UNIT_CARD = {**FORMATS_CARD, "metrics": [{**FORMATS_CARD["metrics"][0], "unit": "元 "},
*FORMATS_CARD["metrics"][1:]]}
#: 经营流水:地区是文字,金额一格是 DECIMAL 落成的数字文本、一格是负数,订单号超过 2^53,付款时间是 ISO 日期时间
LEDGER = {"columns": ["region", "gmv", "order_no", "paid_at"],
"column_types": {"region": "text", "gmv": "number", "order_no": "number", "paid_at": "datetime"},
"rows": [["华南", "45678.50", 202609071234567890, "2026-09-07T08:21:39"],
["华东", -1200.75, 202609071234567891, "2026-09-08T19:02:11"]],
"source": "shop", "sql": "SELECT region, gmv, order_no, paid_at FROM ledger ORDER BY region DESC",
"truncated": False}
FORMATS = ("## 经营月报\n\n本月销售额 [[m:gmv]](约 [[m:gmv|万]]),年度累计 [[m:annual|亿]],退款 [[m:refund]]。"
"转化率 [[m:conv_rate]],环比 [[m:growth]],订单 [[m:orders]]。客单价 [[m:avg]],精确些是 [[m:avg|.3]],"
"取整是 [[m:avg|int]];线上占比 [[m:share|pct]],出口额 [[m:export]]。\n\n"
"[[v:Q1.r0.region]]流水 [[v:Q1.r0.gmv]](约 [[v:Q1.r0.gmv|万]]),[[v:Q1.r1.region]]冲减 [[v:Q1.r1.gmv]]。"
"最大一单的订单号 [[v:Q1.r0.order_no]],付款时间 [[v:Q1.r0.paid_at]]。[[see:Q1]]")
#: 会员周报:Q3 是会员按地区汇总(没碰遮罩列),Q2 是选出了号码的会员消费
MEMBERS = ("## 会员周报\n\n华东会员消费 [[v:Q3.r0.spend]],华南 [[v:Q3.r1.spend]]。[[see:Q3]]\n\n"
"会员林一消费 [[v:Q2.r0.spend]]。")
@@ -94,18 +128,22 @@ def masked_card(crm_id: str) -> dict[str, Any]:
class Sample:
"""一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。masked=True 时口径卡带着取自遮罩列的输入;
regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。"""
regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。card、table 换掉口径卡和门店流水的快照(经营月报);
inputs 是运行输入(报告里写 [[i:字段]] 引用)。"""
def __init__(self, *, nan: bool = False, masked: bool = False, regions: bool = False) -> None:
def __init__(self, *, nan: bool = False, masked: bool = False, regions: bool = False,
card: dict[str, Any] | None = None, table: dict[str, Any] | None = None,
inputs: dict[str, Any] | None = None) -> None:
self.files: dict[str, bytes] = {}
self.shop_id = self.put(shop(nan))
snapshot = table or shop(nan)
self.shop_id = self.put(snapshot)
self.crm_id = self.put(CRM)
self.card = masked_card(self.crm_id) if masked else CARD
self.card = card or (masked_card(self.crm_id) if masked else CARD)
self.card_id = self.put(self.card)
self.entries = {
"fetch": {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id,
"tool": "db_query__shop", "source": "shop", "columns": ["store", "amount", "ratio"],
"rows": len(STORES), "truncated": False},
"tool": "db_query__shop", "source": "shop", "columns": snapshot["columns"],
"rows": len(snapshot["rows"]), "truncated": False},
"members": {"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id,
"tool": "db_query__crm", "source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]),
"truncated": False},
@@ -116,7 +154,7 @@ class Sample:
"columns": REGIONS["columns"], "rows": len(REGIONS["rows"]), "truncated": False}
self.entries["card"] = {"kind": "metric_set", "node_id": "card", "artifact": self.card_id}
self.ledger = list(self.entries.values())
self.catalog = build_catalog(nodes={"card": self.card}, ledger=self.ledger)
self.catalog = build_catalog(nodes={"card": self.card}, ledger=self.ledger, inputs=inputs)
def put(self, obj: Any) -> str:
artifact, raw = stored(obj)
@@ -189,62 +227,79 @@ class Sample:
return rows, bound
def render(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *, run: dict[str, Any] | None = None,
legacy: bool = False, drop: tuple[str, ...] = (), source_masks: dict[str, list[str]] | None = None) -> str:
"""真实封存哈希的页面。drop:导出时服务端已没有文件的工件;source_masks:数据源现在设的遮罩。"""
def build(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *,
run: dict[str, Any] | None = None, legacy: bool = False, drop: tuple[str, ...] = (),
source_masks: dict[str, list[str]] | None = None) -> dict[str, Any]:
"""真实封存哈希的页面数据(bundle-data)。drop:导出时服务端已没有文件的工件;source_masks:数据源现在设的遮罩。"""
sealed = [e for e in events if e[0] <= bound]
files = {k: v for k, v in sample.files.items() if k not in drop}
bundle = build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks=source_masks or {})
return render_html(bundle)
return build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks=source_masks or {})
def pages() -> dict[str, str]:
"""{页面名: HTML}"""
out: dict[str, str] = {}
def formats_bundle(*, card: dict[str, Any] = FORMATS_CARD, table: dict[str, Any] = LEDGER, text: str = FORMATS,
inputs: dict[str, Any] | None = None) -> dict[str, Any]:
"""经营月报(formats)的页面数据:每种显示格式各引用一处。tests/test_bundle_shown.py 换掉口径卡、流水、正文或者
加上运行输入,组装别的写法。"""
f = Sample(card=card, table=table, inputs=inputs)
events, bound = f.events({"write": f.doc(text, "write")})
return build(f, events, bound)
def bundles() -> dict[str, dict[str, Any]]:
"""{页面名: 页面数据}"""
out: dict[str, dict[str, Any]] = {}
s = Sample()
weekly = s.doc(WEEKLY, "write")
events, bound = s.events({"write": weekly})
out["base"] = render(s, events, bound)
out["missing-doc"] = render(s, events, bound, drop=(weekly,))
out["missing-card"] = render(s, events, bound, drop=(s.card_id,))
out["base"] = build(s, events, bound)
out["missing-doc"] = build(s, events, bound, drop=(weekly,))
out["missing-card"] = build(s, events, bound, drop=(s.card_id,))
# 早期运行:运行结束事件落库之前封存,范围到它前一条为止(runner.verify_manifest 的老口径)
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
out["legacy"] = render(s, events, finished - 1, legacy=True)
out["legacy"] = build(s, events, finished - 1, legacy=True)
monthly = s.doc(MONTHLY, "monthly")
events, bound = s.events({"write": weekly, "monthly": monthly})
out["two-reports"] = render(s, events, bound)
out["two-reports"] = build(s, events, bound)
events, bound = s.events({"write": weekly}, ending="failed")
out["failed"] = render(s, events, bound, run={**RUN, "status": "failed"})
out["failed"] = build(s, events, bound, run={**RUN, "status": "failed"})
# 早期运行先失败、经继续运行跑到成功:封存范围同样按结束事件推算,到成功结束的前一条为止
events, _ = s.events({"write": weekly}, ending="continued")
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
out["continued"] = render(s, events, finished - 1, legacy=True)
out["continued"] = build(s, events, finished - 1, legacy=True)
events, bound = s.events({"write": weekly}, tiers=("formal", "degraded"))
out["degraded"] = render(s, events, bound)
out["degraded"] = build(s, events, bound)
stray = s.doc(WEEKLY, "write", run_id="another-run-0000")
events, bound = s.events({"write": stray})
out["stray"] = render(s, events, bound)
out["stray"] = build(s, events, bound)
n = Sample(nan=True)
events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True)
out["nan"] = render(n, events, bound)
out["nan"] = build(n, events, bound)
m = Sample(masked=True)
events, bound = m.events({"write": m.doc(WEEKLY, "write")})
out["masked-card"] = render(m, events, bound)
out["masked-card"] = build(m, events, bound)
r = Sample(regions=True)
events, bound = r.events({"write": r.doc(MEMBERS, "write")})
out["masked-source"] = render(r, events, bound, source_masks={"crm": ["phone"]})
out["masked-source"] = build(r, events, bound, source_masks={"crm": ["phone"]})
out["formats"] = formats_bundle()
out["formats-unit"] = formats_bundle(card=FORMATS_UNIT_CARD)
return out
def pages() -> dict[str, str]:
"""{页面名: HTML}"""
return {name: render_html(bundle) for name, bundle in bundles().items()}
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("用法:python -m tests.bundle_samples <目录>", file=sys.stderr)
+4 -1
View File
@@ -464,7 +464,7 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
rendered = pages()
got = {name: json.loads(data_block(page)) for name, page in rendered.items()}
assert set(got) == {"base", "two-reports", "legacy", "failed", "continued", "degraded", "missing-doc",
"missing-card", "nan", "stray", "masked-card", "masked-source"}
"missing-card", "nan", "stray", "masked-card", "masked-source", "formats", "formats-unit"}
for name, bundle in got.items():
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
@@ -513,3 +513,6 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
nan = got["nan"]
assert "NaN" in next(e["canonical"] for e in nan["events"] if e["type"] == "run.started")
assert b"NaN" in base64.b64decode(next(a["b64"] for a in nan["artifacts"] if a.get("node_id") == "fetch"))
# 口径卡单位带尾随空白:服务端把「元 」原样接在数后面,正文里的销售额带着那个空白
shown = {s.get("ref"): s["text"] for s in segments(got["formats-unit"])}
assert shown["m:gmv"] == "74,613.65元 " and shown["m:gmv|万"] == "7.46万元 "
+782
View File
@@ -0,0 +1,782 @@
"""可验证报告核对正文显示的字(bundle/verifier.js 的显示规则)。
以前页面上 ⑤ 只核「引用的值 = 口径卡的值或快照的格子」,不核正文里显示的字:只改显示文字(cite.value 不动)、重算
全部哈希的伪造页照样全部通过。现在浏览器按服务端的显示规则(engine/evidence.py 的 render_number / render_cell)把那个
值渲染一遍,和正文显示的字逐字比对(只去掉首尾空白)。这里用 node 直接载入 verifier.js(没有页面时它交出核对用的纯函数):
- 移植的渲染和服务端逐条对得上:千分位、不分组、小数位(含负数位)、比率显示成百分数、万 / 亿换算、pct、int、.N、
货币等单位后缀、负数、0、超过 2^53 的整数(按 JSON 原文,不经 double)、DECIMAL 落成的数字文本、文字压成一行、竖线、
日期时间按文档记下的显示规则版本;服务端渲染不出来的,浏览器也不给出一个字。本页没有移植的写法(对象、列表、非 ASCII
数字)一律渲染不出来,判无法核对,不会给出一串不一样的字;
- 整份核对(verify):compose_doc 真实组装的报告原样通过;只改一个数字或取值的显示文字、重算全部哈希的伪造页,
①–④ 照样对得上,⑤ 里那一项不通过,原因点名「正文显示「X」,引用的值是「Y」」;认不出的换算判无法核对,不判通过;
- 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示
文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写;
- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、
负号(长得像负号的横线也算,隔着不占位置的字也算),紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里
有改变文字方向的字(方向控制字符、希伯来文和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的
写法照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数。
数据全是虚构的。
"""
from __future__ import annotations
import base64
import copy
import hashlib
import json
import random
import shutil
import subprocess
from pathlib import Path
from typing import Any
import pytest
import app.bundle
from app.core.artifact_store import canonical_json
from app.engine.evidence import RenderError, _Snapshots, render_cell, render_number
NODE = shutil.which("node")
VERIFIER = Path(app.bundle.__file__).parent / "verifier.js"
pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的核对脚本")
#: 载入 verifier.js(在 node 里没有 document,它把核对用的纯函数交给 module.exports),按 stdin 交来的任务算。
#: noBigInt:载入时把 BigInt 遮掉,当作较早的浏览器
HARNESS = r"""
const fs = require('fs')
const { createHash } = require('crypto')
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
const mod = { exports: {} }
new Function('module', 'BigInt', fs.readFileSync(process.argv[1], 'utf8'))(mod, input.noBigInt ? undefined : BigInt)
const v = mod.exports
function render(c) {
try {
const value = v.parseJson(c.json)
const token = v.tokenAt(v.numberTokens(c.json), [])
if (c.cell) return { shown: v.renderCell(value, token, c.conv, c.kind, c.display) }
const decimals = v.parseJson(c.decimals)
return { shown: v.renderNumber(value, token, { unit: c.unit, decimals,
decimalsToken: v.tokenAt(v.numberTokens(c.decimals), []), fmt: c.format, conv: c.conv }) }
} catch (e) {
return { shown: null, why: String(e && e.message), unshown: e instanceof v.Unshown }
}
}
async function main() {
if (input.task === 'render') return input.cases.map(render)
// 列表各项显示的缩进级数、表格排出来的列号(核对和显示共用的那两个函数)
if (input.task === 'layout') {
return input.blocks.map((b) => (b.type === 'list' ? v.listItems(b.units).map((i) => i.depth)
: v.tableCells(b.units).cols))
}
const digest = async (bytes) => createHash('sha256').update(bytes).digest('hex')
const out = await v.verify(input.data, digest, input.seal)
const results = (items) => items.map((i) => i.result)
return {
events: results(out.events), manifest: results(out.manifest), artifacts: results(out.artifacts),
chain: results(out.chain), meta: results(out.meta),
// shownOk:显示文字核对过、对上了;truthText:出处面板「快照里的值」写的字(数字文本照原文);onPage:正文里这处
// 可点的数字上读的人看到的字(没有显示成可点的数字是 null)
numbers: out.numbers.map((n) => ({ key: n.key, ref: n.seg.ref ?? null, text: String(n.seg.text ?? ''),
result: n.result, reason: n.reason || '', expected: n.expected ?? null, shownOk: !!n.shownOk,
truthText: n.truthText ?? null, onPage: n.onPage ?? null })),
}
}
main().then((r) => process.stdout.write(JSON.stringify(r)), (e) => {
console.error((e && e.stack) || String(e))
process.exit(1)
})
"""
def node(task: str, **payload: Any) -> Any:
done = subprocess.run([NODE, "-e", HARNESS, str(VERIFIER)], input=json.dumps({"task": task, **payload}),
capture_output=True, text=True, timeout=300)
assert done.returncode == 0, done.stderr[-2000:]
return json.loads(done.stdout)
# --------------------------------------------------------------------------
# 渲染规则逐条对照服务端
# --------------------------------------------------------------------------
#: 口径卡、运行输入里的值(render_number)。浮点、整数(含超过 2^53 的、1000 位上下的)、0 和 -0.0、文字、布尔、空值、
#: 非有限数(服务端渲染不出来)
NUMBERS: list[Any] = [
0, 7, -7, 12840, 8381.25, 74613.65, -74613.65, 1234567890.5, 0.0235, -0.0815, 0.346, 5.8113, 2.675, -2.675,
0.1 + 0.2, 1e16, 1.5e-7, 0.004, -0.0, 999.995, 0.005, 1e21, 123456789012345678901234567890, 2**53 + 1,
202609071234567890, -202609071234567891, 1e300, 10**999, 10**1000, 49196.64, 30278.22,
float("nan"), float("inf"), True, False, None, "华南", " 华南 \n 大区\u3000", "a|b", "2026", "",
]
FORMATS = [None, "thousands", "plain", "percent_of_ratio", "bogus"]
DECIMALS = ["null", "0", "1", "2", "4", "-2", "6", "100", "101", "-101", "true", "2.0", "\"2\""]
CONVS = [None, "", "万", "亿", "pct", "int", ".0", ".3", ".6", ".7", "千", "PCT"]
#: 单位照口径卡上写的原样接在后面:前后带空白(含全角空格)的也照接,服务端不去空白
UNITS = ["", "元", "美元", "%", "%", "人次", "元 ", "元\u3000", " 元"]
def py_number(value: Any, unit: str, decimals: Any, fmt: Any, conv: Any) -> str | None:
try:
return render_number(value, unit=unit, decimals=decimals, fmt=fmt, conv=conv)
except RenderError:
return None
def py_cell(raw: Any, conv: Any, kind: Any, display: int) -> str | None:
try:
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind)
except RenderError:
return None
def number_cases() -> list[dict[str, Any]]:
cases = []
for value in NUMBERS:
text = json.dumps(value, ensure_ascii=False)
for fmt in FORMATS:
for decimals in DECIMALS:
for conv in CONVS:
for unit in (UNITS if fmt in (None, "thousands") and decimals in ("null", "2") else ("元", "%")):
cases.append({"json": text, "decimals": decimals, "format": fmt, "conv": conv, "unit": unit,
"py": py_number(json.loads(text), unit, json.loads(decimals), fmt, conv)})
# 随机:各种量级、小数位的浮点和整数,按口径卡常见的几种格式
rng = random.Random(20261006)
for _ in range(4000):
scale = 10 ** rng.randint(-8, 14)
value: Any = rng.choice([round(rng.uniform(-1, 1) * scale, rng.randint(0, 10)), rng.randint(-10**18, 10**18),
rng.uniform(-1, 1) * scale, rng.randint(-999, 999) / 1000])
fmt = rng.choice(FORMATS[:4])
decimals = rng.choice(["null", "0", "1", "2", "3", "4", "-1", "-3", "8"])
conv = rng.choice([None, None, "万", "亿", "pct", "int", ".1", ".2", ".5"])
unit = rng.choice(["", "元", "单"])
text = json.dumps(value)
cases.append({"json": text, "decimals": decimals, "format": fmt, "conv": conv, "unit": unit,
"py": py_number(json.loads(text), unit, json.loads(decimals), fmt, conv)})
return cases
#: 查询快照里的一格(render_cell):数、DECIMAL 落成的数字文本(带空白、指数、前导零的编号、正号)、文字(多个空白、
#: 全角空格、竖线、换行)、ISO 日期时间(各种写法,2 版显示规则把 T 换成空格)、布尔、空值、超长的整数文本
CELLS: list[Any] = [
1280.5, 732, -1200.75, 0, -0.0, 202609071234567890, 2**63, 0.1 + 0.2, 1e-11, 1e22,
"45678.50", "960.00", "-12.5", " 12 ", "1E+5", "1E-7", "1E+999", "1E+1000", "0.000000000049", "0012", "+5",
"1e5", "-0", "1.", ".5", "1" * 4301, "9" * 4300,
"华南", " 多 空格\n换行 ", "\u3000全角空格\u3000", "a|b|c", "\x1c分隔\x1f", "\ufeff零宽\ufeff",
"2026-09-07T08:21:39", "2026-09-07T08:21:39.123+08:00", " 2026-09-07T08:21Z ", "2026-09-07T8:21",
"2026-09-07 08:21:39", "2026-09-07", True, False, None,
]
CELL_KINDS = [None, "number", "text", "datetime"]
CELL_CONVS = [None, "万", "亿", "pct", "int", ".2", "千"]
def cell_cases() -> list[dict[str, Any]]:
cases = []
for raw in CELLS:
text = json.dumps(raw, ensure_ascii=False)
for kind in CELL_KINDS:
for conv in CELL_CONVS:
for display in (1, 2):
cases.append({"cell": True, "json": text, "kind": kind, "conv": conv, "display": display,
"py": py_cell(json.loads(text), conv, kind, display)})
return cases
def test_the_ported_rules_render_exactly_like_the_server():
"""服务端渲染得出来的,浏览器逐字一样;服务端渲染不出来的(引用就算解析不了,不会出现在报告里),浏览器也不给出字。"""
cases = number_cases() + cell_cases()
got = node("render", cases=[{k: v for k, v in c.items() if k != "py"} for c in cases])
wrong = [(c, g) for c, g in zip(cases, got) if g["shown"] != c["py"]]
assert not wrong, [(c["json"][:40], c.get("format"), c.get("decimals"), c.get("unit"), c.get("conv"),
c.get("kind"), c.get("display"), c["py"], g) for c, g in wrong[:12]]
# 服务端渲染不出来的,浏览器给的是「核对不了」(Unshown),不是脚本自己出错
assert all(g["unshown"] for c, g in zip(cases, got) if c["py"] is None)
@pytest.mark.parametrize("raw", [{"a": 1, "b": [1, 2.0]}, [1, "x"], "1٢٣", "0.٥"])
@pytest.mark.parametrize("kind", [None, "number"])
def test_shapes_the_page_does_not_port_cannot_be_checked(raw, kind):
"""对象、列表(服务端按 Python 的 json.dumps 写)、非 ASCII 数字的数字文本(Python 按数渲染):本页没有移植,
渲染不出来,判无法核对;不会给出一串和服务端不一样的字。"""
text = json.dumps(raw, ensure_ascii=False)
[got] = node("render", cases=[{"cell": True, "json": text, "kind": kind, "conv": None, "display": 2}])
assert got["shown"] is None and got["unshown"], got
def test_integers_beyond_double_precision_render_from_the_json_text():
"""订单号这类超过 2^53 的整数:JSON.parse 读成 double 会丢位,渲染按 JSON 原文取精确值。"""
order = 202609071234567891
[number, cell] = node("render", cases=[
{"json": str(order), "decimals": "null", "format": None, "conv": None, "unit": "号"},
{"cell": True, "json": str(order), "kind": "number", "conv": None, "display": 2}])
assert number["shown"] == "202,609,071,234,567,891号" and cell["shown"] == "202,609,071,234,567,891"
def test_floats_round_half_up_on_their_shortest_decimal_form():
"""浮点按最短的十进制写法(Python 的 repr、JS 的 String 同一个)四舍五入:2.675 留两位是 2.68,不是 double
里的 2.67499…;-8.15 留一位是 -8.2(远离 0)。"""
got = node("render", cases=[
{"json": "2.675", "decimals": "2", "format": None, "conv": None, "unit": ""},
{"json": "-0.0815", "decimals": "3", "format": "percent_of_ratio", "conv": None, "unit": ""},
{"json": "999.995", "decimals": "2", "format": None, "conv": None, "unit": "元"}])
assert [g["shown"] for g in got] == ["2.68", "-8.2%", "1,000.00元"]
# --------------------------------------------------------------------------
# 整份核对:原样导出全部通过,只改显示文字的伪造页点名不通过
# --------------------------------------------------------------------------
def sha(data: bytes | str) -> str:
return hashlib.sha256(data.encode() if isinstance(data, str) else data).hexdigest()
def seal_of(bundle: dict[str, Any]) -> str:
lines = [f"{e['seq']}|{e['type']}|{e['node_id'] or ''}|{e['data_sha256']}"
for e in sorted(bundle["events"], key=lambda e: e["seq"])]
return sha("\n".join(lines))
def doc_of(bundle: dict[str, Any], node_id: str = "write") -> tuple[dict[str, Any], dict[str, Any]]:
doc_id = next(r["doc_artifact"] for r in bundle["reports"] if r["node_id"] == node_id)
art = next(a for a in bundle["artifacts"] if a["id"] == doc_id)
return art, json.loads(base64.b64decode(art["b64"]))
def segment(doc: dict[str, Any], ref: str) -> dict[str, Any]:
return next(s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s.get("ref") == ref)
def reseal(bundle: dict[str, Any], art: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]:
"""改过的文档重新落成工件:文档 id、引用它的事件(规范字符串和哈希)、封存清单哈希全部重算。和服务端写工件一样
用规范 JSON:① 到 ④ 都对得上,伪造只可能在 ⑤ 露馅。伪造者还能写进不成对的代理项(JSON 里的 \\ud835 转义),
规范 JSON 编不成 UTF-8:这时照 JSON 的转义写,页面只看字节的哈希,照样对得上。"""
try:
raw = canonical_json(doc).encode()
except UnicodeEncodeError:
raw = json.dumps(doc, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
old, new = art["id"], sha(raw)
art["id"], art["b64"] = new, base64.b64encode(raw).decode()
for report in bundle["reports"]:
if report["doc_artifact"] == old:
report["doc_artifact"] = new
for event in bundle["events"]:
if isinstance(event.get("canonical"), str) and old in event["canonical"]:
event["canonical"] = event["canonical"].replace(old, new)
event["data_sha256"] = sha(event["canonical"])
bundle["meta"]["manifest_hash"] = seal_of(bundle)
return bundle
def forged(ref: str, text: str, bundle: dict[str, Any] | None = None, **cite: Any) -> dict[str, Any]:
"""经营月报样本(或给的 bundle)里一个片段的显示文字改成 text(cite.value 不动,cite.rendered 跟着改,cite 另改
cite 里给的几项),重算全部哈希。"""
from tests.bundle_samples import formats_bundle
bundle = bundle or formats_bundle()
art, doc = doc_of(bundle)
seg = segment(doc, ref)
seg["text"] = text
seg["cite"].update(rendered=text, **cite)
return reseal(bundle, art, doc)
def rewritten(bundle: dict[str, Any], change: Any) -> dict[str, Any]:
"""change(文档) 就地改报告文档,再重算全部哈希(同 reseal)。"""
art, doc = doc_of(bundle)
change(doc)
return reseal(bundle, art, doc)
def by_id(doc: dict[str, Any], seg_id: str) -> dict[str, Any]:
return next(s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s.get("id") == seg_id)
def verdicts(bundle: dict[str, Any]) -> dict[str, Any]:
return node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"])
def item(got: dict[str, Any], ref: str) -> dict[str, Any]:
return next(n for n in got["numbers"] if n["ref"] == ref)
def test_every_format_in_the_monthly_sample_passes_as_exported():
"""经营月报样本(compose_doc 真实组装:货币单位、万 / 亿、比率、负数、不分组、小数位、pct、DECIMAL 文本、
超过 2^53 的订单号、日期时间、文字取值)原样导出:每个数字和取值都通过,显示文字核对过(expected 等于正文)。"""
from tests.bundle_samples import formats_bundle
got = verdicts(formats_bundle())
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain", "meta") for r in got[key]), got
numbers = got["numbers"]
assert len(numbers) == 19
assert all(n["result"] == "pass" and n["expected"] == n["text"] for n in numbers), \
[n for n in numbers if n["result"] != "pass" or n["expected"] != n["text"]]
shown = {n["ref"]: n["text"] for n in numbers}
assert shown == {
"m:gmv": "74,613.65元", "m:gmv|万": "7.46万元", "m:annual|亿": "12.35亿元", "m:refund": "-3,120.50元",
"m:conv_rate": "2.35%", "m:growth": "-8.2%", "m:orders": "12840单", "m:avg": "5.81元", "m:avg|.3": "5.811元",
"m:avg|int": "6元", "m:share|pct": "34.6%", "m:export": "15,230.4美元",
"v:Q1.r0.region": "华南", "v:Q1.r0.gmv": "45,678.5", "v:Q1.r0.gmv|万": "4.57万", "v:Q1.r1.region": "华东",
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202,609,071,234,567,890",
"v:Q1.r0.paid_at": "2026-09-07 08:21:39",
}
@pytest.mark.parametrize("ref, text, cited, expected", [
("v:Q1.r0.region", "华东", "华南", None), # 文字取值:换了地区
("m:gmv", "84,613.65元", "74613.65", "74,613.65元"), # 千分位 + 货币单位
("m:gmv|万", "8.46万元", "74613.65", "7.46万元"), # 口径卡引用换算成万
("m:annual|亿", "13.35亿元", "1234567890.5", "12.35亿元"), # 换算成亿
("m:refund", "3,120.50元", "-3120.5", "-3,120.50元"), # 负数去掉负号
("m:conv_rate", "3.35%", "0.0235", "2.35%"), # 比率显示成百分数
("m:growth", "8.2%", "-0.0815", "-8.2%"), # 负的百分数
("m:share|pct", "43.6%", "0.346", "34.6%"), # pct 换算
("m:orders", "13840单", "12840", "12840单"), # 不分组
("m:avg", "5.80元", "5.8113", "5.81元"), # 口径卡的小数位
("m:avg|.3", "5.812元", "5.8113", "5.811元"), # |.N
("m:avg|int", "5元", "5.8113", "6元"), # |int
("m:export", "15,230.4元", "15230.4", "15,230.4美元"), # 换了货币单位
("v:Q1.r0.gmv", "54,678.5", "45678.5", "45,678.5"), # DECIMAL 落成的数字文本
("v:Q1.r1.gmv", "1,200.75", "-1200.75", "-1,200.75"), # 负数单元格
("v:Q1.r0.gmv|万", "5.57万", "45678.5", "4.57万"), # 单元格换算成万
("v:Q1.r0.order_no", "202,609,071,234,567,891", "202609071234567890",
"202,609,071,234,567,890"), # 超过 2^53 的整数
("v:Q1.r0.paid_at", "2026-09-08 08:21:39", "2026-09-07T08:21:39", "2026-09-07 08:21:39"), # 日期时间
])
def test_changing_only_the_shown_text_fails_and_names_both(ref, text, cited, expected):
"""只改显示文字、引用值不动、重算全部哈希:①–④ 照样通过,⑤ 里只有这一项不通过,原因点名正文显示的字和引用的值
(数字另写按显示规则应显示的字)。"""
got = verdicts(forged(ref, text))
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]), got
hit = next(n for n in got["numbers"] if n["ref"] == ref)
assert hit["result"] == "fail", hit
assert hit["reason"].startswith(f"正文显示「{text}」,引用的值是「{cited}」"), hit["reason"]
if expected is None:
assert "应显示为" not in hit["reason"], hit["reason"]
else:
assert hit["reason"].endswith(f",按显示规则应显示为「{expected}」"), hit["reason"]
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
def test_surrounding_blanks_in_the_shown_text_are_tolerated():
"""只去掉首尾空白:「 华南 」仍算和「华南」一致;中间多一个字就不是。"""
assert next(n for n in verdicts(forged("v:Q1.r0.region", " 华南\u3000"))["numbers"]
if n["ref"] == "v:Q1.r0.region")["result"] == "pass"
assert next(n for n in verdicts(forged("v:Q1.r0.region", "华南区"))["numbers"]
if n["ref"] == "v:Q1.r0.region")["result"] == "fail"
@pytest.mark.parametrize("ref, cite, text", [
("m:gmv", {"conv": "千"}, "74.61千元"), # 系统没有的换算
("v:Q1.r0.gmv", {"conv": "千"}, "45.68千"),
("m:gmv", {"conv": 4}, "7.46万元"), # 换算不是文字
])
def test_a_format_the_page_does_not_know_cannot_pass(ref, cite, text):
"""不认识的写法:渲染不出来,判无法核对显示文字,不判通过。"""
hit = next(n for n in verdicts(forged(ref, text, **cite))["numbers"] if n["ref"] == ref)
assert hit["result"] == "na" and hit["reason"].startswith("无法核对显示文字:"), hit
def test_an_unknown_cell_display_version_cannot_pass():
"""文档记下的单元格显示规则版本认不出(服务端复核记 bad_schema):单元格的显示文字判无法核对;口径卡的照常核对。"""
from tests.bundle_samples import formats_bundle
bundle = formats_bundle()
art, doc = doc_of(bundle)
doc["cell_display"] = 3
got = verdicts(reseal(bundle, art, doc))
cells = [n for n in got["numbers"] if n["ref"].startswith("v:")]
assert cells and all(n["result"] == "na" and n["reason"].startswith("无法核对显示文字:") for n in cells)
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"].startswith("m:"))
def test_a_browser_without_bigint_cannot_check_numbers_but_still_checks_the_rest():
"""较早的浏览器没有 BigInt:数字的显示文字判无法核对(写明原因),文字取值照常核对,别的核对项不受影响,脚本不出错。"""
from tests.bundle_samples import formats_bundle
bundle = formats_bundle()
got = node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"], noBigInt=True)
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain", "meta") for r in got[key])
texts = {"v:Q1.r0.region", "v:Q1.r1.region", "v:Q1.r0.paid_at"}
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] in texts)
# 原因写给读报告的人:说清该怎么办,不提 BigInt、正则这类实现细节
assert all(n["result"] == "na" and n["reason"] == "无法核对显示文字:这个浏览器版本较旧,请用新版浏览器打开本文件"
for n in got["numbers"] if n["ref"] not in texts)
def test_every_sample_page_still_passes_its_display_check():
"""check-bundle 的样本页(tests/bundle_samples.py)原样导出:每个核对得到出处的数字和取值,显示文字都核对通过;
没有一项因为显示文字不通过或核对不了。"""
from tests.bundle_samples import bundles
for name, bundle in bundles().items():
got = verdicts(bundle)
assert not [n for n in got["numbers"] if n["reason"].startswith(("正文显示", "无法核对显示文字"))], name
assert all(n["expected"] == n["text"] for n in got["numbers"] if n["result"] == "pass"), name
# --------------------------------------------------------------------------
# 单位前后带空白:服务端照原样接在数后面,只去首尾空白时两边一样去
# --------------------------------------------------------------------------
def card_with_unit(unit: str) -> dict[str, Any]:
"""经营月报的口径卡,销售额的单位换成 unit。"""
from tests.bundle_samples import FORMATS_CARD
card = copy.deepcopy(FORMATS_CARD)
card["metrics"][0]["unit"] = unit
return card
@pytest.mark.parametrize("unit", ["元 ", "元\u3000", " 元", "元 \u3000"])
def test_a_unit_with_surrounding_blanks_passes_as_exported(unit):
"""单位输入框不去空白,指标节点照存:服务端把「元 」原样接在数后面,正文显示「74,613.65元 」。只去掉首尾空白时
两边一样去:原样导出的销售额和换算成万的那处都通过,不会被说成文件被改过。"""
from tests.bundle_samples import formats_bundle
got = verdicts(formats_bundle(card=card_with_unit(unit)))
for ref in ("m:gmv", "m:gmv|万"):
hit = item(got, ref)
assert hit["result"] == "pass" and hit["shownOk"] and hit["expected"] == hit["text"], hit
assert all(n["result"] == "pass" for n in got["numbers"]), [n for n in got["numbers"] if n["result"] != "pass"]
@pytest.mark.parametrize("unit", ["元 ", "元\u3000"])
@pytest.mark.parametrize("ref, digits", [("m:gmv", "84,613.65"), ("m:gmv|万", "8.46万")])
def test_a_unit_with_trailing_blanks_still_catches_a_changed_digit(unit, ref, digits):
"""单位带尾随空白时,改了一位数字照样不通过,原因点名改过的字。"""
from tests.bundle_samples import formats_bundle
text = f"{digits}{unit}"
hit = item(verdicts(forged(ref, text, formats_bundle(card=card_with_unit(unit)))), ref)
assert hit["result"] == "fail" and hit["reason"].startswith(f"正文显示「{text}」,引用的值是「74613.65」"), hit
# --------------------------------------------------------------------------
# 运行输入:输入本身不随文件附带,显示文字照样拿引用的值核对
# --------------------------------------------------------------------------
INPUTS = {"week_start": "2026-09-01", "target": 50000, "region": "华南", "rate": 0.125, "reviewed": True}
INPUT_TEXT = ("## 周报\n\n统计周期自 [[i:week_start]] 起,目标 [[i:target]](约 [[i:target|万]]),重点地区 [[i:region]],"
"达成率 [[i:rate|pct]],已复核:[[i:reviewed]]。")
def test_input_citations_check_their_shown_text():
"""运行输入不随文件附带,引用的值核对不了(照旧无法核对,原因不变);正文显示的字照样拿引用的值按服务端的写法
(不分组、不接单位,可带换算)重新渲染比对:原样导出时对得上,出处面板注明一致。"""
from tests.bundle_samples import formats_bundle
got = verdicts(formats_bundle(text=INPUT_TEXT, inputs=INPUTS))
inputs = [n for n in got["numbers"] if n["ref"].startswith("i:")]
assert {n["ref"]: n["text"] for n in inputs} == {
"i:week_start": "2026-09-01", "i:target": "50000", "i:target|万": "5万", "i:region": "华南",
"i:rate|pct": "12.5%", "i:reviewed": "是"}
assert all(n["result"] == "na" and n["reason"] == "引用的是运行输入,运行输入未随文件附带" and n["shownOk"]
for n in inputs), inputs
@pytest.mark.parametrize("ref, text, cited, expected", [
("i:week_start", "2026-09-08", "2026-09-01", None), # 改了统计周期(文字取值)
("i:region", "华东", "华南", None),
("i:target", "60000", "50000", None),
("i:target|万", "6万", "50000", "5万"),
("i:rate|pct", "21.5%", "0.125", "12.5%"),
])
def test_changing_only_the_shown_text_of_an_input_fails(ref, text, cited, expected):
"""只改运行输入的显示文字(引用的值不动)、重算全部哈希:不通过,原因点名正文显示的字和引用的值。"""
from tests.bundle_samples import formats_bundle
hit = item(verdicts(forged(ref, text, formats_bundle(text=INPUT_TEXT, inputs=INPUTS))), ref)
assert hit["result"] == "fail" and hit["reason"].startswith(f"正文显示「{text}」,引用的值是「{cited}」"), hit
if expected:
assert hit["reason"].endswith(f",按显示规则应显示为「{expected}」"), hit
# --------------------------------------------------------------------------
# 出处面板:落成数字文本的值照原文写
# --------------------------------------------------------------------------
def test_numeric_text_cells_show_their_own_digits_in_the_panel():
"""DECIMAL(20,0) 落成数字文本的订单号:按数比对时读成 double 会丢位,出处面板「快照里的值」照快照的原文写,
和「引用的值」一位不差;45678.50 这类也照原文写。"""
from tests.bundle_samples import LEDGER, formats_bundle
table = copy.deepcopy(LEDGER)
table["rows"][0][2] = "202609071234567891"
got = verdicts(formats_bundle(table=table))
order = item(got, "v:Q1.r0.order_no")
assert order["result"] == "pass" and order["text"] == "202,609,071,234,567,891", order
assert order["truthText"] == "202609071234567891", order
assert item(got, "v:Q1.r0.gmv")["truthText"] == "45678.50"
# 不是数字文本的格子不另写原文:照旧按值写
assert item(got, "v:Q1.r1.gmv")["truthText"] is None and item(got, "v:Q1.r0.region")["truthText"] is None
# --------------------------------------------------------------------------
# 正文的排法:核对通过的字要原样显示,旁边的字不能把它改读成别的数
# --------------------------------------------------------------------------
def test_every_checked_value_is_shown_on_the_page_as_checked():
"""原样导出:每个数字和取值都显示成可点的数字,读的人看到的字就是核对过的字。"""
from tests.bundle_samples import formats_bundle
got = verdicts(formats_bundle())
assert all(n["onPage"] == n["text"] for n in got["numbers"]), [n for n in got["numbers"] if n["onPage"] != n["text"]]
def test_inline_markup_cannot_hide_a_checked_value():
"""伪造的链接把核对通过的数字、取值塞进链接地址(链接只显示文字、不显示地址),链接文字写成别的数:这一句改按
未排版的原文显示,核对过的字一个不少地露出来,读的人看得到两个数。"""
from tests.bundle_samples import formats_bundle
def change(doc: dict[str, Any]) -> None:
by_id(doc, "s3")["text"] += "[84,613.65元](x " # 本月销售额 [84,613.65元](x 74,613.65元)(约 …
by_id(doc, "s5")["text"] = ")" + by_id(doc, "s5")["text"]
by_id(doc, "s36")["text"] += "[华南](x " # ),[华南](x 华东)冲减 …
by_id(doc, "s38")["text"] = ")" + by_id(doc, "s38")["text"]
got = verdicts(rewritten(formats_bundle(), change))
for ref, text in (("m:gmv", "74,613.65元"), ("v:Q1.r1.region", "华东")):
hit = item(got, ref)
assert hit["result"] == "pass" and hit["onPage"] == text, hit
GLUED = "无法核对显示文字:紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值"
BLURRED = "无法核对显示文字:紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值"
TURNED = "无法核对显示文字:这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样"
#: 经营月报第一段里的数字(第二段是 Q1 的取值)
FIRST = {"m:gmv", "m:gmv|万", "m:annual|亿", "m:refund", "m:conv_rate", "m:growth", "m:orders", "m:avg", "m:avg|.3",
"m:avg|int", "m:share|pct", "m:export"}
def _glue_before(seg_id: str, extra: str, after: str = ""):
def change(doc: dict[str, Any]) -> None:
by_id(doc, seg_id)["text"] += extra
if after:
nxt = by_id(doc, f"s{int(seg_id[1:]) + 2}")
nxt["text"] = after + nxt["text"]
return change
def _glue_after(seg_id: str, extra: str):
def change(doc: dict[str, Any]) -> None:
by_id(doc, seg_id)["text"] = extra + by_id(doc, seg_id)["text"]
return change
def _split_pair(high: str, low: str):
"""销售额前面插两个文字片段:前一个以代理对的前半结尾,后一个是后半。页面上两半挨着,拼成一个字显示。"""
def change(doc: dict[str, Any]) -> None:
unit = next(u for b in doc["blocks"] for u in b["units"] if any(s.get("id") == "s4" for s in u["segments"]))
at = next(i for i, s in enumerate(unit["segments"]) if s.get("id") == "s4")
by_id(doc, "s3")["text"] += high
unit["segments"].insert(at, {"kind": "text", "id": "s3b", "text": low, "state": "neutral"})
return change
@pytest.mark.parametrize("ref, change", [
("m:gmv", _glue_before("s3", "1")), # 前面多一位:174,613.65元
("m:gmv", _glue_before("s3", "1" + chr(0x200B))), # 隔着看不见的零宽空格
("m:gmv", _glue_before("s3", "1**", after="**")), # 隔着排版时藏起来的粗体标记
("m:gmv", _glue_before("s3", chr(0x1D7F7))), # 数学字母区的数字 1(占两个 UTF-16 单元)
# 隔着显示时不占位置的字(Chrome 里量过,宽 0 到 1px 不到):控制字符 DEL、未分配的默认不显示字、韩文填充符、
# 对象替换符、发丝空格、蒙古文里一个宽 0 的字、叠在数字上的组合符号、变体选择符
("m:gmv", _glue_before("s3", "1" + chr(0x7F))),
("m:gmv", _glue_before("s3", "1" + chr(0xFFF0))),
("m:gmv", _glue_before("s3", "1" + chr(0x115F))),
("m:gmv", _glue_before("s3", "1" + chr(0xFFFC))),
("m:gmv", _glue_before("s3", "1" + chr(0x200A))),
("m:gmv", _glue_before("s3", "1" + chr(0x1843))),
("m:gmv", _glue_before("s3", "1" + chr(0x0301))),
("m:gmv", _glue_before("s3", "1" + chr(0xFE0F))),
("m:gmv", _glue_before("s3", "1" + chr(0x201A))), # 和半角逗号长得一样的低位单引号(U+201A)
("m:gmv", _glue_before("s3", chr(0xD800) + "1")), # 不成对的代理项不和后面的 1 拼成一个字
("m:gmv", _split_pair(chr(0xD835), chr(0xDFF7))), # 代理对分在两个文字片段里,页面上照样拼成数字 1
("v:Q1.r0.gmv", _glue_after("s34", "0")), # 后面多一位:45,678.50
("v:Q1.r0.gmv", _glue_after("s34", ".5")), # 后面接小数:45,678.5.5
("v:Q1.r0.gmv", _glue_after("s34", chr(0x2024) + "5")), # 和句点长得一样的单点前导符
("m:conv_rate", _glue_before("s12", "-")), # 前面加负号:-2.35%
("m:conv_rate", _glue_before("s12", chr(0xFF0D))), # 全角负号
# 长得像负号的横线:连字符、不断行连字符、数字宽的横线、短破折号、长破折号、修饰字母负号、粗减号、小号长破折号、
# 数学减号、上标负号,和显示出来同样是一道横线的汉字「一」、假名长音、制表符横线、罗马盎司符号(码点在两个
# UTF-16 单元里)
*[("m:conv_rate", _glue_before("s12", chr(cp))) for cp in (
0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x02D7, 0x2796, 0xFE58, 0x2212, 0x207B, 0x4E00, 0x30FC, 0x2500,
0x10191)],
("m:refund", _glue_before("s9", "-")), # 负数前面再加负号:--3,120.50元
("v:Q1.r0.paid_at", _glue_after("s45", "5")), # 取值以数字结尾:2026-09-07 08:21:395
])
def test_digits_glued_to_a_checked_value_cannot_pass(ref, change):
"""伪造者在相邻的文字片段里补一位数、一个负号(隔着不占位置、显示成什么说不准的字,藏起来的粗体标记也算紧挨着;
长得像负号的横线也算负号):显示出来的数不止核对过的这几位,这一项判无法核对、写明原因;别的照常通过。"""
from tests.bundle_samples import formats_bundle
got = verdicts(rewritten(formats_bundle(), change))
hit = item(got, ref)
assert hit["result"] == "na" and hit["reason"] == GLUED and hit["shownOk"], hit
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
@pytest.mark.parametrize("ref, change", [
("m:gmv", _glue_before("s3", chr(0x0336))), # 叠在空格上的长横线:页面上像一个负号
("m:gmv", _glue_before("s3", chr(0x82))), # 控制字符,Chrome 里画成一道短横
("m:gmv", _glue_before("s3", chr(0x7F))), # 控制字符 DEL,什么都不显示
("m:gmv", _glue_before("s3", chr(0xAD))), # 可选连字符:在它那里换行时显示成连字符
("m:gmv", _glue_before("s3", chr(0xE000))), # 私用区的字:各家字体画成什么都有
("v:Q1.r0.gmv", _glue_after("s34", chr(0x0378))), # 未分配的码点紧跟在数字后面
("v:Q1.r0.gmv", _glue_after("s34", "." + chr(0x7F))), # 隔着句点紧跟一个说不准的字
])
def test_characters_that_may_show_as_anything_next_to_a_value_cannot_pass(ref, change):
"""紧挨着数字的是显示成什么说不准的字(控制字符、组合符号、可选连字符、私用区和未分配的字):各家浏览器、字体
可能把它显示成数字、横线或者什么都不显示,判无法核对,原因说清是这类字;别的照常通过。"""
from tests.bundle_samples import formats_bundle
got = verdicts(rewritten(formats_bundle(), change))
hit = item(got, ref)
assert hit["result"] == "na" and hit["reason"] == BLURRED and hit["shownOk"], hit
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
def test_uncertain_characters_where_they_cannot_change_the_value_still_pass():
"""说不准的字挨着的不是数字的头尾(销售额以「元」结尾,后面跟一个控制字符),表情后面的变体选择符,数字前面的波浪线,
都改不了读到的数:照常通过。"""
from tests.bundle_samples import formats_bundle
def change(doc: dict[str, Any]) -> None:
by_id(doc, "s5")["text"] = chr(0x7F) + chr(0x0336) + by_id(doc, "s5")["text"]
by_id(doc, "s12")["text"] += chr(0x2705) + chr(0xFE0F) # 转化率紧前面:对勾表情加上表情样式的变体选择符
by_id(doc, "s14")["text"] += chr(0x301C) # 环比紧前面:波浪线不是横线,不算负号
got = verdicts(rewritten(formats_bundle(), change))
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
@pytest.mark.parametrize("change, scope", [
(lambda doc: by_id(doc, "s5").update(text=chr(0x202E) + by_id(doc, "s5")["text"]), FIRST), # 从右往左覆盖
# 希伯来字母:逻辑上在销售额后面的「5」显示在销售额前面(页面上读作 574,613.65元)
(lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + chr(0x05D0)),
by_id(doc, "s5").update(text="5" + by_id(doc, "s5")["text"])), FIRST),
# 希伯来字母后面的「5」显示在 45,678.50 紧后面(读作 45,678.505);阿拉伯字母同样
(lambda doc: by_id(doc, "s34").update(text=chr(0x05D0) + "5" + by_id(doc, "s34")["text"]), None),
(lambda doc: by_id(doc, "s34").update(text=chr(0x0628) + "5" + by_id(doc, "s34")["text"]), None),
# 腓尼基字母(从右往左书写,码点在两个 UTF-16 单元里)
(lambda doc: by_id(doc, "s34").update(text=chr(0x10900) + "5" + by_id(doc, "s34")["text"]), None),
])
def test_text_that_changes_direction_next_to_checked_values_cannot_pass(change, scope):
"""改变文字方向的控制字符、从右往左书写的文字(希伯来文、阿拉伯文这类)能把后面的一位数挪到数字的另一边显示,
按字的先后看不出紧挨着:同一段里出现在别的片段里,这一段的数字、取值判无法核对;别的段照常通过。scope 是受影响的
那一段(None:经营月报第二段,Q1 的取值)。"""
from tests.bundle_samples import formats_bundle
got = verdicts(rewritten(formats_bundle(), change))
for n in got["numbers"]:
inside = n["ref"] in scope if scope is not None else n["ref"] not in FIRST
if inside:
assert n["result"] == "na" and n["reason"] == TURNED, n
else:
assert n["result"] == "pass", n
def test_ranges_dates_and_signs_written_by_the_system_still_pass():
"""真报告里常见的写法不算紧挨着:两个数之间的连字符、破折号(区间)、日期和数字之间隔着字或空白、负数自己的负号。"""
from tests.bundle_samples import formats_bundle
text = ("## 区间\n\n销售额区间 [[m:gmv]]-[[m:export]],流水区间 [[v:Q1.r0.gmv]]-[[v:Q1.r1.gmv]]。"
"截至2026年9月[[m:orders]],2026-09-07 当天客单价 [[m:avg]],环比 [[m:growth]],"
"退款区间 [[m:refund]]" + chr(0x2014) + "[[m:annual|亿]]。")
got = verdicts(formats_bundle(text=text))
assert len(got["numbers"]) == 9
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
NOT_SHOWN = "正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过"
def test_a_checked_value_in_a_block_the_page_does_not_show_fails():
"""分隔线不显示句子:把一段改成分隔线,这一段里核对通过的数字、取值读的人都看不到(伪造的字可以写在别处),
服务端导出时不会出现,判不通过;别的段照常通过。"""
from tests.bundle_samples import formats_bundle
def change(doc: dict[str, Any]) -> None:
doc["blocks"][2]["type"] = "hr"
got = verdicts(rewritten(formats_bundle(), change))
second = {"v:Q1.r0.region", "v:Q1.r0.gmv", "v:Q1.r0.gmv|万", "v:Q1.r1.region", "v:Q1.r1.gmv",
"v:Q1.r0.order_no", "v:Q1.r0.paid_at"}
for n in got["numbers"]:
if n["ref"] in second:
assert n["result"] == "fail" and n["reason"] == NOT_SHOWN and n["onPage"] is None, n
else:
assert n["result"] == "pass" and n["onPage"] == n["text"], n
def test_a_table_cell_outside_the_rows_and_columns_fails():
"""表格只显示表头和各行各列上的格:把一格的位置改成第 1.5 列,读的人看不到它,判不通过。原样导出时表格里每一格都
显示成可点的数字。"""
from tests.bundle_samples import FORMATS, formats_bundle
bundle = formats_bundle(text=FORMATS + "\n\n[[table:Q1 cols=region,gmv rows=0-1]]")
got = verdicts(bundle)
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"])
moved: list[str] = []
def change(doc: dict[str, Any]) -> None:
table = next(b for b in doc["blocks"] if b["type"] == "table")
unit = next(u for u in table["units"] if u.get("loc") == {"row": 1, "col": 1})
unit["loc"] = {"row": 1, "col": 1.5}
moved.extend(f"write|{s['id']}" for s in unit["segments"] if s["kind"] in ("number", "value"))
got = verdicts(rewritten(bundle, change))
assert len(moved) == 1
for n in got["numbers"]:
if n["key"] in moved:
assert n["result"] == "fail" and n["reason"] == NOT_SHOWN, n
else:
assert n["result"] == "pass", n
def _list_unit(depth: Any) -> dict[str, Any]:
return {"segments": [{"kind": "structural", "text": "- "}, {"kind": "text", "text": "一项"}], "depth": depth}
def test_list_indentation_is_capped_on_screen():
"""列表照级数缩进(每级 14px),最多 8 级:服务端按原文的缩进算级数、没有上限,伪造的级数写成十万或负数,那一项
也留在屏幕上(读的人看得到核对过的数,窄屏不横向溢出)。核对和显示用的是同一份级数(listItems)。"""
depths = [0, 1, 3, 8, 9, 100000, -1, -100000, 2.7, "x", None, "4"]
got = node("layout", blocks=[{"type": "list", "units": [_list_unit(d) for d in depths]}])
assert got == [[0, 1, 3, 8, 8, 8, 0, 0, 2, 0, 0, 4]]
def test_table_columns_without_cells_are_not_laid_out():
"""表格只排有格的列:服务端每行从第 0 列起一格不落,列号连着;伪造的列号写成十万,那一格紧接着前一列显示,不会
被几万个空列挤到表格右边看不见的地方。列号不是非负整数的格照旧不排(判不通过,见上一条)。"""
units = [{"loc": {"row": r, "col": c}, "segments": []}
for r, c in [(-1, 0), (-1, 1), (0, 0), (0, 1), (0, 100000), (1, 1.5), (1, -1), (1, "x"), (1, "2")]]
assert node("layout", blocks=[{"type": "table", "units": units}]) == [[0, 1, 2, 100000]]
def test_a_huge_column_number_neither_hides_nor_breaks_the_check():
"""列号写成 2^32:以前按列号建空列,数组长度超出上限,整段核对出错;现在那一格紧接着显示、照常核对通过。"""
from tests.bundle_samples import FORMATS, formats_bundle
def change(doc: dict[str, Any]) -> None:
table = next(b for b in doc["blocks"] if b["type"] == "table")
unit = next(u for u in table["units"] if u.get("loc") == {"row": 1, "col": 1})
unit["loc"] = {"row": 1, "col": 2 ** 32}
got = verdicts(rewritten(formats_bundle(text=FORMATS + "\n\n[[table:Q1 cols=region,gmv rows=0-1]]"), change))
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
def test_reasons_for_readers_do_not_name_implementation_details():
"""核对页是发给业务读者的文件:「无法核对显示文字」的原因说清是什么情况、该怎么办,不提 BigInt、正则、ASCII。"""
source = VERIFIER.read_text(encoding="utf-8")
start = source.index("const T = {")
block = source[start:source.index("\n }\n", start)]
for word in ("BigInt", "Unicode", "ASCII", "正则", "重新渲染时出错"):
assert word not in block, word
+352 -26
View File
@@ -8,15 +8,22 @@
// - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口),
// 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因;
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
// 数字与取值的显示文字都核对得了(没有「无法核对显示文字」:移植的显示规则漏了真实写法时这里先报);
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
// 总览显示不通过;另做两种「哈希全部重算」的伪造:改报告里一个数字的引用值,或者引用值不动、只改它的显示文字(数字改
// 一位;有引用快照文字的取值时也改一个),文档、事件、清单、页面顶部的封存哈希都重算一遍,①–④ 都对得上,只有 ⑤ 拿
// 口径卡、快照对出来(显示文字按显示规则重新渲染后逐字比对)不通过,原因点名正文显示的字和引用的值,报告标题旁的徽标
// 跟着不通过;
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后
// 经继续运行跑到成功的早期运行、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询),逐份
// 断言各项结果和原因文案;在上面篡改 ④
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询、每种显示
// 格式各引用一处的经营月报、单位带尾随空白的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、
// 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、前面
// 补一位数、隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、从右往左书写的字母把一位数
// 挪到数字前面、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、
// 表格的列号写成十万,核对通过的数字照样留在屏幕上(宽屏、窄屏都不横向溢出);在上面篡改 ④
// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把
// 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、
// 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、
@@ -141,6 +148,11 @@ const stateOf = (page) => page.evaluate(() => ({
acc[b.dataset.result] = (acc[b.dataset.result] ?? 0) + 1
return acc
}, {}),
// 正文里标成不通过的数字:读屏念的那一句
failedButtons: [...document.querySelectorAll('button.num[data-result="fail"]')].map((b) => b.getAttribute('aria-label') ?? ''),
// 正文里每个可点的数字:哪一处、结果、读的人看到的字
buttons: [...document.querySelectorAll('button.num')].map((b) => ({ key: b.dataset.num, result: b.dataset.result,
text: b.textContent })),
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
seal: document.getElementById('seal-hash')?.textContent ?? '',
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
@@ -155,22 +167,35 @@ const hasItem = (s, check, result, text) => s.items.some((i) => i.check === chec
&& (!text || i.text.includes(text)))
/**
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
* 「哈希全部重算」的伪造:报告里挑一个能核对的数字或取值(引用口径卡或随文件附带的快照,pick(片段) 为真的第一个),
* edit(片段) 改它,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希全部重算。返回改了哪一处的说明(what、所在报告
* node、改过的片段 seg),找不到可改的返回 null
*/
function forge(data) {
function rewrite(data, pick, edit) {
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
return rewriteDoc(data, (doc) => {
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind)
&& embedded.has(doc.catalog?.[c.alias]?.artifact) && pick(seg)) hit = seg
}
if (!hit) return null
edit(hit)
return { what: `片段 ${hit.id}(${hit.cite.kind})`, seg: hit }
})
}
/**
* change(文档) 就地改报告文档(返回 { what, seg? } 说明改了哪里,找不到可改的返回 null),再把文档、引用它的事件、
* 清单哈希全部重算(页面顶部的封存哈希由 tamper 的 seal 换)。返回说明,另带所在报告 node
*/
function rewriteDoc(data, change) {
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
if (!docArt) return null
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const done = change(doc)
if (!done) return null
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
@@ -186,7 +211,48 @@ function forge(data) {
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
data.meta.manifest_hash = sha(lines.join('\n'))
return { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
return { ...done, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
}
/** 经营月报样本里按 id 找片段(改它两边的文字做排版上的伪造) */
const segById = (doc, id) => doc.blocks.flatMap((b) => b.units).flatMap((u) => u.segments).find((s) => s.id === id)
/** 改一个数字的引用值(显示文字不动),重算全部哈希:⑤ 拿口径卡、快照对出来 */
const forge = (data) => rewrite(data, (seg) => typeof seg.cite.value === 'number', (seg) => { seg.cite.value += 1 })
/**
* 只改显示文字的伪造(验收里的 T4、T5):引用值不动,正文显示的字换成 change(原来的字),cite.rendered 跟着改,重算全部
* 哈希。pick 挑哪一个片段(默认第一个能核对的)
*/
const forgeShown = (change, pick = () => true) => (data) => rewrite(data, pick, (seg) => {
seg.text = change(seg.text)
seg.cite.rendered = seg.text
})
/** 显示文字里第一个数字加一(9 变 0):74,613.65元 → 84,613.65元 */
const bumpDigit = (text) => text.replace(/\d/, (d) => String((Number(d) + 1) % 10))
/** 引用快照里文字的取值(华南、门店名这类) */
const isTextValue = (seg) => seg.kind === 'value' && typeof seg.cite.value === 'string' && seg.cite.value.trim() !== ''
/**
* 只改显示文字的伪造该有的样子:⑤ 里只有那一项不通过,原因点名正文显示的字和引用的值,正文里那个数字标成不通过、
* 读屏读作「不通过」;①–④ 照样通过(伪造重算了全部哈希)。quiet:读的是真数据,原因只断言、不打印
*/
const namesShown = ({ quiet = false } = {}) => (s, forged) => {
// 条目多时不通过的列在外面一份、收起的全部里又一份:只数外面的
const failed = s.items.filter((i) => i.check === 'numbers' && i.result === 'fail' && !i.folded)
const named = `正文显示「${forged.seg.text}」,引用的值是「${forged.seg.cite.value}」`
return [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['⑤ 里只有这一项不通过', failed.length === 1, `${failed.length} 项`],
['原因点名正文显示的字和引用的值', failed.length === 1 && failed[0].text.includes(named),
quiet ? '' : failed[0]?.text ?? '没有不通过的项'],
['正文里那个数字标成不通过,读屏读作「不通过」', s.failedButtons.length === 1
&& s.failedButtons[0].includes(`${forged.seg.text},不通过,查看出处`), quiet ? '' : s.failedButtons.join(' | ')],
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
]
}
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
@@ -311,6 +377,12 @@ await section('断网打开', async () => {
check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts)
check('数字与取值没有不通过的', noFail(s.checks.numbers) || s.checks.numbers === 'na',
`${s.checks.numbers}:${JSON.stringify(s.numberStates)}`)
// 显示文字在真数据上也要核得了:移植的显示规则漏了某种真实写法(或者真报告的排版被判成紧挨着)时,数字会大批变成
// 「无法核对显示文字」,上面那条「没有不通过」看不出来。Chrome 有 BigInt,正常导出的都该核得了。读的是真数据:
// 只打印条数
const unshown = new Set(s.items.filter((i) => i.check === 'numbers' && i.result === 'na'
&& i.text.includes('无法核对显示文字')).map((i) => i.text)).size
check('数字与取值的显示文字都核对得了(没有「无法核对显示文字」)', unshown === 0, `${unshown} 项`)
// 早期运行的运行结果、升级前的运行类别核对不了(无法核对),只要求没有对不上的
check('运行信息没有与已核对的事件对不上的', s.checks.meta !== 'fail', `${s.checks.meta}:${JSON.stringify(s.meta)}`)
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
@@ -332,7 +404,7 @@ await section('断网打开', async () => {
const text = await page.locator('#detail').innerText()
const cited = await target.getAttribute('data-kind') === 'number'
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text)
&& (!cited || /显示格式由服务端渲染/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
&& (!cited || /与按显示规则渲染的结果一致/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
await page.keyboard.press('Escape')
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
} else {
@@ -359,8 +431,9 @@ await section('去掉 crypto.subtle', async () => {
// ---------------------------------------------------------------- 篡改
/**
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state) 返回
* [[检查名, 条件, 说明]…]。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份(重算全部哈希的伪造会这么做)
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state, 说明, page) 返回
* [[检查名, 条件, 说明]…](要点开面板的可以是异步的)。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份
* (重算全部哈希的伪造会这么做)
*/
async function tamper(source, label, mutate, expect, { seal = false } = {}) {
const data = structuredClone(dataOf(source))
@@ -374,7 +447,9 @@ async function tamper(source, label, mutate, expect, { seal = false } = {}) {
const opened = await openFile(writePage(`tampered-${label.replace(/[^\w一-鿿]+/g, '_')}.html`, page))
try {
const s = await stateOf(opened.page)
for (const [name, cond, detail] of expect(s, what)) check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
for (const [name, cond, detail] of await expect(s, what, opened.page)) {
check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
}
} finally {
await opened.ctx.close()
}
@@ -408,6 +483,18 @@ await section('篡改', async () => {
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
], { seal: true })
// 只改显示文字(验收里的 T4):引用值不动,一个数字的显示文字改一位,重算全部哈希。⑤ 按显示规则重新渲染对出来,点名
// 正文显示的字和引用的值。读的是真数据:原因只断言、不打印
await tamper(html, '一个数字的显示文字并重算全部哈希',
forgeShown(bumpDigit, (seg) => typeof seg.cite.value === 'number' && /\d/.test(seg.text ?? '')),
namesShown({ quiet: true }), { seal: true })
// 文字取值(验收里的 T5):导出的报告里有引用快照文字的取值才做;样本页 formats 上一定做
if (rewrite(structuredClone(tamperable), isTextValue, () => {})) {
await tamper(html, '一个文字取值的显示并重算全部哈希', forgeShown((t) => `${t}(改)`, isTextValue),
namesShown({ quiet: true }), { seal: true })
} else {
note('导出的报告里没有引用快照文字的取值,文字取值的篡改在样本页 formats 上做')
}
// ③ 改元信息里的封存哈希
await tamper(html, '元信息里的封存哈希', (data) => {
const h = data.meta.manifest_hash
@@ -493,7 +580,7 @@ await section('样本页:各种形状', async () => {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 12, Object.keys(samples).join('、'))
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 14, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
@@ -517,7 +604,7 @@ await section('样本页:各种形状', async () => {
check('base:斜体渲染成斜体', (await page.locator('.doc em').allTextContents()).includes('按发生周统计'))
const cell = page.locator('td.n button.num')
check('base:表格里数字列右对齐', await cell.count() === 8)
// 「显示格式由服务端渲染」只附在带出处的数字上:模型自己写的数、表格里的文字取值都是原文
// 「与按显示规则渲染的结果一致」只附在显示文字核对过的数字和取值上:模型自己写的数没人重新渲染过
const detailOf = async (locator) => {
await locator.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
@@ -528,9 +615,9 @@ await section('样本页:各种形状', async () => {
const cellNumber = page.locator('td.n button.num[data-kind="number"]').first()
const value = page.locator('button.num[data-kind="value"]').first()
const bare = page.locator('button.num[data-kind="number"][data-result="na"]').filter({ hasText: /^42$/ })
check('base:带出处的数字注明显示格式由服务端渲染', /显示格式由服务端渲染/.test(await detailOf(cellNumber)))
check('base:文字取值、没有出处的数字不注明显示格式', !/显示格式由服务端渲染/.test(await detailOf(value))
&& !/显示格式由服务端渲染/.test(await detailOf(bare)))
check('base:带出处的数字注明显示文字与按显示规则渲染的结果一致', /与按显示规则渲染的结果一致/.test(await detailOf(cellNumber)))
check('base:表格里的文字取值同样核对显示文字', /与按显示规则渲染的结果一致/.test(await detailOf(value)))
check('base:没有出处的数字不注明', !/按显示规则/.test(await detailOf(bare)))
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
})
@@ -657,6 +744,53 @@ await section('样本页:各种形状', async () => {
check('stray:文档没通过核对时数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')
&& s.reports[0]?.warn.includes('其中的数字不予核对'), s.checks.numbers)
})
// 经营月报:每种显示格式各引用一处(货币单位、万 / 亿、比率、负数、不分组、小数位、pct、DECIMAL 文本、超过 2^53 的
// 订单号、日期时间、文字取值),原样导出全部通过:每个数字和取值的显示文字都按显示规则重新渲染、逐字对上
await sample('formats', async (s, page) => {
check('formats:总览「全部通过」,没有一项无法核对', s.overall === 'pass' && s.text === '全部通过'
&& !s.items.some((i) => i.result !== 'pass'), `${s.overall}:${s.text}`)
check('formats:⑤ 数字 16 个、取值 3 个全部通过', s.checks.numbers === 'pass'
&& s.sums.numbers.includes('数字 16 个 · 取值 3 个') && s.numberStates.pass === 19, s.sums.numbers)
const wan = page.locator('button.num').filter({ hasText: /^7\.46万元$/ })
let detail = '找不到「7.46万元」'
if (await wan.count()) {
await wan.first().click()
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
await page.keyboard.press('Escape')
}
check('formats:点开换算成万的数字,引用的值、口径卡里的值照原值写,注明与按显示规则渲染的结果一致',
detail.includes('74613.65') && detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'),
detail.replace(/\s+/g, ' ').slice(0, 160))
const order = page.locator('button.num').filter({ hasText: '202,609,071,234,567,890' })
detail = '找不到订单号'
let rowCell = ''
if (await order.count()) {
await order.first().click()
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
rowCell = await page.locator('#detail td[data-cell="order_no"]').textContent().catch(() => '') ?? ''
await page.keyboard.press('Escape')
}
// 引用的值、快照里的值、快照第 1 行那一格都按 JSON 原文写:读成 double 的 202609071234567900 一处都不能有
check('formats:超过 2^53 的订单号核对通过,出处面板里的值(连同快照那一行)一位不差',
detail.includes('202609071234567890') && !detail.includes('202609071234567900')
&& rowCell === '202609071234567890' && detail.includes('与按显示规则渲染的结果一致'),
`${detail.replace(/\s+/g, ' ').slice(0, 160)} | 那一行的格:${rowCell}`)
})
// 口径卡单位带尾随空白(「元 」):服务端照原样接在数后面,正文是「74,613.65元 」,两边都只去首尾空白,原样导出照样
// 全部通过,不会被说成文件被改过
await sample('formats-unit', async (s, page) => {
check('formats-unit:口径卡单位带尾随空白时原样导出「全部通过」', s.overall === 'pass' && s.text === '全部通过'
&& s.numberStates.pass === 19, `${s.overall}:${s.text};${JSON.stringify(s.numberStates)}`)
const gmv = page.locator('button.num').filter({ hasText: /^74,613\.65元\s*$/ })
let detail = '找不到销售额'
if (await gmv.count()) {
await gmv.first().click()
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
await page.keyboard.press('Escape')
}
check('formats-unit:点开销售额,注明与按显示规则渲染的结果一致,不另列「按显示规则应为」',
detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'), detail.replace(/\s+/g, ' ').slice(0, 160))
})
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
{
@@ -804,6 +938,198 @@ await section('样本页:篡改', async () => {
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
], { seal: true })
// 只改显示文字、引用值不动、重算全部哈希(验收里的 T4、T5):经营月报上改文字取值、千分位加货币单位的数字、换算成万的
// 口径卡引用,各自只有那一项不通过。原因点名正文显示的字和引用的值,数字另写按显示规则应显示的字
const shownForgeries = [
['文字取值的显示', 'v:Q1.r0.region', '华东', null],
['数字的显示', 'm:gmv', '84,613.65元', '74,613.65元'],
['换算成万的口径卡引用的显示', 'm:gmv|万', '8.46万元', '7.46万元'],
]
for (const [label, ref, text, expected] of shownForgeries) {
await tamper(samples.formats, `${label}并重算全部哈希`, forgeShown(() => text, (seg) => seg.ref === ref), (s, forged) => {
const reason = s.items.find((i) => i.check === 'numbers' && i.result === 'fail')?.text ?? ''
return [
...namesShown()(s, forged),
expected ? ['原因另写按显示规则应显示的字', reason.includes(`按显示规则应显示为「${expected}」`), reason]
: ['文字取值的原因不另写应显示的字', !reason.includes('按显示规则应显示为'), reason],
]
}, { seal: true })
}
// 点开改过的那个数字:出处面板并排列出按显示规则应为的字和正文显示的字
{
const data = structuredClone(dataOf(samples.formats))
const forged = forgeShown(() => '8.46万元', (seg) => seg.ref === 'm:gmv|万')(data)
const opened = await openFile(writePage('tampered-shown-detail.html', withSeal(withData(samples.formats, data),
data.meta.manifest_hash)))
try {
await opened.page.locator(`button.num[data-num="${forged.node}|${forged.seg.id}"]`).click()
const detail = await appears(opened.page.locator('#detail')) ? await opened.page.locator('#detail').innerText() : ''
check('篡改换算成万的口径卡引用的显示:出处面板写明不通过,列出按显示规则应为「7.46万元」、正文显示「8.46万元」',
detail.includes('不通过') && /按显示规则应为\s*7\.46万元/.test(detail) && /正文显示\s*8\.46万元/.test(detail),
detail.replace(/\s+/g, ' ').slice(0, 200))
} finally {
await opened.ctx.close()
}
}
// 核对过的字不动、改它旁边的(经营月报,重算全部哈希):读的人看到的数要就是核对过的那个
const buttonOf = (s, forged) => s.buttons.find((b) => b.key === `${forged.node}|${forged.seg.id}`)
// 伪造的链接把销售额塞进链接地址(链接只显示文字),链接文字写成 84,613.65元:这一句改按原文显示,核对过的字露出来
await tamper(samples.formats, '用链接地址藏起核对通过的数字', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += '[84,613.65元](x '
segById(doc, 's5').text = `)${segById(doc, 's5').text}`
return { what: '销售额塞进链接地址', seg: segById(doc, 's4') }
}), async (s, forged, page) => {
const btn = buttonOf(s, forged)
const sentence = await page.locator('.doc p').first().innerText()
return [
['销售额照样显示成可点的数字,字一个不少', btn?.text === '74,613.65元' && btn.result === 'pass', JSON.stringify(btn)],
['这一句按原文显示,伪造的链接写法露在外面', sentence.includes('[84,613.65元](x 74,613.65元)'), sentence.slice(0, 80)],
]
}, { seal: true })
// 前面补一位数:读起来是 174,613.65元,绿色下划线只在 74,613.65元 下面
await tamper(samples.formats, '核对通过的数字前面补一位数', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += '1'
return { what: '本月销售额 174,613.65元', seg: segById(doc, 's4') }
}), (s, forged) => {
const btn = buttonOf(s, forged)
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'na' && i.text.includes('紧挨着的正文里还有数字或负号'))
return [
['销售额判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)],
['⑤ 写明紧挨着的正文里还有数字或负号', !!item, item?.text ?? '没有这一项'],
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
]
}, { seal: true })
// 一段改成分隔线:分隔线不显示句子,这一段里核对通过的数字读的人看不到,导出时不会出现
await tamper(samples.formats, '把一段改成分隔线', (data) => rewriteDoc(data, (doc) => {
doc.blocks[2].type = 'hr'
return { what: '第二段改成分隔线' }
}), (s) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['原因写明正文里没有显示这一处', hasItem(s, 'numbers', 'fail', '正文里没有显示这一处:导出时不会出现这种情况')],
], { seal: true })
// 文字片段冒用销售额的 id:页面按片段本身找核对结果,冒用的文字显示不成可点的数字、借不到「通过」
await tamper(samples.formats, '文字片段冒用核对通过的数字的 id', (data) => rewriteDoc(data, (doc) => {
const gmv = segById(doc, 's4')
doc.blocks[2].units.at(-1).segments.push({ kind: 'text', id: gmv.id, text: '84,613.65元', state: 'neutral' })
return { what: '第二段末尾加一段冒用 id 的文字', seg: gmv }
}), (s, forged) => {
const same = s.buttons.filter((b) => b.key === `${forged.node}|${forged.seg.id}`)
return [
['冒用 id 的文字不显示成可点的数字', same.length === 1 && same[0].text === '74,613.65元'
&& !s.buttons.some((b) => b.text.includes('84,613.65')), JSON.stringify(same)],
]
}, { seal: true })
// 紧挨着的字(经营月报,重算全部哈希):隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、
// 从右往左书写的字母把一位数挪到数字前面——读起来都不是核对过的那个数,判无法核对并写明原因。字一律按码点写,脚本
// 里不出现看不见、改变方向的字
const cp = (...points) => String.fromCodePoint(...points)
const naBecause = (why) => (s, forged) => {
const btn = buttonOf(s, forged)
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'na' && i.text.includes(why))
return [
['那个数字判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)],
[`⑤ 写明「${why}」`, !!item, item?.text ?? '没有这一项'],
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
]
}
const GLUED = '紧挨着的正文里还有数字或负号'
await tamper(samples.formats, '隔着显示时不占位置的控制字符补一位数', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += `1${cp(0x7f)}`
return { what: '销售额前面补「1」和一个 DEL', seg: segById(doc, 's4') }
}), naBecause(GLUED), { seal: true })
await tamper(samples.formats, '前面加一个长得像负号的连字符', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's12').text += cp(0x2010)
return { what: '转化率前面加 U+2010', seg: segById(doc, 's13') }
}), naBecause(GLUED), { seal: true })
await tamper(samples.formats, '叠在空格上的横线', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += cp(0x336)
return { what: '销售额前面的空格上叠一道长横线(U+0336)', seg: segById(doc, 's4') }
}), naBecause('紧挨着的正文里有看不见或在不同设备上显示不同的字'), { seal: true })
// 希伯来字母:逻辑上在销售额后面的「5」显示在销售额紧前面(读作 574,613.65元)。先量一下页面上确实这样显示,
// 说明这种伪造能把数读错
await tamper(samples.formats, '从右往左书写的字母把一位数挪到数字前面', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += cp(0x5d0)
segById(doc, 's5').text = `5${segById(doc, 's5').text}`
return { what: '销售额前面加一个希伯来字母、后面加「5」', seg: segById(doc, 's4') }
}), async (s, forged, page) => {
const gap = await page.evaluate((key) => {
const btn = document.querySelector(`button.num[data-num="${key}"]`)
const text = btn?.nextSibling?.firstChild
if (!btn || !text || text.nodeType !== 3 || !text.data.startsWith('5')) return null
const range = document.createRange()
range.setStart(text, 0)
range.setEnd(text, 1)
return Math.round(btn.getBoundingClientRect().left - range.getBoundingClientRect().right)
}, `${forged.node}|${forged.seg.id}`)
return [
['页面上「5」显示在销售额紧前面', gap !== null && Math.abs(gap) <= 2, `间距 ${gap}px`],
...naBecause('这一句里有从右往左书写的文字')(s, forged),
]
}, { seal: true })
// 列表缩进写得极大或写成负数(服务端按原文的缩进算级数、没有上限):最多缩进 8 级,列表里核对通过的数字都留在屏幕上、
// 照常通过,宽屏、窄屏都不横向溢出
for (const [label, depths] of [['列表缩进写成十万级', [100000, 3]], ['列表缩进写成负数', [-100000, -3]]]) {
const data = structuredClone(dataOf(samples.formats))
rewriteDoc(data, (doc) => {
const block = doc.blocks[2]
block.type = 'list'
block.units.forEach((unit, i) => {
unit.depth = depths[i]
if (i > 0) unit.segments.unshift({ kind: 'structural', id: `li${i}`, text: '\n- ' })
})
return { what: label }
})
for (const width of [1280, 390]) {
const opened = await openFile(writePage(`tampered-depth-${depths[0]}-${width}.html`,
withSeal(withData(samples.formats, data), data.meta.manifest_hash)), { width })
try {
const s = await stateOf(opened.page)
const boxes = await opened.page.evaluate(() => [...document.querySelectorAll('.doc li button.num')].map((b) => {
const r = b.getBoundingClientRect()
return [Math.round(r.left), Math.round(r.right)]
}))
check(`篡改${label}(${width} 宽):列表里的数字都在屏幕上、照常通过,页面不横向溢出`,
boxes.length === 7 && boxes.every(([l, r]) => l >= 0 && r <= width) && s.overflow <= 0
&& s.overall === 'pass' && s.text === '全部通过',
`${JSON.stringify(boxes)};溢出 ${s.overflow}px;${s.overall}:${s.text}`)
} finally {
await opened.ctx.close()
}
}
}
// 表格里一格的列号写成十万:只排有格的列,那一格紧接着前一列显示,不会被几万个空列挤到表格右边,也不建几十万个空格
{
const data = structuredClone(dataOf(samples.base))
const done = rewriteDoc(data, (doc) => {
const table = doc.blocks.find((b) => b.type === 'table')
const unit = table?.units.find((u) => u.loc?.row === 0 && u.loc?.col === 1)
const seg = unit?.segments.find((x) => x.kind === 'number' || x.kind === 'value')
if (!seg) return null
unit.loc = { row: 0, col: 100000 }
return { what: '表格第一行第二格的列号改成 100000', seg }
})
if (!done) {
check('篡改表格的列号:样本页 base 里找不到表格第一行第二格的数字', false)
} else {
const opened = await openFile(writePage('tampered-table-col.html', withSeal(withData(samples.base, data),
data.meta.manifest_hash)))
try {
const got = await opened.page.evaluate((key) => {
const btn = document.querySelector(`button.num[data-num="${key}"]`)
const wrap = btn?.closest('.table-wrap')
if (!btn || !wrap) return null
const b = btn.getBoundingClientRect()
const w = wrap.getBoundingClientRect()
return { result: btn.dataset.result, visible: b.left >= w.left && b.right <= w.left + wrap.clientWidth,
cols: wrap.querySelectorAll('thead th').length, cells: wrap.querySelectorAll('td').length }
}, `${done.node}|${done.seg.id}`)
check('篡改表格的列号:那一格紧接着前一列显示、照常通过,表格只有 3 列',
got?.result === 'pass' && got.visible && got.cols === 3 && got.cells === 24, JSON.stringify(got))
} finally {
await opened.ctx.close()
}
}
}
})
// ---------------------------------------------------------------- 记录页的导出按钮