check-manifest.mjs 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
  1. // 独立复算 manifest 校验(与 runtimeAssets.js 的 validateManifest 同规则)
  2. // 并追加「文件是否真实可达」检查 —— 这一项曾因遗漏导致整批图标不显示:
  3. // manifest 的 path 写错时,Vite dev 会把不存在的路径兜底成 200 + index.html,
  4. // 状态码仍是 200,光看格式校验与 HTTP 状态都发现不了,只有对磁盘路径 exists 才拦得住。
  5. import { readFileSync, existsSync, statSync } from 'node:fs'
  6. import { createHash } from 'node:crypto'
  7. const manifest = JSON.parse(readFileSync('public/cockpit/assets/manifest.json', 'utf8'))
  8. const REQUIRED = ['key', 'path', 'source', 'format', 'dimensions', 'version', 'hash', 'status', 'fallbackKey', 'core']
  9. const STATUSES = new Set(['formal', 'temporary', 'derived', 'fallback'])
  10. const HASH = /^sha256:[0-9a-f]{64}$/
  11. const safe = (p) => typeof p === 'string' && p.length > 0 && !p.startsWith('/') && !p.includes('..') && !/^[a-z][a-z0-9+.-]*:/i.test(p)
  12. // 运行时解析层拼的是 `${BASE_URL}cockpit/${path}`,所以磁盘基准是 public/cockpit
  13. const RUNTIME_ROOT = 'public/cockpit'
  14. const errors = []
  15. const keys = new Set()
  16. for (const e of manifest.entries) {
  17. for (const f of REQUIRED) if (!Object.prototype.hasOwnProperty.call(e, f)) errors.push(`${e.key}: 缺字段 ${f}`)
  18. if (keys.has(e.key)) errors.push(`${e.key}: 重复 key`)
  19. keys.add(e.key)
  20. if (!safe(e.path)) errors.push(`${e.key}: 路径不安全`)
  21. if (!HASH.test(e.hash)) errors.push(`${e.key}: hash 格式非法`)
  22. if (!STATUSES.has(e.status)) errors.push(`${e.key}: status 非法`)
  23. if (!(e.dimensions.width > 0 && e.dimensions.height > 0)) errors.push(`${e.key}: dimensions 非法`)
  24. if (e.fallbackKey !== null && !keys.has(e.fallbackKey)) errors.push(`${e.key}: fallback 缺失`)
  25. // ① 文件真实存在(拼出的运行时路径必须命中磁盘)
  26. const abs = `${RUNTIME_ROOT}/${e.path}`
  27. if (!existsSync(abs)) {
  28. errors.push(`${e.key}: 文件不存在 -> ${abs}`)
  29. continue
  30. }
  31. // ② 不是 dev server 的 HTML 兜底页
  32. const buf = readFileSync(abs)
  33. if (buf.length > 0 && buf[0] === 0x3c /* '<' */ && buf.subarray(0, 15).toString().includes('!DOCTYPE')) {
  34. errors.push(`${e.key}: 取到的是 HTML 文档而非素材(路径指向了 dev 兜底页)`)
  35. continue
  36. }
  37. // ③ 内容 hash 与登记值一致
  38. const actual = createHash('sha256').update(buf).digest('hex')
  39. if (`sha256:${actual}` !== e.hash) errors.push(`${e.key}: hash 不匹配(文件已被改动,需重跑 build-manifest.mjs)`)
  40. }
  41. console.log('entries:', manifest.entries.length, '| release:', manifest.release)
  42. console.log(errors.length ? `FAIL:\n - ${errors.join('\n - ')}` : 'manifest 校验通过(含文件可达性与 hash 一致性)')
  43. console.log('status 分布:', manifest.entries.map((e) => `${e.key}=${e.status}`).join(' '))
  44. if (errors.length) process.exitCode = 1