| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122 |
- // 批次 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
|