diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 9f6d9a5a..37a10191 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -8,8 +8,13 @@ * ③ 每份嵌入的工件:sha256(原字节) 等于工件 id; * ④ 引用链:报告文档的 id 是这个报告节点最后一次自查事件(已核对)记下的那份,文档记的运行、节点对得上; * 口径卡和快照的 id 出现在已核对的事件内容里,或者已核对的报告文档的证据目录里; - * ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端 - * engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版; + * ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值(判等照搬服务端 + * engine/expressions.py 的 cell_value / same_value);正文显示的字等于按服务端的显示规则(engine/evidence.py 的 + * render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字, + * 两边都只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过。运行输入不随文件附带,照旧无法核对,但显示的字 + * 照样拿引用的值核对,对不上判不通过。核对通过的字要原样显示在正文里(checkLayout):正文里根本不显示的判不通过, + * 紧挨着还有数字、负号或显示成什么说不准的字,或者同一句里有改变文字方向的字,读起来可能不是这个值的判无法核对; + * 列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数; * ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称 * 绑不进封存,页面上写明它们取自运行记录。 * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩) @@ -148,6 +153,31 @@ numMetricDiffers: '引用的值与口径卡里的值不一致', numNoCell: (why) => `快照里找不到这一格:${why}`, numCellDiffers: '引用的值与快照里那一格的值不一致', + /** cited 已经加好引号(空值写「空值(NULL)」);expected 和引用的值写法一样时不写(文字取值) */ + numShownDiffers: (shown, cited, expected) => `正文显示「${shown}」,引用的值是${cited}` + + (expected == null ? '' : `,按显示规则应显示为「${expected}」`), + numShownUnknown: (why) => `无法核对显示文字:${why}`, + shownFormat: (fmt) => `显示格式「${fmt}」无法识别`, + shownConv: (conv) => `换算「${conv}」无法识别`, + shownTextConv: (conv) => `文字不能换算为「${conv}」`, + shownRatioScale: (conv) => `比率不能换算为「${conv}」`, + shownPctUnit: '单位已是百分号,不能再换算为百分数', + shownDecimals: (d) => `小数位「${d}」无法识别`, + shownZero: '按显示规则会显示为 0', + shownTooLong: '数值位数超出可显示的范围', + shownNotFinite: '不是有限的数值', + shownInexact: '数值位数过多,本页无法精确核对', + shownUnit: '口径卡记下的单位不是文字', + shownShape: '这个值是结构化数据,本页无法按系统的显示规则核对', + shownDigits: '数字里有全角或其他文字的数字,本页无法按系统的显示规则核对', + shownDisplay: (v) => `报告文档记下的单元格显示规则版本「${v}」无法识别`, + shownOld: '这个浏览器版本较旧,请用新版浏览器打开本文件', + shownBroken: '本页按系统的显示规则处理这个值时出错', + /** 正文的排法(checkLayout):显示文字核对过了,读起来却不是这个值 */ + shownGlued: '紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值', + shownBlurred: '紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值', + shownTurned: '这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样', + numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过', numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`, statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`, statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`, @@ -168,14 +198,17 @@ tierAbsent: (seq) => `第 ${seq} 条事件(出具判定)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`, metaNeedsManifest: '封存清单没有通过核对,事件的类型和序号不可信,无法对照', dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值', - dCell: '快照里的值', dShown: '正文显示', dShownNote: '显示格式由服务端渲染', dArtifact: '所在工件', dReport: '所在报告', + dCell: '快照里的值', dShown: '正文显示', dShownSame: '与按显示规则渲染的结果一致', dExpected: '按显示规则应为', + dArtifact: '所在工件', dReport: '所在报告', dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`, nullValue: '空值(NULL)', appendedNote: '这些事件追加在封存之后,不在封存范围内,不参与清单核对,也不能当作证据。', appendedTag: '封存之后追加,不在封存范围内', shaSubtle: 'SHA-256 由浏览器内置实现计算(crypto.subtle)', shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致', - renderNote: '数字的显示格式(千分位、单位、小数位)由服务端渲染;本页核对的是引用的值,不重新排版。', + renderNote: '正文里带出处的数字和取值,本页按系统的显示规则(千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)' + + '把口径卡或快照里的值(运行输入按引用的值)重新渲染一遍,与显示的文字逐字比对;核对通过的字在正文里原样显示,' + + '紧挨着它还有数字、负号或看不见的字,或者同一句里有改变文字方向的字,判无法核对。', schema: (s) => `文件格式 ${s}`, } @@ -348,7 +381,7 @@ return textOf(m).replace(/\s+/g, ' ').trim() === textOf(t).replace(/\s+/g, ' ').trim() } - /** locate_cell:(那一格的原值, 列名)。列号换成列名;行是对象时按键取 */ + /** locate_cell:(那一格的原值, 列名, 这一格在行里的下标或键)。列号换成列名;行是对象时按键取 */ function locateCell(table, row, column) { if (!isObj(table) || !Array.isArray(table.rows)) throw new Error('快照里没有表格数据') const rows = table.rows @@ -367,12 +400,12 @@ } if (isObj(record)) { if (!Object.prototype.hasOwnProperty.call(record, name)) throw new Error(`没有列「${name}」`) - return [record[name], name] + return [record[name], name, name] } const index = columns.indexOf(name) if (index < 0) throw new Error(`没有列「${name}」`) if (!Array.isArray(record) || index >= record.length) throw new Error(`第 ${row} 行中没有「${name}」对应的单元格`) - return [record[index], name] + return [record[index], name, index] } function columnKind(table, column) { @@ -380,6 +413,564 @@ return typeof kind === 'string' ? kind : null } + // ------------------------------------------------------------------ 显示规则:照搬 engine/evidence.py + // + // 正文里带出处的数字和取值,字是服务端按引用的值渲染的(render_number / render_cell)。这里按同样的规则把口径卡、快照里 + // 的值再渲染一遍,和正文显示的字逐字比对:只改显示文字、重算全部哈希的伪造(74,613.65元 改成 84,613.65元、华南改成 + // 华东)在这里露馅。数照 Python 的 Decimal 算:系数用 BigInt 存,位数照原样记,四舍五入是 ROUND_HALF_UP(一半远离 0); + // 浮点取 repr 的那串字(和 JS 的 String 同一个最短写法),整数取 JSON 原文(超过 2^53 的,JSON.parse 读成 double 会丢位)。 + // 服务端渲染不出来的(引用就算解析不了,正常导出不会出现)和本页没有移植的写法一律抛 Unshown:核对结果是「无法核对」, + // 不是通过 + + /** 显示文字核对不了:不认识的格式、本页没有移植的写法 */ + class Unshown extends Error {} + + const FORMATS = ['plain', 'thousands', 'percent_of_ratio'] + const MISSING = '—' + /** 换算成万、亿:小数点左移几位 */ + const CONV_SCALE = { '万': 4, '亿': 8 } + const CONV_PLACES = /^\.([0-6])$/ + const MAX_PLACES = 100 + const MAX_DIGITS = 1000 + /** Python 的 int() 最多认这么多位:更长的整数文本 cell_value 照文本 */ + const INT_DIGITS = 4300 + /** Python 的空白(str.isspace(),re 的 \s 同一套):比 JS 的 \s 多 \x1c–\x1f、\x85,少 U+FEFF */ + const PY_SPACE = '\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000' + const PY_RUNS = new RegExp(`[${PY_SPACE}]+`, 'g') + const PY_EDGES = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g') + // 较早的浏览器没有 BigInt、不认 \p{…}:只是显示文字核对不了(判无法核对),整段脚本不能因此出错,别的照常核对 + const BIG = typeof BigInt === 'function' ? BigInt : null + const unicode = (source) => { + try { + return new RegExp(source, 'u') + } catch (e) { + return null + } + } + // Python 的 \d 认所有 Unicode 十进制数字:_NUMERIC_TEXT、_ISO_DATETIME 照这个认 + const PY_NUMERIC = unicode(`^[${PY_SPACE}]*-?(?:0|[1-9]\\p{Nd}*)(?:\\.\\p{Nd}+)?(?:E[+-]?\\p{Nd}+)?[${PY_SPACE}]*$`) + const ISO_DATETIME = unicode('^(\\p{Nd}{4}-\\p{Nd}{2}-\\p{Nd}{2})T(\\p{Nd}{2}:\\p{Nd}{2}(?::\\p{Nd}{2}(?:\\.\\p{Nd}+)?)?' + + '(?:Z|[+-]\\p{Nd}{2}:?\\p{Nd}{2})?)$') + + const pyStrip = (text) => text.replace(PY_EDGES, '') + /** _clean:文本值压成一行 */ + const pyClean = (text) => pyStrip(text.replace(PY_RUNS, ' ')) + + /** 十进制数 { neg, coef, exp }:(−1)^neg × coef × 10^exp,coef 是非负的 BigInt。text 是 JSON 的数、数字文本或 String(数) */ + function decimalOf(text) { + if (!BIG) throw new Unshown(T.shownOld) + const m = /^(-?)(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text) + if (!m) throw new Unshown(T.shownBroken) + const frac = m[3] || '' + return { neg: m[1] === '-', coef: BIG(m[2] + frac), exp: Number(m[4] || 0) - frac.length } + } + + const isZero = (d) => d.coef === BIG(0) + const width = (coef) => coef.toString().length + + /** Decimal.adjusted():最高位的指数 */ + const adjusted = (d) => (isZero(d) ? d.exp : width(d.coef) - 1 + d.exp) + + /** Decimal.quantize:舍入到 10^exp 这一位,ROUND_HALF_UP(一半远离 0) */ + function quantize(d, exp) { + if (d.exp >= exp) return { neg: d.neg, coef: d.coef * BIG(10) ** BIG(d.exp - exp), exp } + const shift = exp - d.exp + // 要舍掉的位数比系数还多:不到一半,舍成 0(不必真去算 10 的几万次方) + if (shift > width(d.coef)) return { neg: d.neg, coef: BIG(0), exp } + const unit = BIG(10) ** BIG(shift) + const up = (d.coef % unit) * BIG(2) >= unit ? BIG(1) : BIG(0) + return { neg: d.neg, coef: d.coef / unit + up, exp } + } + + /** Decimal.normalize:去掉尾零,0 写成 0E0 */ + function normalize(d) { + if (isZero(d)) return { neg: d.neg, coef: d.coef, exp: 0 } + let { coef, exp } = d + while (coef % BIG(10) === BIG(0)) { + coef /= BIG(10) + exp++ + } + return { neg: d.neg, coef, exp } + } + + /** format(Decimal, ',f'):定点写法,group 时整数部分千分位。0 不带负号(服务端 copy_abs),正指数的 0 写成「0」 */ + function fixed(d, group) { + const exp = isZero(d) && d.exp > 0 ? 0 : d.exp + let digits = d.coef.toString() + let int = digits + let frac = '' + if (exp > 0) int = digits + '0'.repeat(exp) + else if (exp < 0) { + digits = digits.padStart(1 - exp, '0') + int = digits.slice(0, digits.length + exp) + frac = digits.slice(digits.length + exp) + } + if (group) { + let out = '' + for (let end = int.length; end > 0; end -= 3) out = int.slice(Math.max(0, end - 3), end) + (out ? `,${out}` : '') + int = out + } + return (d.neg && !isZero(d) ? '-' : '') + int + (frac ? `.${frac}` : '') + } + + /** _digits:按小数位四舍五入后写出来。不是 0 的值写出来成了 0,服务端不写(引用算解析不了) */ + function digitsOf(d, places, group, strip) { + const q = quantize(d, places == null ? -10 : -places) + if (isZero(q) && !isZero(d)) throw new Unshown(T.shownZero) + return fixed(places == null || strip ? normalize(q) : q, group) + } + + const shifted = (d, k) => ({ neg: d.neg, coef: d.coef, exp: d.exp + k }) + + /** _render_numeric:万 / 亿留两位、去尾零,单位接在后面;pct、比率乘 100 加 %(不接单位);int、.N 改小数位 */ + function renderNumeric(d, unit, decimals, fmt, conv) { + if (adjusted(d) >= MAX_DIGITS) throw new Unshown(T.shownTooLong) + const ratio = fmt === 'percent_of_ratio' + const group = fmt !== 'plain' + if (typeof conv === 'string' && Object.prototype.hasOwnProperty.call(CONV_SCALE, conv)) { + if (ratio) throw new Unshown(T.shownRatioScale(conv)) + return digitsOf(shifted(d, -CONV_SCALE[conv]), 2, true, true) + conv + unit + } + if (conv === 'pct') { + if (unit.includes('%') || unit.includes('%')) throw new Unshown(T.shownPctUnit) + return `${digitsOf(shifted(d, 2), decimals != null ? Math.max(decimals - 2, 0) : 1, group, false)}%` + } + let places + if (conv === 'int') places = 0 + else if (typeof conv === 'string' && CONV_PLACES.test(conv)) places = Number(conv.slice(1)) + else if (conv) throw new Unshown(T.shownConv(conv)) + else places = decimals == null ? null : ratio ? Math.max(decimals - 2, 0) : decimals + if (ratio) return `${digitsOf(shifted(d, 2), places, group, false)}%` + return digitsOf(d, places, group, false) + unit + } + + /** _decimal:JSON 里的数 → 十进制数。token 是它在 JSON 里的原文:整数按原文取精确值,浮点按 repr 的那串字 */ + function pyDecimal(value, token) { + if (typeof token === 'string' && /^-?\d+$/.test(token)) return decimalOf(token) + if (!Number.isFinite(value)) throw new Unshown(T.shownNotFinite) + // 没有原文(找不到它在文件里的位置):浮点和 2^53 以内的整数,String 就是 Python 的写法;更大的整数不知道原来的位 + if (typeof token !== 'string' && Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new Unshown(T.shownInexact) + } + return decimalOf(String(value)) + } + + /** + * render_number:口径卡里的指标按卡上的格式渲染(单元格也走这里,格式是千分位)。token 是值在 JSON 里的原文(数才有), + * decimalsToken 是小数位的原文:写成 2.0 的在 Python 里是浮点,服务端不认 + */ + function renderNumber(value, token, { unit = '', decimals = null, decimalsToken = null, fmt = null, conv = null } = {}) { + const format = fmt || 'thousands' + if (!FORMATS.includes(format)) throw new Unshown(T.shownFormat(format)) + if (value == null) return MISSING + if (typeof value !== 'number') { + if (conv) throw new Unshown(T.shownTextConv(conv)) + if (typeof value === 'boolean') return value ? '是' : '否' + if (typeof value === 'string') return pyClean(value) + throw new Unshown(T.shownShape) + } + if (decimals != null && (typeof decimals !== 'number' || !Number.isInteger(decimals) || Math.abs(decimals) > MAX_PLACES + || (typeof decimalsToken === 'string' && !/^-?\d+$/.test(decimalsToken)))) { + throw new Unshown(T.shownDecimals(decimalsToken ?? decimals)) + } + return renderNumeric(pyDecimal(value, token), unit, decimals, format, conv) + } + + /** + * render_cell(shown(raw)):快照里的一格。kind 是查询时记下的列类型,display 是报告文档记下的单元格显示规则版本 + * (2 版起 ISO 日期时间的 T 换成空格)。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦ + */ + function renderCell(raw, token, conv, kind, display) { + if (display !== 1 && display !== 2) throw new Unshown(T.shownDisplay(display)) + if (!PY_NUMERIC || !ISO_DATETIME) throw new Unshown(T.shownOld) + let value = raw + if (display >= 2 && typeof raw === 'string') { + const m = ISO_DATETIME.exec(pyStrip(raw)) + if (m) value = `${m[1]} ${m[2]}` + } + if (typeof value === 'string' && kind !== 'text' && PY_NUMERIC.test(value)) { + const text = pyStrip(value) + // Python 把全角、阿拉伯文这些数字也当数(int('1٢٣') 是 123),本页只照 ASCII 算 + if (/[^\x00-\x7f]/.test(text)) throw new Unshown(T.shownDigits) + if (!/^-?\d+$/.test(text) || text.replace('-', '').length <= INT_DIGITS) { + return renderNumeric(decimalOf(text), '', null, 'thousands', conv) + } + } + // 对象、列表:服务端按 Python 的 json.dumps 写(键的先后、浮点的写法),本页没有移植 + if (isObj(value) || Array.isArray(value)) throw new Unshown(T.shownShape) + return renderNumber(value, token, { conv }).replace(/\|/g, '¦') + } + + /** + * JSON 文本里每个数的原文。交回和 JSON.parse 同形状的一棵树:对象是 Map(键重复时后面的算,同 JSON.parse),数组是 + * 数组,数是它的原文,别的值是 null。Python 写出的裸 NaN、Infinity、-Infinity 也认(同 parseJson)。读不了抛错 + */ + function numberTokens(text) { + const NUMBER = /-?(?:Infinity|(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|NaN/y + const WORD = /true|false|null/y + let at = 0 + const fail = () => { + throw new Error('JSON 格式有误') + } + const skip = () => { + while (at < text.length && ' \t\n\r'.includes(text[at])) at++ + } + const string = () => { + const from = at++ + while (at < text.length && text[at] !== '"') at += text[at] === '\\' ? 2 : 1 + if (at >= text.length) fail() + at++ + return text.slice(from, at) + } + const value = () => { + skip() + const c = text[at] + if (c === '{' || c === '[') { + const object = c === '{' + const close = object ? '}' : ']' + const out = object ? new Map() : [] + at++ + skip() + if (text[at] === close) { + at++ + return out + } + for (;;) { + if (object) { + skip() + if (text[at] !== '"') fail() + const key = JSON.parse(string()) + skip() + if (text[at++] !== ':') fail() + out.set(key, value()) + } else { + out.push(value()) + } + skip() + const next = text[at++] + if (next === close) return out + if (next !== ',') fail() + } + } + if (c === '"') { + string() + return null + } + for (const re of [NUMBER, WORD]) { + re.lastIndex = at + const m = re.exec(text) + if (m) { + at = re.lastIndex + return re === NUMBER ? m[0] : null + } + } + return fail() + } + const tree = value() + skip() + if (at !== text.length) fail() + return tree + } + + /** numberTokens 的树里一处的原文:path 是一串键和下标。不是数、找不到都是 null */ + function tokenAt(tree, path) { + let node = tree + for (const key of path) { + if (node instanceof Map) node = node.has(key) ? node.get(key) : null + else if (Array.isArray(node) && Number.isInteger(key)) node = key >= 0 && key < node.length ? node[key] : null + else return null + } + return typeof node === 'string' ? node : null + } + + // ------------------------------------------------------------------ 正文的排法:核对和显示共用 + // + // 正文怎样排,核对时就怎样算(不碰页面,node 里也算得了):一个数字、取值在正文里显示成什么字、紧挨着它的是什么字, + // 都按这一节的函数算;显示时(renderBlock / renderUnit)也走它们,两边不会各算各的。 + + // 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、斜体、删除线,和前端 run/Markdown.tsx 的规则一致。 + // 斜体要求左边不是 *(否则 **粗体** 会被它先拆掉):前端用后行断言,这里不用(较早的 Safari 不认,构造正则时 + // 就会报错,整页核对跟着失败),匹配到以后自己看左边那个字 + const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__' + + '|\\*([^*\\n]+?)\\*(?!\\*)|~~([^~]+?)~~' + const BIT = { code: 1, bold: 2, strike: 4, link: 8, em: 16 } + // 列表项的开头:行首的 -、*、+ 或者序号 + const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ + + /** 整句算一次行内样式:服务端把一句切成了好几个片段,粗体、代码可能跨片段,每个片段取自己那一截 */ + function inlineStyles(text) { + const hide = new Uint8Array(text.length) + const style = new Uint8Array(text.length) + const walk = (from, to, inherited, depth) => { + const part = text.slice(from, to) + const re = new RegExp(INLINE, 'g') + let last = 0 + for (let m = re.exec(part); m; m = re.exec(part)) { + const [whole, ticks, code, linkText, , bold1, bold2, italic, strike] = m + if (italic !== undefined && m.index > 0 && part[m.index - 1] === '*') { + // 左边紧挨着 *:不算斜体(前端后行断言排除的情形),从下一个字接着找 + re.lastIndex = m.index + 1 + continue + } + for (let i = last; i < m.index; i++) style[from + i] = inherited + const inner = code ?? linkText ?? bold1 ?? bold2 ?? italic ?? strike ?? '' + const base = from + m.index + const off = code !== undefined ? ticks.length : whole.indexOf(inner) + for (let i = 0; i < whole.length; i++) hide[base + i] = 1 + for (let i = 0; i < inner.length; i++) hide[base + off + i] = 0 + const bit = code !== undefined ? BIT.code : linkText !== undefined ? BIT.link + : bold1 !== undefined || bold2 !== undefined ? BIT.bold : italic !== undefined ? BIT.em : BIT.strike + if (code !== undefined || linkText !== undefined || depth >= 3) { + for (let i = 0; i < inner.length; i++) style[base + off + i] = inherited | bit + } else { + walk(base + off, base + off + inner.length, inherited | bit, depth + 1) + } + last = m.index + whole.length + } + for (let i = last; i < part.length; i++) style[from + i] = inherited + } + walk(0, text.length, 0, 0) + return { hide, style } + } + + // 列表最多显示这么多级缩进(每级 14px)。服务端按原文的缩进算级数(两个空格一级),没有上限:伪造的级数写成十万、 + // 负数,那一项会被挤出屏幕或挤到左边看不见的地方,读的人看不到核对通过的数,页面却照样通过;窄屏上也会横向溢出 + const MAX_DEPTH = 8 + + /** 列表的各项:一项从行首写着列表符号或序号的结构片段开始,后面的句子接在这一项里。depth 是显示的缩进级数 */ + function listItems(units) { + const items = [] + for (const u of units) { + const lead = Array.isArray(u.segments) ? u.segments[0] : null + const starts = !items.length || (isObj(lead) && lead.kind === 'structural' && ITEM_START.test(String(lead.text ?? ''))) + const depth = Math.min(Math.max(Math.floor(Number(u.depth)) || 0, 0), MAX_DEPTH) + if (starts) items.push({ depth, units: [u] }) + else items[items.length - 1].units.push(u) + } + return items + } + + /** + * 表格:{ cells(位置 → 句子,一格可能被分成几句,按位置归拢), rows(表身各行的行号,表头是 -1,另算), cols(有格的 + * 列号,从小到大)}。服务端每行从第 0 列起一格不落,列号连着;只排有格的列:伪造的列号写成十万,那一格紧接着前一列 + * 显示,不会被几万个空列挤到表格右边看不见的地方(也不会建出几十万个空格、拖垮页面) + */ + function tableCells(units) { + const cells = new Map() + const rows = [] + const cols = new Set() + for (const u of units) { + const loc = isObj(u.loc) ? u.loc : { row: 0, col: 0 } + push(cells, `${loc.row}:${loc.col}`, u) + const col = Number(loc.col) + if (Number.isInteger(col) && col >= 0) cols.add(col) + if (loc.row >= 0 && !rows.includes(loc.row)) rows.push(loc.row) + } + rows.sort((a, b) => a - b) + return { cells, rows, cols: [...cols].sort((a, b) => a - b) } + } + + /** + * 一块里连着显示的几组句子:段落、标题、引用整块一组,列表每项一组,表格每格一组(只有表头和表身各行各列上的格 + * 显示出来)。分隔线不显示句子,代码块按原文显示、不标数字:都交回空的 + */ + function flowsOf(block) { + const units = Array.isArray(block.units) ? block.units.filter(isObj) : [] + switch (block.type) { + case 'hr': + case 'code': + return [] + case 'list': + return listItems(units).map((item) => item.units) + case 'table': { + const { cells, rows, cols } = tableCells(units) + return [-1, ...rows].flatMap((r) => cols.map((c) => cells.get(`${r}:${c}`) || [])) + } + default: + return [units] + } + } + + const plans = new WeakMap() + + /** + * 一句怎样显示:{ segs(结构片段以外的片段), text(连起来的字), styles(行内样式), at(每个片段在 text 里的起止), + * raw }。数字、取值片段里有字会被行内标记藏起来时(伪造的链接把它塞进不显示的链接地址、取值里本身带 * 这类),整句 + * 按未排版的原文显示(raw):核对过的字一个不少地露出来,读的人不会只看到旁边伪造的那个数。核对和显示各算一次会是 + * 同一个结果,算过的记下来 + */ + function unitPlan(unit) { + let plan = plans.get(unit) + if (plan) return plan + const segs = (Array.isArray(unit.segments) ? unit.segments : []).filter((s) => isObj(s) && s.kind !== 'structural') + const at = [] + let text = '' + for (const seg of segs) { + const piece = String(seg.text ?? '') + at.push([text.length, text.length + piece.length]) + text += piece + } + let styles = inlineStyles(text) + const raw = segs.some((seg, i) => (seg.kind === 'number' || seg.kind === 'value') + && styles.hide.subarray(at[i][0], at[i][1]).some((x) => x === 1)) + if (raw) styles = { hide: new Uint8Array(text.length), style: new Uint8Array(text.length) } + plan = { segs, text, styles, at, raw } + plans.set(unit, plan) + return plan + } + + // 紧挨着核对通过的数字、取值的字,按码点分几类(kindOf): + // - digit:数字。Python 的 \d 认的全都算(全角的、数学字母区的、阿拉伯文的也是); + // - minus:负号。横线类标点(连字符、各种破折号,全角的、小号的也算;波浪形的不算),减号类符号(数学减号、上下标 + // 负号、修饰字母负号、粗减号),以及显示出来同样是一道横线的字:Unicode 易混字符表里映射到「-」「ー」的(汉字「一」、 + // 假名长音、制表符的横线这类),和在 Chrome 的常用字体里逐个画出来看是一道横线、高度和负号相当的字; + // - sep:半角逗号、句点和长得一样的窄标点(低位单引号、单点前导符、小号的逗号句点这类,取自易混字符表):「1,」后面 + // 接着数字也算紧挨着。全角的「,」「.」不算:字宽,中文里两个数之间本来就常用「,」隔开; + // - skip:显示时不占位置的字(零宽空格、连接符、变体选择符、韩文填充符这类格式字符和默认不显示的字),隔着它们照样 + // 算紧挨着; + // - blur:显示成什么说不准的字:控制字符(制表、换行这几个空白除外)、未分配和私用区的字、孤立的代理项、组合附加符号 + // (叠在前一个字上:叠在空格上能在数字前面画出一道横线)、可选连字符(在它那里换行时显示成连字符)、发丝空格、对象 + // 替换符,以及在 Chrome 的常用字体里量出来几乎不占宽度的几个字。各家浏览器、字体把它们显示成数字、横线还是什么都 + // 不显示说不准:隔着它们照样往外找数字、负号;找不到的也判无法核对,原因另写; + // - other:别的字(汉字、字母、空格、别的标点)把数字隔开。 + // 改变文字方向的控制字符(BIDI)和从右往左书写的文字(RTL:希伯来文、阿拉伯文这类)另算:它们能把一位数挪到数字的 + // 另一边显示(逻辑上在数字后面的「5」显示在数字前面),按字的先后看不出来,同一句里出现就判无法核对 + const DIGIT = unicode('^\\p{Nd}$') || /^[0-9\uff10-\uff19]$/ + const DASH_LIKE = '\\u02d7\\u0c7c\\u1173\\u172d\\u174d\\u180a\\u1b78\\u1bc7\\u1bf3\\u1c7c\\u2043\\u207b\\u208b\\u2212' + + '\\u23af\\u23ba-\\u23bd\\u23e4\\u2500\\u2501\\u2504\\u2505\\u2508\\u2509\\u254c\\u254d\\u2550\\u2574\\u2576\\u2578' + + '\\u257a\\u257c\\u257e\\u2796\\u29ff\\u2cba\\u2cbb\\u2d67\\u2f00\\u30fc\\u3127\\u3161\\u3192\\u31d0\\u4e00\\ua7f7' + + '\\uaa7d\\uff70\\uffda' + const DASH_LIKE_ASTRAL = '\\u{10110}\\u{10191}\\u{11049}\\u{1104b}\\u{11052}\\u{110be}\\u{13404}\\u{1bc03}\\u{1bc08}' + + '\\u{1bc0d}\\u{1bc86}\\u{1d369}' + const MINUS = unicode(`^(?![\\u301c\\u3030])[\\p{Pd}${DASH_LIKE}${DASH_LIKE_ASTRAL}]$`) + || new RegExp(`^[-\\u2010-\\u2015\\u2e3a\\u2e3b\\ufe58\\ufe63\\uff0d${DASH_LIKE}]$`) + const SEP = unicode('^[.,\\u00b8\\u201a\\u2024\\u2e12\\ua4f8\\ua4f9\\ua60e\\uabec\\ufe50\\ufe52\\u{16f53}\\u{1d16d}]$') + || /^[.,\u00b8\u201a\u2024\u2e12\ua4f8\ua4f9\ua60e\uabec\ufe50\ufe52]$/ + // 变体选择符只换前一个字的字形(文字样式还是表情样式),算 skip:表情后面常带一个,不能因此把数字判成核对不了 + const VARIATION = unicode('^[\\u180b-\\u180d\\u180f\\ufe00-\\ufe0f\\u{e0100}-\\u{e01ef}]$') + || /^[\u180b-\u180d\u180f\ufe00-\ufe0f]$/ + const BLUR_EXTRA = '\\u00ad\\u02dd\\u1843\\u1cd3\\u200a\\u2044\\ufffc' + const BLUR = unicode(`^(?![\\t\\n\\f\\r])[\\p{Cc}\\p{Cn}\\p{Co}\\p{Cs}\\p{Mn}\\p{Me}${BLUR_EXTRA}]$`) + || new RegExp('^(?![\\t\\n\\f\\r])[\\x00-\\x1f\\x7f-\\x9f\\u0300-\\u036f\\u0483-\\u0489\\u1ab0-\\u1aff\\u1dc0-\\u1dff' + + `\\u20d0-\\u20ff\\ud800-\\udfff\\ue000-\\uf8ff\\ufe20-\\ufe2f\\ufff0-\\ufff8${BLUR_EXTRA}]$`) + const SKIP = unicode('^[\\p{Cf}\\p{Default_Ignorable_Code_Point}]$') + || /^[\u034f\u061c\u115f\u1160\u17b4\u17b5\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\u3164\ufeff\uffa0]$/ + const BIDI = /^[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]$/ + const RTL = unicode('^[\\u0590-\\u08ff\\ufb1d-\\ufdff\\ufe70-\\ufefe\\u{10800}-\\u{10fff}\\u{1e800}-\\u{1efff}]$') + || /^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefe]$/ + + /** 一个字(码点)算哪一类,见上 */ + function kindOf(c) { + if (VARIATION.test(c)) return 'skip' + if (DIGIT.test(c)) return 'digit' + if (MINUS.test(c)) return 'minus' + if (SEP.test(c)) return 'sep' + if (BLUR.test(c)) return 'blur' + return SKIP.test(c) ? 'skip' : 'other' + } + + const NOWHERE = { at: -1, kind: null, blurred: false } + + /** + * ⑤ 的最后一步:核对通过的数字、取值,读的人在正文里看到的就该是核对过的那串字。items 是这份报告的 片段 → 核对结果, + * 每一项记下正文里显示出来的字(onPage)。 + * - 正文里根本不显示它(分隔线里、表格里不在各行各列上的格):伪造的字可以写在别处,读的人看不到核对过的那个数。 + * 服务端导出时不会出现(分隔线不带句子,表格的格都在行列上),判不通过; + * - 紧挨着的字里还有数字或负号(前面补一位、后面接一位或一段小数、前面加负号;隔着不占位置的字、显示时藏起来的排版 + * 标记也算紧挨着),紧挨着的是显示成什么说不准的字,或者同一句里有改变文字方向的字:读起来的数可能不止核对过的 + * 这几位,判无法核对——真报告里也可能有(放行的数字、日期紧贴着引用),不能说成被改过。两个数之间的连字符是区间, + * 不算负号。 + * 代码块按原文显示、不标数字,照旧 + */ + function checkLayout(doc, items) { + const numeric = (seg) => seg.kind === 'number' || seg.kind === 'value' + const soft = (kind) => kind === 'skip' || kind === 'blur' + for (const block of Array.isArray(doc.blocks) ? doc.blocks.filter(isObj) : []) { + if (block.type === 'code') { + for (const unit of Array.isArray(block.units) ? block.units.filter(isObj) : []) { + for (const seg of unitPlan(unit).segs) if (items.has(seg)) items.get(seg).onPage = String(seg.text ?? '') + } + continue + } + for (const flow of flowsOf(block)) { + // 这一组显示出来的字(藏起来的排版标记不算),逐个记下属于哪个片段 + const shownUnits = [] + for (const unit of flow) { + const { segs, text, styles, at } = unitPlan(unit) + segs.forEach((seg, i) => { + let shown = '' + for (let k = at[i][0]; k < at[i][1]; k++) { + if (styles.hide[k]) continue + shown += text[k] + shownUnits.push([text[k], seg]) + } + if (items.has(seg)) items.get(seg).onPage = shown + }) + } + // 拼成码点:代理对的两半在页面上挨着就显示成一个字(数学字母区的数字这类),分在两个文字片段里、中间隔着 + // 藏起来的排版标记也一样;不成对的半个单独算一个字(显示成替换字符),不和后面的字拼。可点的数字自成一块,它的 + // 字不和外面的拼 + const chars = [] + for (let k = 0; k < shownUnits.length; k++) { + const [c, seg] = shownUnits[k] + const next = shownUnits[k + 1] + const paired = next && c >= '\ud800' && c < '\udc00' && next[0] >= '\udc00' && next[0] < '\ue000' + && (next[1] === seg || (!items.has(seg) && !items.has(next[1]))) + chars.push(paired ? [c + shownUnits[++k][0], seg] : [c, seg]) + } + // 可点的数字自成一块,它里面的字改变不了外面的方向:只看别的片段 + const turned = chars.some(([c, seg]) => !items.has(seg) && (BIDI.test(c) || RTL.test(c))) + const kinds = chars.map(([c]) => kindOf(c)) + const spans = new Map() + chars.forEach(([, seg], k) => { + if (spans.has(seg)) spans.get(seg)[1] = k + else spans.set(seg, [k, k]) + }) + // 从 k 往 step 方向找下一个看得见的字:skip 跳过,blur 也越过去、记下来。{ at, kind, blurred } + const look = (k, step) => { + let blurred = false + for (let j = k + step; j >= 0 && j < chars.length; j += step) { + if (kinds[j] === 'skip') continue + if (kinds[j] === 'blur') { + blurred = true + continue + } + return { at: j, kind: kinds[j], blurred } + } + return { ...NOWHERE, blurred } + } + for (const [seg, item] of items) { + if (item.result !== PASS || !spans.has(seg)) continue + const [s0, s1] = spans.get(seg) + // 片段自己的头一个、末一个字:越过它自己头尾不占位置、说不准的字(取值里本来就带的组合符号这类) + let first = s0 + while (first < s1 && soft(kinds[first])) first++ + let last = s1 + while (last > first && soft(kinds[last])) last-- + const lead = kinds[first] === 'digit' || kinds[first] === 'minus' + const tail = kinds[last] === 'digit' + const l1 = look(s0, -1) + const l2 = l1.at >= 0 ? look(l1.at, -1) : NOWHERE + const r1 = look(s1, 1) + const r2 = r1.at >= 0 ? look(r1.at, 1) : NOWHERE + const before = kinds[first] === 'digit' && (l1.kind === 'digit' || (l1.kind === 'sep' && l2.kind === 'digit')) + const sign = lead && l1.kind === 'minus' && l2.kind !== 'digit' && !(l2.at >= 0 && numeric(chars[l2.at][1])) + const after = tail && (r1.kind === 'digit' || (r1.kind === 'sep' && r2.kind === 'digit')) + // 数字头尾紧挨着(或隔着一个逗号、句点)的是说不准的字:它本身就可能显示成数字、负号 + const near = (a, b) => a.blurred || (a.kind === 'sep' && b.blurred) + const blurred = (lead && near(l1, l2)) || (tail && near(r1, r2)) + if (turned || before || sign || after || blurred) { + const why = turned ? T.shownTurned : before || sign || after ? T.shownGlued : T.shownBlurred + Object.assign(item, { result: NA, reason: T.numShownUnknown(why) }) + } + } + } + } + for (const item of items.values()) { + if (item.onPage === undefined && item.result === PASS) Object.assign(item, { result: FAIL, reason: T.numNotShown }) + } + } + // ------------------------------------------------------------------ 核对 function push(map, key, value) { @@ -399,16 +990,30 @@ return found } + /** 文档里的每个片段和它在文档 JSON 里的位置(引用的值按原文取,见 numberTokens) */ function* segmentsOf(doc) { - for (const block of Array.isArray(doc.blocks) ? doc.blocks : []) { - for (const unit of isObj(block) && Array.isArray(block.units) ? block.units : []) { - for (const seg of isObj(unit) && Array.isArray(unit.segments) ? unit.segments : []) { - if (isObj(seg)) yield seg + const list = (holder, key) => (isObj(holder) && Array.isArray(holder[key]) ? holder[key] : []) + for (const [b, block] of list(doc, 'blocks').entries()) { + for (const [u, unit] of list(block, 'units').entries()) { + for (const [s, seg] of list(unit, 'segments').entries()) { + if (isObj(seg)) yield [seg, ['blocks', b, 'units', u, 'segments', s]] } } } } + /** 一份嵌入工件里每个数的原文(numberTokens):用到时才算,只算一次;算不出来是 null */ + function tokensOf(item) { + if (item.tokens === undefined) { + try { + item.tokens = numberTokens(decoder.decode(fromBase64(item.a.b64))) + } catch (e) { + item.tokens = null + } + } + return item.tokens + } + /** shownSeal:页面顶部直接写出的封存哈希(服务端渲染进模板,不执行脚本也看得到的那一份) */ async function verify(data, digest, shownSeal) { const out = { @@ -581,12 +1186,16 @@ const doc = trusted.get(report) || (art && art.parsed && isObj(art.loose) ? art.loose : null) out.docs.push({ report, art, link, doc, trusted: trusted.has(report) }) if (!doc) continue - for (const seg of segmentsOf(doc)) { + for (const [seg, path] of segmentsOf(doc)) { if (seg.kind !== 'number' && seg.kind !== 'value') continue - const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained) + // 引用的值在文档 JSON 里的原文:超过 2^53 的整数照原文写进原因和出处面板 + const citedToken = () => tokenAt(tokensOf(art), [...path, 'cite', 'value']) + const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained, citedToken) : { result: NA, reason: T.numDocUntrusted, source: describe(seg, doc) } out.numbers.push({ ...check, seg, report, key: `${report.node_id}|${seg.id}` }) } + // 核对通过的,还要看读的人在正文里看到的是不是这串字 + if (trusted.has(report)) checkLayout(doc, new Map(out.numbers.filter((n) => n.report === report).map((n) => [n.seg, n]))) } // ⑥ 运行信息 @@ -608,12 +1217,23 @@ return T.srcOther } - function checkNumber(seg, doc, arts, chained) { + /** + * citedToken() 交回引用的值在文档 JSON 里的原文(数才有)。引用的值对上了,再核正文显示的字(checkShown):两样都对上 + * 才算通过 + */ + function checkNumber(seg, doc, arts, chained, citedToken = () => null) { const cite = isObj(seg.cite) ? seg.cite : null const source = describe(seg, doc) if (!cite) return { result: NA, reason: T.numUncited, source } if (cite.status !== 'resolved') return { result: NA, reason: T.numUnresolved(cite.reason), source } - if (cite.kind === 'input') return { result: NA, reason: T.numInput, source } + if (cite.kind === 'input') { + // 运行输入不随文件附带,引用的值核对不了,照旧无法核对。正文显示的字照样拿引用的值按服务端的写法(render_number + // 不分组、不接单位,可带换算)重新渲染比对:只改显示文字(统计周期、地区这类)的伪造在这里露馅,判不通过 + const info = { source, kind: cite.kind, cite, citedToken: citedToken() } + const shown = checkShown(info, seg, () => renderNumber(cite.value, info.citedToken, + { fmt: 'plain', conv: convOf(cite) })) + return shown.result === FAIL ? shown : { ...shown, result: NA, reason: T.numInput } + } if (cite.kind !== 'metric' && cite.kind !== 'cell') return { result: NA, reason: T.numOther, source } const entry = own(doc.catalog, cite.alias) const info = { source, kind: cite.kind, cite } @@ -631,26 +1251,81 @@ return { ...info, result: NA, reason: art.a.masked ? art.reason : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable } } if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained } + info.citedToken = citedToken() if (cite.kind === 'metric') { const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric') const metrics = Array.isArray(art.json.metrics) ? art.json.metrics : [] - const metric = metrics.find((m) => isObj(m) && m.id === metricId) + const at = metrics.findIndex((m) => isObj(m) && m.id === metricId) + const metric = metrics[at] info.card = art.json if (!metric) return { ...info, result: FAIL, reason: T.numNoMetric } info.truth = metric.value - return sameValue(cite.value, metric.value) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numMetricDiffers } + info.truthToken = tokenAt(tokensOf(art), ['metrics', at, 'value']) + if (!sameValue(cite.value, metric.value)) return { ...info, result: FAIL, reason: T.numMetricDiffers } + // 口径卡上记着这个指标的单位、小数位、显示格式,报告里另写的换算记在引用上 + return checkShown(info, seg, () => renderNumber(metric.value, info.truthToken, { + unit: unitOf(metric.unit), decimals: metric.decimals, + decimalsToken: tokenAt(tokensOf(art), ['metrics', at, 'decimals']), fmt: metric.format, conv: convOf(cite) })) } const loc = isObj(cite.locator) ? cite.locator : {} info.snapshot = art.json + let cell try { - const [raw, column] = locateCell(art.json, loc.row, loc.column) + const [raw, column, key] = locateCell(art.json, loc.row, loc.column) info.row = loc.row info.column = column info.truth = cellValue(raw, columnKind(art.json, column)) + info.truthToken = tokenAt(tokensOf(art), ['rows', loc.row, key]) + // 落成数字文本的(DECIMAL)按数比对,出处面板照快照的原文写:读成 double 后超过 2^53 的订单号会丢位, + // 和一位不差的「引用的值」并排时自相矛盾 + if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw) + cell = { raw, column } } catch (e) { return { ...info, result: FAIL, reason: T.numNoCell(e.message) } } - return sameValue(cite.value, info.truth) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numCellDiffers } + if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numCellDiffers } + const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1 + return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite), + columnKind(art.json, cell.column), display)) + } + + /** 引用上记的换算(|万、|pct 这类),没写是 null */ + const convOf = (cite) => (own(cite, 'conv') === undefined ? null : cite.conv) + + /** 口径卡的单位:服务端是 str(unit or ""),不是文字的本页不认 */ + function unitOf(unit) { + if (!unit) return '' + if (typeof unit !== 'string') throw new Unshown(T.shownUnit) + return unit + } + + /** 数的写法:超过 2^53 的整数照 JSON 原文(String 会丢位),别的同 String */ + const numberText = (value, token) => (typeof token === 'string' && /^-?\d+$/.test(token) && !Number.isSafeInteger(value) + ? token : String(value)) + + /** 出处面板里的值:数同 numberText,别的同 valueText */ + const exactText = (value, token) => (typeof value === 'number' ? numberText(value, token) : valueText(value)) + + /** + * 正文显示的字和按显示规则渲染出来的字逐字比对,两边都只去掉首尾空白:口径卡的单位带尾随空白(「元 」)时,服务端照原样 + * 接在数后面,正文和渲染出来的都是「74,613.65元 」,只去一边的话原样导出的报告也对不上。渲染不出来的判无法核对: + * 不认识的格式不能当成核对过了。对上了记 shownOk(出处面板据此注明一致);对不上的点名两样:正文显示的字、引用的值 + * (加上按显示规则应显示的字,和引用的值写法一样时不写) + */ + function checkShown(info, seg, render) { + let expected + try { + expected = render() + } catch (e) { + return { ...info, result: NA, reason: T.numShownUnknown(e instanceof Unshown ? e.message : T.shownBroken) } + } + info.expected = expected + const shown = String(seg.text ?? '') + if (pyStrip(shown) === pyStrip(expected)) return { ...info, result: PASS, shownOk: true } + const value = info.cite.value + const cited = typeof value === 'number' ? numberText(value, info.citedToken) : value == null ? null : textOf(value) + return { ...info, result: FAIL, reason: T.numShownDiffers(shown, cited == null ? T.nullValue : `「${cited}」`, + expected === cited ? null : expected) } } /** @@ -784,51 +1459,6 @@ // ------------------------------------------------------------------ 渲染:报告正文 - // 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、斜体、删除线,和前端 run/Markdown.tsx 的规则一致。 - // 斜体要求左边不是 *(否则 **粗体** 会被它先拆掉):前端用后行断言,这里不用(较早的 Safari 不认,构造正则时 - // 就会报错,整页核对跟着失败),匹配到以后自己看左边那个字 - const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__' - + '|\\*([^*\\n]+?)\\*(?!\\*)|~~([^~]+?)~~' - const BIT = { code: 1, bold: 2, strike: 4, link: 8, em: 16 } - // 列表项的开头:行首的 -、*、+ 或者序号 - const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ - - /** 整句算一次行内样式:服务端把一句切成了好几个片段,粗体、代码可能跨片段,每个片段取自己那一截 */ - function inlineStyles(text) { - const hide = new Uint8Array(text.length) - const style = new Uint8Array(text.length) - const walk = (from, to, inherited, depth) => { - const part = text.slice(from, to) - const re = new RegExp(INLINE, 'g') - let last = 0 - for (let m = re.exec(part); m; m = re.exec(part)) { - const [whole, ticks, code, linkText, , bold1, bold2, italic, strike] = m - if (italic !== undefined && m.index > 0 && part[m.index - 1] === '*') { - // 左边紧挨着 *:不算斜体(前端后行断言排除的情形),从下一个字接着找 - re.lastIndex = m.index + 1 - continue - } - for (let i = last; i < m.index; i++) style[from + i] = inherited - const inner = code ?? linkText ?? bold1 ?? bold2 ?? italic ?? strike ?? '' - const base = from + m.index - const off = code !== undefined ? ticks.length : whole.indexOf(inner) - for (let i = 0; i < whole.length; i++) hide[base + i] = 1 - for (let i = 0; i < inner.length; i++) hide[base + off + i] = 0 - const bit = code !== undefined ? BIT.code : linkText !== undefined ? BIT.link - : bold1 !== undefined || bold2 !== undefined ? BIT.bold : italic !== undefined ? BIT.em : BIT.strike - if (code !== undefined || linkText !== undefined || depth >= 3) { - for (let i = 0; i < inner.length; i++) style[base + off + i] = inherited | bit - } else { - walk(base + off, base + off + inner.length, inherited | bit, depth + 1) - } - last = m.index + whole.length - } - for (let i = last; i < part.length; i++) style[from + i] = inherited - } - walk(0, text.length, 0, 0) - return { hide, style } - } - function styledSlice(text, styles, from, to) { const frag = document.createDocumentFragment() let i = from @@ -853,19 +1483,15 @@ .filter((s) => isObj(s) && s.kind !== 'structural').map((s) => String(s.text ?? '')).join('') function renderUnit(unit, ctx) { - const segs = (Array.isArray(unit.segments) ? unit.segments : []).filter((s) => isObj(s) && s.kind !== 'structural') - const text = segs.map((s) => String(s.text ?? '')).join('') - const styles = inlineStyles(text) + const { segs, text, styles, at } = unitPlan(unit) const frag = document.createDocumentFragment() - let pos = 0 - for (const seg of segs) { - const from = pos - pos += String(seg.text ?? '').length - let body = styledSlice(text, styles, from, pos) + segs.forEach((seg, i) => { + let body = styledSlice(text, styles, at[i][0], at[i][1]) if (seg.strong) body = h('strong', {}, body) - const number = ctx.numbers.get(`${ctx.report.node_id}|${seg.id}`) + // 按片段本身找它的核对结果,不按片段 id:伪造的文字片段写上和数字一样的 id,也借不到它的结果、显示不成可点的数字 + const number = ctx.numbers.get(seg) frag.append(number ? numberButton(number, body) : h('span', {}, body)) - } + }) return frag } @@ -886,16 +1512,9 @@ case 'code': return h('pre', {}, h('code', {}, units.map(unitText).join(''))) case 'list': { - const items = [] - for (const u of units) { - const lead = Array.isArray(u.segments) ? u.segments[0] : null - const starts = !items.length || (isObj(lead) && lead.kind === 'structural' && ITEM_START.test(String(lead.text ?? ''))) - if (starts) items.push({ depth: Number(u.depth) || 0, units: [u] }) - else items[items.length - 1].units.push(u) - } const list = h(block.ordered ? 'ol' : 'ul') if (block.ordered && Number.isInteger(block.start) && block.start !== 1) list.setAttribute('start', String(block.start)) - for (const item of items) { + for (const item of listItems(units)) { const li = h('li', {}, item.units.map(inline)) if (item.depth) li.style.marginLeft = `${item.depth * 14}px` list.append(li) @@ -903,27 +1522,18 @@ return list } case 'table': { - // 表头 row = -1;一格可能被分成几句,按位置归拢 - const cells = new Map() - const rowIds = [] - let cols = 0 - for (const u of units) { - const loc = isObj(u.loc) ? u.loc : { row: 0, col: 0 } - push(cells, `${loc.row}:${loc.col}`, u) - cols = Math.max(cols, (Number(loc.col) || 0) + 1) - if (loc.row >= 0 && !rowIds.includes(loc.row)) rowIds.push(loc.row) - } - rowIds.sort((a, b) => a - b) + // 表头 row = -1;一格可能被分成几句,按位置归拢(tableCells) + const { cells, rows: rowIds, cols } = tableCells(units) const textAt = (r, c) => (cells.get(`${r}:${c}`) || []).map(unitText).join('').replace(/[*`]/g, '').trim() - const numeric = Array.from({ length: cols }, (_, c) => { + const numeric = new Set(cols.filter((c) => { const texts = rowIds.map((r) => textAt(r, c)).filter(Boolean) return texts.length > 0 && texts.every((t) => NUMERIC_CELL.test(t)) - }) - const cell = (tag, r, c) => h(tag, { class: tag === 'td' && numeric[c] ? 'n' : null }, + })) + const cell = (tag, r, c) => h(tag, { class: tag === 'td' && numeric.has(c) ? 'n' : null }, (cells.get(`${r}:${c}`) || []).map(inline)) return h('div', { class: 'table-wrap' }, h('table', {}, - h('thead', {}, h('tr', {}, Array.from({ length: cols }, (_, c) => cell('th', -1, c)))), - h('tbody', {}, rowIds.map((r) => h('tr', {}, Array.from({ length: cols }, (_, c) => cell('td', r, c))))))) + h('thead', {}, h('tr', {}, cols.map((c) => cell('th', -1, c)))), + h('tbody', {}, rowIds.map((r) => h('tr', {}, cols.map((c) => cell('td', r, c))))))) } default: return h('p', {}, units.map(inline)) @@ -966,16 +1576,21 @@ function detailOf(number) { const cite = isObj(number.seg.cite) ? number.seg.cite : null const card = number.card - // 只有带出处的数字是服务端按引用的值渲染的;模型自己写的数、表格里的文字取值都是原文 - const rendered = number.seg.kind === 'number' && cite && cite.status === 'resolved' + // 显示文字核对过的(引用的值对上了,按显示规则渲染得出来):一致时注明,对不上时另列应显示的字。没有出处的数、 + // 核对不了的引用不写:那些字没人重新渲染过。一致不等于通过:运行输入的显示文字对得上,引用的值仍核对不了;紧挨着 + // 还有数字的,这处的字本身也对得上,读起来却不是这个值 + const checked = typeof number.expected === 'string' + const same = checked && number.shownOk === true const rows = [ [T.dResult, [badge(number.result), number.reason ? ` ${number.reason}` : '']], [T.dSource, number.source], number.node ? [T.dNode, nodeLabel(number.node)] : null, card ? [T.dCaliber, [card.caliber || '—', card.caliber_version ? ` ${card.caliber_version}` : ''].join('')] : null, - cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, valueText(cite.value)] : null, - 'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, valueText(number.truth)] : null, - [T.dShown, [String(number.seg.text ?? ''), rendered ? h('span', { class: 'muted' }, `(${T.dShownNote})`) : null]], + cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, exactText(cite.value, number.citedToken)] : null, + 'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, + number.truthText ?? exactText(number.truth, number.truthToken)] : null, + checked && !same ? [T.dExpected, number.expected] : null, + [T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : null]], number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null, [T.dReport, nodeLabel(number.report.node_id)], ] @@ -984,14 +1599,22 @@ if (snap && typeof snap.sql === 'string' && snap.sql) parts.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, snap.sql)) const record = snap && Number.isInteger(number.row) && Array.isArray(snap.rows) ? snap.rows[number.row] : undefined if (Array.isArray(record) || isObj(record)) { - // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮 + // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮。数照 JSON + // 原文写(同上面「快照里的值」):超过 2^53 的订单号读成 double 会丢位,和一位不差的「引用的值」对不上 let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String) if (isObj(record) && !columns.length) columns = Object.keys(record) - const cellAt = (c, i) => (isObj(record) ? own(record, c) : record[i]) + const art = view.out.artifacts.find((a) => a.a.id === number.artifact) + const tokens = art ? tokensOf(art) : null + const cellText = (c, i) => { + const value = isObj(record) ? own(record, c) : record[i] + if (value == null) return '—' + if (typeof value !== 'number') return textOf(value) + return numberText(value, tokenAt(tokens, ['rows', number.row, isObj(record) ? c : i])) + } parts.push(h('p', { class: 'sub' }, T.dRow(number.row)), h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, columns.map((c) => h('th', { class: c === number.column ? 'hit' : null }, c)))), - h('tbody', {}, h('tr', {}, columns.map((c, i) => h('td', { class: c === number.column ? 'hit' : null }, - cellAt(c, i) == null ? '—' : textOf(cellAt(c, i))))))))) + h('tbody', {}, h('tr', {}, columns.map((c, i) => h('td', { class: c === number.column ? 'hit' : null, + 'data-cell': c }, cellText(c, i)))))))) } return parts } @@ -1086,11 +1709,12 @@ root.append(h('p', { class: 'note' }, T.noReports)) return } - const numbers = new Map(out.numbers.map((n) => [n.key, n])) for (const item of out.docs) { const label = nodeLabel(item.report.node_id) // 报告的徽标连同正文里的数字一起算:重算了全部哈希的伪造只在 ⑤ 露馅,标题旁不能还写着通过 const nums = out.numbers.filter((n) => n.report === item.report) + // 正文里按片段本身找核对结果(renderUnit) + const numbers = new Map(nums.map((n) => [n.seg, n])) const docResult = [item.art, item.link].some((x) => x && x.result === FAIL) || !item.art ? FAIL : item.trusted ? PASS : NA const result = summarize([{ result: docResult }, ...nums]) const box = h('article', { class: 'report', 'data-report': item.report.node_id, 'data-result': result }, @@ -1231,6 +1855,14 @@ document.documentElement.setAttribute('data-verified', '') } + // 在 node 里载入(tests/test_bundle_shown.py 拿服务端的渲染逐条对照、整份核对):没有页面,交出核对用的纯函数就停 + if (typeof document === 'undefined') { + if (typeof module === 'object' && module) { + module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown, listItems, tableCells } + } + return + } + // 脚本在 head 里:一开始执行就标上 data-js,样式表据此把「尚未核对」换成「正在核对」;文档解析完再核对 document.documentElement.setAttribute('data-js', '') const start = () => main().catch((e) => { diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index e3b48b8f..e8cae88b 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -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) diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index c8edc293..eaefd5b9 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -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万元 " diff --git a/backend/tests/test_bundle_shown.py b/backend/tests/test_bundle_shown.py new file mode 100644 index 00000000..4989ac6a --- /dev/null +++ b/backend/tests/test_bundle_shown.py @@ -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 diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index b577f0b4..150cddda 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -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() + } + } + } }) // ---------------------------------------------------------------- 记录页的导出按钮