From 183814d2c1a2e936d80ad52c11dfa8e7f7489fa1 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 04:04:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E9=AA=8C=E8=AF=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=A1=A5=E5=85=A8=EF=BC=9A=E5=88=97=E5=87=BA?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E6=B8=85=E5=8D=95=E6=A0=B8=E5=AF=B9=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E6=96=87=E4=BE=9B=E9=A1=B5=E9=9D=A2=E5=A4=96=E8=87=AA?= =?UTF-8?q?=E8=A1=8C=E9=87=8D=E7=AE=97=EF=BC=8C=E5=86=99=E6=98=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=BB=93=E6=9E=9C=EF=BC=8C=E8=87=AA=E6=9F=A5=E8=AE=A4?= =?UTF-8?q?=E6=B3=95=E4=B8=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=B8=80=E8=87=B4?= =?UTF-8?q?=EF=BC=8C=E6=89=93=E5=8D=B0=E6=97=B6=E6=94=B6=E8=B5=B7=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 封存清单一项可展开参与核对的每一行(序号|类型|节点|内容哈希),读的人不信页面脚本也能复制出去用别的工具算; check-bundle 在页面之外用 node 重算这段原文,等于封存哈希 - 元信息加「运行结果」(已完成 / 已失败 / 已取消):失败的运行报告照样导得出去,读的人要知道它没跑完 - 报告节点认最后一次记下了文档的自查,和服务端、证据接口同一个认法,后面多一条没记文档的自查不再误报不通过; 运行记录里有自查、文件里却没附带文档的报告照实报不通过 - 核对下不了结论(数据块坏了、内置 SHA-256 自检不过)时也标上核对结束,等结果的一方不必干等;打印时收起出处面板 - check-bundle:拿不到导出页面时,依赖它的几节跳过并注明原因,不再一个原因报五次 --- backend/app/bundle/template.html | 5 +++++ backend/app/bundle/verifier.js | 16 ++++++++++++---- scripts/check-bundle.mjs | 26 +++++++++++++++++++------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/backend/app/bundle/template.html b/backend/app/bundle/template.html index cbed683d..8f94d85c 100644 --- a/backend/app/bundle/template.html +++ b/backend/app/bundle/template.html @@ -112,6 +112,11 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font .foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); } .foot p { margin: 4px 0; } .noscript { max-width: 920px; margin: 24px auto; padding: 0 16px; color: var(--bad); } +@media print { + .detail, .close, details > summary { display: none !important; } + .page { max-width: none; padding: 0; } + .num { text-decoration-color: currentColor !important; background: none !important; } +} @media (max-width: 760px) { .detail { top: auto; left: 0; width: auto; max-height: 72vh; border-left: 0; border-top: 1px solid var(--border); border-radius: 14px 14px 0 0; } } diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index bc0feeb9..afde59f2 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -30,6 +30,7 @@ const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' } const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' } const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' } + const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消' } const EVENT_LABEL = { 'run.started': '运行开始', 'run.finished': '运行结束', 'run.failed': '运行失败', 'run.cancelled': '运行取消', 'run.interrupted': '等待人工介入', 'run.resumed': '恢复运行', 'node.started': '节点开始', 'node.finished': '节点完成', @@ -53,7 +54,8 @@ + '它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID ' + `${id}),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致` + `(接口 GET /api/runs/${id}/verify)。`, - metaRun: '运行 ID', metaWorkflow: '工作流', metaClass: '运行类别', metaVersionHash: '版本哈希', metaStarted: '开始', + metaRun: '运行 ID', metaWorkflow: '工作流', metaClass: '运行类别', metaStatus: '运行结果', metaVersionHash: '版本哈希', + metaStarted: '开始', metaFinished: '结束', metaRange: '封存范围', metaTier: '出具档位', metaExported: '导出时间', // 序号不连续(模型的流式输出占序号、不落库),后面注明范围内实际有几条。说法同记录页的封存凭证 range: (seq, count) => `第 1–${seq} 条事件(范围内 ${count} 条)`, @@ -76,6 +78,7 @@ eventMismatch: '内容与记录的哈希不一致', eventNotJson: '内容不是有效的 JSON', manifestComputed: '重算结果', + manifestLines: (n) => `查看参与清单核对的 ${n} 行(每行 序号|类型|节点|内容哈希,可复制后用任意 SHA-256 工具自行计算)`, manifestOk: '与封存清单哈希一致', manifestMismatch: '与封存清单哈希不一致:事件被增删、改动或换了顺序', beyondSeal: (seq) => `第 ${seq} 条事件的序号超出封存范围`, @@ -344,7 +347,7 @@ const computed = await digest(utf8(lines.join('\n'))) const beyond = order.find((ev) => !(ev.seq <= meta.manifest_seq)) out.manifest = { - computed, count: lines.length, + computed, count: lines.length, lines, result: computed === meta.manifest_hash && !beyond ? PASS : FAIL, reason: computed !== meta.manifest_hash ? T.manifestMismatch : beyond ? T.beyondSeal(beyond.seq) : T.manifestOk, } @@ -814,6 +817,7 @@ [T.metaRun, h('code', {}, String(meta.run_id || ''))], [T.metaWorkflow, String(meta.workflow_name || '—')], [T.metaClass, meta.version != null ? `${runClass} v${meta.version}` : runClass], + meta.status ? [T.metaStatus, STATUS_LABEL[meta.status] || String(meta.status)] : null, meta.version_hash ? [T.metaVersionHash, h('code', {}, String(meta.version_hash))] : null, [T.metaStarted, when(meta.started_at)], [T.metaFinished, when(meta.finished_at)], @@ -868,8 +872,12 @@ root.append(checkBlock('events', T.checkEvents, T.sumEvents(out.events.length), out.events, (i) => `${T.chainByEvent(i.ev.seq, eventWhat(i.ev))}`)) const m = out.manifest - root.append(checkBlock('manifest', T.checkManifest, T.sumManifest(m.count), [m], - () => [T.manifestComputed, ' ', h('code', {}, m.computed)])) + const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(m.count), [m], + () => [T.manifestComputed, ' ', h('code', {}, m.computed)]) + // 参与核对的原文照拼法列出来:读的人不信这一页的脚本,也能复制出去用别的工具算一遍 + manifest.append(h('details', {}, h('summary', {}, T.manifestLines(m.count)), + h('pre', { class: 'sql', 'data-manifest-lines': '' }, m.lines.join('\n')))) + root.append(manifest) root.append(checkBlock('artifacts', T.checkArtifacts, T.sumCount(out.artifacts.length, '份'), out.artifacts, (i) => [KIND_LABEL[i.a.kind] || i.a.kind, i.a.node_id ? ` · ${nodeLabel(i.a.node_id)}` : '', ' · ', h('code', {}, short(i.a.id))])) diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 0e0d757b..79d6a7be 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -171,9 +171,11 @@ await section('导出', async () => { `运行 ${run.id.slice(0, 8)},${picked.reports} 份报告、${picked.cited} 个带出处的数字`) const before = await getJson(`/runs/${run.id}/events`).catch(() => null) const resp = await fetch(`${API}/runs/${run.id}/bundle.html`) - html = await resp.text() - check('导出接口返回 200 和 text/html', resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? ''), - `HTTP ${resp.status},${Math.round(Buffer.byteLength(html) / 1024)} KB`) + const body = await resp.text() + const ok = resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? '') + check('导出接口返回 200 和 text/html', ok, `HTTP ${resp.status},${Math.round(Buffer.byteLength(body) / 1024)} KB`) + // 只有真拿到了页面,后面几节才有东西可查;没拿到的原因上面已经报了,后面几节跳过,不重复报 + html = ok && DATA_RE.test(body) ? body : '' const disposition = resp.headers.get('content-disposition') ?? '' const star = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1] let name = '' @@ -206,8 +208,14 @@ await section('导出', async () => { let tamperable = null // 篡改要用:原始数据 +const skipped = () => { + if (html) return false + note('没有拿到导出的页面(原因见上一节),这一节跳过') + return true +} + await section('断网打开', async () => { - if (!html) throw new Error('上一节没有拿到导出的页面') + if (skipped()) return tamperable = dataOf(html) const { ctx, page, requests, errors } = await openFile(writePage('bundle.html', html)) try { @@ -222,6 +230,10 @@ await section('断网打开', async () => { check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | ')) check('用的是浏览器内置的 SHA-256', s.sha === 'subtle', String(s.sha)) check('封存哈希醒目地写在页面上', await page.locator('#seal-hash').innerText() === tamperable.meta.manifest_hash) + // 页面列出的清单原文:拿出去用别的工具(这里是 node 的 crypto)算,等于封存哈希——读的人可以不信页面上的脚本 + const lines = await page.locator('[data-manifest-lines]').textContent() + check('页面列出的清单原文在页面之外重算,等于封存哈希', + createHash('sha256').update(lines ?? '', 'utf8').digest('hex') === tamperable.meta.manifest_hash) check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(await page.locator('#seal-note').innerText()) && /\/api\/runs\/[^/]+\/verify/.test(await page.locator('#seal-note').innerText())) if (s.numbers) { @@ -244,7 +256,7 @@ await section('断网打开', async () => { }) await section('去掉 crypto.subtle', async () => { - if (!html) throw new Error('没有导出的页面') + if (skipped()) return const { ctx, page, requests, errors } = await openFile(join(TMP, 'bundle.html'), { noSubtle: true }) try { const s = await stateOf(page) @@ -278,7 +290,7 @@ async function tampered(label, mutate, expectFail, keepPass = []) { } await section('篡改', async () => { - if (!tamperable) throw new Error('没有导出的页面') + if (skipped() || !tamperable) return // ① 改一条带规范字符串的事件内容(不动它记的哈希):只有 ① 变红,清单照样对得上 await tampered('一条事件的内容', (data) => { const ev = data.events.find((e) => e.type === 'run.started' && typeof e.canonical === 'string') @@ -351,7 +363,7 @@ const luminance = (rgb) => { } await section('深浅色与窄屏', async () => { - if (!html) throw new Error('没有导出的页面') + if (skipped()) return for (const colorScheme of ['light', 'dark']) { const { ctx, page } = await openFile(join(TMP, 'bundle.html'), { colorScheme, width: 390 }) try {