// 批次 4(票 17)字体自查 —— 复算授权门禁与回退链,并做对抗式反证。 // // 为什么独立复算而不是引 runtimeAssets.js:与批次 1 的 check-manifest.mjs 同一纪律 —— // 自查脚本若与被测代码共用实现,绿的是同一段逻辑,不是事实。这里从磁盘上的 // manifest.json + woff2 文件重新算一遍,再与源码里的取值对照。 // // 用法:node .scratch/lanhu-cockpit-asset-replacement/tools/check-fonts.mjs import { readFileSync, existsSync, statSync } from 'node:fs' import { createHash } from 'node:crypto' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const ROOT = join(here, '..', '..', '..') const RUNTIME_ROOT = join(ROOT, 'public', 'cockpit') const manifest = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'assets', 'manifest.json'), 'utf8')) const fonts = manifest.fonts const tokens = readFileSync(join(ROOT, 'src', 'modules', 'cockpit', 'styles', 'tokens.css'), 'utf8') const errors = [] const ok = (cond, msg) => { if (!cond) errors.push(msg) } // ---- ① 字体块存在且结构完整 ---- if (!fonts) { errors.push('manifest 缺 fonts 块') } const licenses = (fonts && fonts.licenses) || [] const fontEntries = (fonts && fonts.entries) || [] const licenseById = new Map(licenses.map((l) => [l.id, l])) // ---- ② 证据齐全性(规格 11:标识/名称/版本齐全;只查齐全性,不判合法性)---- const REQUIRED = ['id', 'family', 'version', 'holder', 'evidence', 'evidenceHash'] const HASH = /^sha256:[0-9a-f]{64}$/ const safe = (p) => typeof p === 'string' && p.length > 0 && !p.startsWith('/') && !p.includes('..') && !/^[a-z][a-z0-9+.-]*:/i.test(p) for (const lic of licenses) { for (const f of REQUIRED) ok(!!lic[f], `许可证据 ${lic.id}: 缺字段 ${f}`) ok(HASH.test(lic.evidenceHash || ''), `许可证据 ${lic.id}: evidenceHash 格式非法`) ok(safe(lic.evidence), `许可证据 ${lic.id}: evidence 路径不安全`) const covers = lic.covers || [] ok(covers.includes('web-embedding'), `许可证据 ${lic.id}: 未声明 web-embedding`) ok(covers.includes('commercial-use'), `许可证据 ${lic.id}: 未声明 commercial-use`) // 归档的许可证文本必须真实存在且 hash 一致 const abs = join(RUNTIME_ROOT, lic.evidence) if (!existsSync(abs)) { errors.push(`许可证据 ${lic.id}: 归档文件不存在 -> ${lic.evidence}`) continue } const actual = createHash('sha256').update(readFileSync(abs)).digest('hex') ok(`sha256:${actual}` === lic.evidenceHash, `许可证据 ${lic.id}: 归档文本 hash 不匹配`) } // ---- ③ 授权门禁:有证据/无证据分支结果唯一(与 runtimeAssets 同口径独立复算)---- const gateOf = (entry) => { if (!entry.licenseId) return { resolvable: false, code: 'font-license-id-missing' } const lic = licenseById.get(entry.licenseId) if (!lic) return { resolvable: false, code: 'font-license-unknown' } const missing = REQUIRED.filter((f) => !lic[f]) if (!HASH.test(lic.evidenceHash || '') || !safe(lic.evidence) || missing.length) { return { resolvable: false, code: 'font-license-incomplete' } } return { resolvable: true, code: 'ok' } } const gated = fontEntries.map((e) => ({ key: e.key, ...gateOf(e) })) for (const g of gated) ok(g.resolvable, `门禁拒绝:${g.key} -> ${g.code}`) // ---- ④ 字体文件真实可达 + hash 一致(沿用批次 1 的「HTTP 200 也可能是 HTML 兜底」教训)---- for (const e of fontEntries) { const abs = join(RUNTIME_ROOT, e.path) if (!existsSync(abs)) { errors.push(`${e.key}: 字体文件不存在 -> ${e.path}`) continue } const buf = readFileSync(abs) const actual = createHash('sha256').update(buf).digest('hex') ok(`sha256:${actual}` === e.hash, `${e.key}: 字体文件 hash 不匹配(需重跑 build-fonts.py)`) // woff2 魔数 'wOF2';取到 HTML 兜底页时首字节是 '<' const magic = buf.subarray(0, 4).toString('latin1') ok(magic === 'wOF2', `${e.key}: 不是 woff2(魔数 ${JSON.stringify(magic)},疑似 dev 兜底页)`) ok(statSync(abs).size > 0, `${e.key}: 空文件`) } // ---- ⑤ 系统字体未打包:产物目录里不得出现系统字体的实体文件 ---- const SYSTEM_FONT_NAMES = /yahei|pingfang|bahnschrift|segoe|din[_ -]?alternate|simsun|simhei|msyh/i for (const e of fontEntries) { ok(!SYSTEM_FONT_NAMES.test(e.path), `${e.key}: 路径像是打包了系统字体 -> ${e.path}`) ok(!SYSTEM_FONT_NAMES.test(e.family), `${e.key}: 字族名像是系统字体 -> ${e.family}`) } const bundled = fontEntries.every((e) => e.path.startsWith('assets/fonts/')) ok(bundled, '字体条目未全部落在 assets/fonts/ 下(越出受控资源目录)') // ---- ⑥ 语义槽位未改动:槽位名/回退链仍与规格 03 口径一致,Web 字体只在回退链之前 ---- const fontLine = (tokens.match(/--font:\s*([^;]+);/) || [])[1] || '' const numLine = (tokens.match(/--num:\s*([^;]+);/) || [])[1] || '' ok(fontLine.includes('"Noto Sans SC Subset"'), '--font 未前置 Web 字体') ok(fontLine.includes('"Microsoft YaHei UI"'), '--font 丢了系统回退链') ok(numLine.includes('"Rajdhani Subset"'), '--num 未前置 Web 字体') ok(numLine.includes('"Bahnschrift SemiBold"'), '--num 丢了系统回退链') // Web 字体必须排在系统字体之前,否则子集外汉字永远走不到系统回退 ok(fontLine.indexOf('Noto Sans SC Subset') < fontLine.indexOf('Microsoft YaHei UI'), '--font 的 Web 字体未排在系统回退链之前') ok(numLine.indexOf('Rajdhani Subset') < numLine.indexOf('Bahnschrift SemiBold'), '--num 的 Web 字体未排在系统回退链之前') // 槽位名本身未被改名 ok(/--font\s*:/.test(tokens) && /--num\s*:/.test(tokens), '字体语义槽位名被改动') // ---- ⑦ 对抗式反证:门禁必须能失败(防假绿)---- // 把证据字段抹掉后重新判定,必须由「可解析」翻转为「不可解析」;翻不过来说明门禁是空壳。 const victim = fontEntries[0] const broken = { ...victim, licenseId: '' } ok(gateOf(broken).resolvable === false, '对抗式反证失败:去掉 licenseId 后仍被判为可解析(门禁是空壳)') const brokenEvidence = JSON.parse(JSON.stringify(fonts)) brokenEvidence.entries[0].licenseId = 'nonexistent-license' const adversarial = gateOf(brokenEvidence.entries[0]) ok(adversarial.resolvable === false && adversarial.code === 'font-license-unknown', '对抗式反证失败:未知 licenseId 未被拒绝') // ---- 报告 ---- console.log('字体条目:', fontEntries.length, '| 许可证据:', licenses.length, '| 子集用字:', fonts ? fonts.subsetTextChars : 0) console.log('门禁逐条:', gated.map((g) => `${g.key}=${g.code}`).join(' ')) console.log('嵌入字体:', fontEntries.map((e) => `${e.key} ${(e.bytes / 1024).toFixed(1)}KB ${e.hash.slice(7, 19)}`).join('\n ')) console.log('--font:', fontLine.trim()) console.log('--num :', numLine.trim()) console.log(errors.length ? `FAIL:\n - ${errors.join('\n - ')}` : '字体自查通过(证据齐全性 + 门禁分支唯一 + 文件可达与 hash + 回退链 + 对抗式反证)') if (errors.length) process.exitCode = 1