diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 60e3f524..37a10191 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -13,7 +13,8 @@ * render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字, * 两边都只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过。运行输入不随文件附带,照旧无法核对,但显示的字 * 照样拿引用的值核对,对不上判不通过。核对通过的字要原样显示在正文里(checkLayout):正文里根本不显示的判不通过, - * 紧挨着还有数字或负号、读起来不是这个值的判无法核对; + * 紧挨着还有数字、负号或显示成什么说不准的字,或者同一句里有改变文字方向的字,读起来可能不是这个值的判无法核对; + * 列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数; * ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称 * 绑不进封存,页面上写明它们取自运行记录。 * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩) @@ -174,7 +175,8 @@ shownBroken: '本页按系统的显示规则处理这个值时出错', /** 正文的排法(checkLayout):显示文字核对过了,读起来却不是这个值 */ shownGlued: '紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值', - shownTurned: '这一句里有改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样', + shownBlurred: '紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值', + shownTurned: '这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样', numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过', numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`, statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`, @@ -206,7 +208,7 @@ shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致', renderNote: '正文里带出处的数字和取值,本页按系统的显示规则(千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)' + '把口径卡或快照里的值(运行输入按引用的值)重新渲染一遍,与显示的文字逐字比对;核对通过的字在正文里原样显示,' - + '紧挨着它还有数字或负号的判无法核对。', + + '紧挨着它还有数字、负号或看不见的字,或者同一句里有改变文字方向的字,判无法核对。', schema: (s) => `文件格式 ${s}`, } @@ -731,31 +733,41 @@ 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 ?? ''))) - if (starts) items.push({ depth: Number(u.depth) || 0, units: [u] }) + 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 } */ + /** + * 表格:{ cells(位置 → 句子,一格可能被分成几句,按位置归拢), rows(表身各行的行号,表头是 -1,另算), cols(有格的 + * 列号,从小到大)}。服务端每行从第 0 列起一格不落,列号连着;只排有格的列:伪造的列号写成十万,那一格紧接着前一列 + * 显示,不会被几万个空列挤到表格右边看不见的地方(也不会建出几十万个空格、拖垮页面) + */ function tableCells(units) { const cells = new Map() const rows = [] - let cols = 0 + 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) - cols = Math.max(cols, (Number(loc.col) || 0) + 1) + 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 } + return { cells, rows, cols: [...cols].sort((a, b) => a - b) } } /** @@ -772,7 +784,7 @@ return listItems(units).map((item) => item.units) case 'table': { const { cells, rows, cols } = tableCells(units) - return [-1, ...rows].flatMap((r) => Array.from({ length: cols }, (_, c) => cells.get(`${r}:${c}`) || [])) + return [-1, ...rows].flatMap((r) => cols.map((c) => cells.get(`${r}:${c}`) || [])) } default: return [units] @@ -807,27 +819,72 @@ return plan } - // 判断紧挨着的字:数字(Python 的 \d 认的全都算,全角的、数学字母区的也是)、负号(半角、全角、小号、数学减号); - // 看不见的字(零宽空格这类格式字符、组合附加符号)跳过,隔着它们照样算紧挨着。改变文字方向的控制字符另算:它能把 - // 后面的一位数挪到数字前面显示,按字的先后看不出来 + // 紧挨着核对通过的数字、取值的字,按码点分几类(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 MINUS = /^[-\u2212\ufe63\uff0d]$/ - const INVISIBLE = unicode('^[\\p{Cf}\\p{Mn}\\p{Me}]$') - || /^[\u00ad\u0300-\u036f\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]$/ + 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) : []) { @@ -836,43 +893,75 @@ continue } for (const flow of flowsOf(block)) { - // 这一组显示出来的字(按码点:数学字母区的数字占两个 UTF-16 单元),逐个记下属于哪个片段:藏起来的不显示, - // 看不见的不参与判断。可点的数字自成一块,它里面的字改变不了外面的方向,只看别的片段里的方向控制字符 - const chars = [] - let turned = false + // 这一组显示出来的字(藏起来的排版标记不算),逐个记下属于哪个片段 + 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 - let c = text[k] - const code = text.charCodeAt(k) - if (code >= 0xd800 && code < 0xdc00 && k + 1 < at[i][1]) c = text.slice(k, ++k + 1) - shown += c - if (BIDI.test(c) && !items.has(seg)) turned = true - if (!INVISIBLE.test(c)) chars.push([c, seg]) + shown += text[k] + shownUnits.push([text[k], seg]) } if (items.has(seg)) items.get(seg).onPage = shown }) } - const ch = (k) => (k >= 0 && k < chars.length ? chars[k][0] : '') - const digit = (k) => DIGIT.test(ch(k)) - const inNumber = (k) => k >= 0 && k < chars.length && numeric(chars[k][1]) + // 拼成码点:代理对的两半在页面上挨着就显示成一个字(数学字母区的数字这类),分在两个文字片段里、中间隔着 + // 藏起来的排版标记也一样;不成对的半个单独算一个字(显示成替换字符),不和后面的字拼。可点的数字自成一块,它的 + // 字不和外面的拼 + 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 [first, last] = spans.get(seg) - const before = digit(first) && (digit(first - 1) || (/^[.,]$/.test(ch(first - 1)) && digit(first - 2))) - const sign = (digit(first) || MINUS.test(ch(first))) && MINUS.test(ch(first - 1)) - && !digit(first - 2) && !inNumber(first - 2) - const after = digit(last) && (digit(last + 1) || (/^[.,]$/.test(ch(last + 1)) && digit(last + 2))) - if (turned || before || sign || after) { - Object.assign(item, { result: NA, reason: T.numShownUnknown(turned ? T.shownTurned : T.shownGlued) }) + 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) }) } } } @@ -1436,15 +1525,15 @@ // 表头 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)) @@ -1769,7 +1858,7 @@ // 在 node 里载入(tests/test_bundle_shown.py 拿服务端的渲染逐条对照、整份核对):没有页面,交出核对用的纯函数就停 if (typeof document === 'undefined') { if (typeof module === 'object' && module) { - module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown } + module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown, listItems, tableCells } } return } diff --git a/backend/tests/test_bundle_shown.py b/backend/tests/test_bundle_shown.py index 430b1933..4989ac6a 100644 --- a/backend/tests/test_bundle_shown.py +++ b/backend/tests/test_bundle_shown.py @@ -12,8 +12,10 @@ ①–④ 照样对得上,⑤ 里那一项不通过,原因点名「正文显示「X」,引用的值是「Y」」;认不出的换算判无法核对,不判通过; - 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示 文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写; -- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过,紧挨着补数字、 - 负号、方向控制字符的判无法核对,区间、日期这类真报告里的写法照常通过。 +- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、 + 负号(长得像负号的横线也算,隔着不占位置的字也算),紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里 + 有改变文字方向的字(方向控制字符、希伯来文和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的 + 写法照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数。 数据全是虚构的。 """ @@ -65,6 +67,11 @@ function render(c) { 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) @@ -244,8 +251,12 @@ def segment(doc: dict[str, Any], ref: str) -> dict[str, Any]: def reseal(bundle: dict[str, Any], art: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]: """改过的文档重新落成工件:文档 id、引用它的事件(规范字符串和哈希)、封存清单哈希全部重算。和服务端写工件一样 - 用规范 JSON:① 到 ④ 都对得上,伪造只可能在 ⑤ 露馅。""" - raw = canonical_json(doc).encode() + 用规范 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"]: @@ -536,6 +547,11 @@ def test_inline_markup_cannot_hide_a_checked_value(): 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 = ""): @@ -553,21 +569,51 @@ def _glue_after(seg_id: str, extra: str): 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\u200b")), # 隔着看不见的零宽空格 + ("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", "\uff0d")), # 全角负号 + ("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)) @@ -576,33 +622,75 @@ def test_digits_glued_to_a_checked_value_cannot_pass(ref, change): assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref) -def test_direction_controls_next_to_checked_values_cannot_pass(): - """文字方向控制字符(从右往左覆盖)能把后面的一位数挪到数字前面显示,按字的先后看不出紧挨着:同一句里出现在 - 别的片段里,这一句的数字、取值判无法核对;别的段照常通过。""" +@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(0x202E) + by_id(doc, "s5")["text"] + 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)) - 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"} for n in got["numbers"]: - if n["ref"] in first: - assert n["result"] == "na" and n["reason"] == ("无法核对显示文字:这一句里有改变文字方向的控制字符," - "显示出来的顺序可能和核对过的不一样"), n + 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]]。") + "截至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"]) == 7 + assert len(got["numbers"]) == 9 assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"] @@ -652,6 +740,39 @@ def test_a_table_cell_outside_the_rows_and_columns_fails(): 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") diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index a439e72c..150cddda 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -21,7 +21,9 @@ // 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询、每种显示 // 格式各引用一处的经营月报、单位带尾随空白的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、 // 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、前面 -// 补一位数、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;在上面篡改 ④ +// 补一位数、隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、从右往左书写的字母把一位数 +// 挪到数字前面、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、 +// 表格的列号写成十万,核对通过的数字照样留在屏幕上(宽屏、窄屏都不横向溢出);在上面篡改 ④ // 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把 // 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、 // 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、 @@ -1017,6 +1019,117 @@ await section('样本页:篡改', async () => { && !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() + } + } + } }) // ---------------------------------------------------------------- 记录页的导出按钮