check-fonts.mjs 7.2 KB

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