Add firmware SHA hash tool

This commit is contained in:
mid
2026-08-08 02:45:44 +08:00
parent 55bc6ba15d
commit 290da46567
18 changed files with 821 additions and 18 deletions
+2
View File
@@ -9,6 +9,8 @@ Key rules:
- Keep each tool independently lazy-loaded through `src/tools/registry.ts`. - Keep each tool independently lazy-loaded through `src/tools/registry.ts`.
- Put testable computation in `src/lib/` and cover it in `tests/`. - Put testable computation in `src/lib/` and cover it in `tests/`.
- Preserve the documented file, record, image, random-output, syntax-rendering, and request-size limits. Update the limits table and tests when any limit changes. - Preserve the documented file, record, image, random-output, syntax-rendering, and request-size limits. Update the limits table and tests when any limit changes.
- Preserve the firmware Hash byte stream contract: sorted effective blocks without gap filling, or an 8-byte binary uint32 address/length header per block; lengths exclude headers and Big Endian is the default.
- Treat every Intel HEX address and length operation as checked uint32 arithmetic. Reject overflow, conflicting overlap, malformed control records, excessive records and excessive blocks before allocating result buffers.
- Treat asynchronous replacement and clearing as race-sensitive: stale Worker, file, image, or key-generation results must never replace current state. - Treat asynchronous replacement and clearing as race-sensitive: stale Worker, file, image, or key-generation results must never replace current state.
- Keep IDs synchronized across `shared/tool-catalog.json`, `shared/default-navigation.json`, and `src/tools/registry.ts`. - Keep IDs synchronized across `shared/tool-catalog.json`, `shared/default-navigation.json`, and `src/tools/registry.ts`.
- Use the shared UI components and existing responsive styles. - Use the shared UI components and existing responsive styles.
+12 -1
View File
@@ -2,12 +2,23 @@
本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。 本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。
## Unreleased ## 0.0.6 - 2026-08-08
### Added ### Added
- 增加统一的 Windows PowerShell 启动与更新脚本,提供固定的生产配置、Git 快进更新、锁定依赖重装和构建后启动流程。 - 增加统一的 Windows PowerShell 启动与更新脚本,提供固定的生产配置、Git 快进更新、锁定依赖重装和构建后启动流程。
- 增加 Windows Server 非 Docker 部署、启动和更新说明。 - 增加 Windows Server 非 Docker 部署、启动和更新说明。
- 增加独立的固件 Hash 工具,计算 Intel HEX 总有效数据和各 block 的 SHA-256 / SHA-512。
- 增加 8 字节二进制 Block 校验头模式,头部包含 32 位绝对起始地址和 32 位数据长度,默认 Big Endian,并支持 Little Endian。
### Fixed
- Intel HEX 解析器增加全部非空记录上限、显式 uint32 地址空间检查和无符号扩展地址运算,拒绝越过 `0xFFFFFFFF` 的数据。
### Verification
- 68 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出和控制记录边界覆盖。
- Vue/TypeScript 类型检查和生产构建通过。
## 0.0.5 - 2026-08-07 ## 0.0.5 - 2026-08-07
+21 -6
View File
@@ -2,7 +2,7 @@
一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。 一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。
当前应用版本:`0.0.5`。 当前应用版本:`0.0.6`。
## 内置工具 ## 内置工具
@@ -10,6 +10,7 @@
- Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组 - Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组
- AES-128-CMAC(RFC 4493) - AES-128-CMAC(RFC 4493)
- CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX - CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX
- 固件 SHA-256 / SHA-512,支持 Intel HEX 有效数据和带地址/长度的 Block 校验头
- RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组 - RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组
- 密码学安全随机数,支持连续 Hex、空格 Hex 和代码字节数组 - 密码学安全随机数,支持连续 Hex、空格 Hex 和代码字节数组
- JSON 格式化、压缩、校验和语法着色 - JSON 格式化、压缩、校验和语法着色
@@ -55,7 +56,7 @@ CHANGELOG.md 版本变更、修复和验证记录
## 多人使用与状态边界 ## 多人使用与状态边界
- 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。 - 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。
- CRC、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。 - CRC、固件 Hash、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。
- `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。 - `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。
- 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。 - 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。
- 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。 - 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。
@@ -68,8 +69,9 @@ CHANGELOG.md 版本变更、修复和验证记录
| 功能 | 当前限制 | 超限行为 | | 功能 | 当前限制 | 超限行为 |
| --- | --- | --- | | --- | --- | --- |
| CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 | | CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 |
| Intel HEX 数据记录 | 最多 500,000 条 | Worker 停止解析并显示错误 | | Intel HEX 非空记录 | 最多 500,000 条 | Worker 停止解析并显示错误 |
| Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 | | Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 |
| 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 选择或计算时拒绝 |
| PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 | | PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 |
| PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 | | PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 |
| 安全随机数 | 每次 1 至 65,536 字节 | 拒绝无效长度 | | 安全随机数 | 每次 1 至 65,536 字节 | 拒绝无效长度 |
@@ -173,10 +175,10 @@ docker compose restart web-tools
## 离线镜像 ## 离线镜像
若交付目录包含 `personal-web-tools-0.0.5-amd64.tar`,可直接导入: 若交付目录包含 `personal-web-tools-0.0.6-amd64.tar`,可直接导入:
```bash ```bash
docker load -i personal-web-tools-0.0.5-amd64.tar docker load -i personal-web-tools-0.0.6-amd64.tar
docker compose up -d --no-build docker compose up -d --no-build
``` ```
@@ -220,10 +222,23 @@ npm audit --audit-level=low
`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404、导航 API、请求大小限制及跨站写入拦截。 `npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404、导航 API、请求大小限制及跨站写入拦截。
自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。 自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、固件 Hash 与大小端 Block 头向量、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。
## 增加工具 ## 增加工具
### 固件 Hash 固定语义
固件 Hash 与 CRC 共用经过校验的 Intel HEX 解析器,但使用独立纯逻辑、Worker 和 Vue 页面。解析器处理扩展段/线性地址,将数据按 32 位绝对地址排序,合并连续记录,去重内容一致的重叠记录,并拒绝冲突或越过 `0xFFFFFFFF` 的数据。记录文本、记录校验和、EOF 和启动地址不参与 Hash。
“有效数据”模式的总输入为所有 block 数据按地址升序直接拼接,不填充地址空洞。“Block 校验头”模式在每个 block 前加入 8 个二进制字节:
```text
uint32 startAddress
uint32 dataLength
```
默认使用 Big Endian,可以显式切换 Little Endian。两个字段始终为 4 字节;`dataLength` 只表示该 block 的有效数据字节数,不包含 8 字节校验头。总输入严格为 `Header1 || Data1 || Header2 || Data2 ...`,同时输出总 SHA-256/SHA-512 和每个 block 的独立摘要。不得把固定宽度 Hex 文本误作为 ASCII 字符加入 Hash,也不得隐式使用运行设备的原生端序。
### 1. 先定义稳定标识 ### 1. 先定义稳定标识
为工具选择永久的 kebab-case `id`,例如 `url-encode`。已发布工具的 `id` 不应修改,因为路由、懒加载映射和用户保存的导航配置都依赖它。 为工具选择永久的 kebab-case `id`,例如 `url-encode`。已发布工具的 `id` 不应修改,因为路由、懒加载映射和用户保存的导航配置都依赖它。
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
web-tools: web-tools:
image: personal-web-tools:0.0.5 image: personal-web-tools:0.0.6
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "personal-web-tools", "name": "personal-web-tools",
"version": "0.0.5", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "personal-web-tools", "name": "personal-web-tools",
"version": "0.0.5", "version": "0.0.6",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.29.0", "@lucide/vue": "^1.29.0",
"@stablelib/aes": "^2.0.1", "@stablelib/aes": "^2.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "personal-web-tools", "name": "personal-web-tools",
"version": "0.0.5", "version": "0.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+1 -1
View File
@@ -2,7 +2,7 @@
"version": 1, "version": 1,
"categories": [ "categories": [
{ "id": "encoding", "tools": ["ascii-hex", "base64"] }, { "id": "encoding", "tools": ["ascii-hex", "base64"] },
{ "id": "checksum", "tools": ["crc"] }, { "id": "checksum", "tools": ["crc", "firmware-hash"] },
{ "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "random"] }, { "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "random"] },
{ "id": "formatting", "tools": ["json", "xml"] }, { "id": "formatting", "tools": ["json", "xml"] },
{ "id": "image", "tools": ["png-ico"] } { "id": "image", "tools": ["png-ico"] }
+7
View File
@@ -50,6 +50,13 @@
"description": "解析 BIN 或 Intel HEX 数据并计算 CRC。", "description": "解析 BIN 或 Intel HEX 数据并计算 CRC。",
"icon": "BadgeCheck" "icon": "BadgeCheck"
}, },
{
"id": "firmware-hash",
"title": "固件 Hash",
"category": "checksum",
"description": "解析 Intel HEX 有效数据并计算 SHA-256 / SHA-512。",
"icon": "Hash"
},
{ {
"id": "json", "id": "json",
"title": "JSON", "title": "JSON",
+2
View File
@@ -5,6 +5,7 @@ defineProps<{
modelValue: T modelValue: T
options: ModeOption<T>[] options: ModeOption<T>[]
label: string label: string
disabled?: boolean
}>() }>()
const emit = defineEmits<{ 'update:modelValue': [value: T] }>() const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
@@ -19,6 +20,7 @@ const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
class="mode-tab" class="mode-tab"
:class="{ 'is-active': modelValue === option.value }" :class="{ 'is-active': modelValue === option.value }"
:aria-pressed="modelValue === option.value" :aria-pressed="modelValue === option.value"
:disabled="disabled"
@click="emit('update:modelValue', option.value)" @click="emit('update:modelValue', option.value)"
> >
{{ option.label }} {{ option.label }}
+172
View File
@@ -0,0 +1,172 @@
import { formatBytes } from '@/lib/bytes'
import { MAX_FIRMWARE_FILE_SIZE } from '@/lib/firmware'
import { DEFAULT_MAX_INTEL_HEX_BLOCKS, MAX_UINT32_ADDRESS, UINT32_ADDRESS_SPACE_SIZE, type DataBlock } from '@/lib/intelHex'
export type FirmwareHashMode = 'data-only' | 'block-header'
export type FirmwareHashByteOrder = 'big-endian' | 'little-endian'
export interface FirmwareHashDigest {
sha256: string
sha512: string
}
export interface FirmwareHashBlockResult extends FirmwareHashDigest {
startAddress: number
endAddress: number
length: number
headerHex: string
}
export interface FirmwareHashReport {
combined: FirmwareHashDigest
blocks: FirmwareHashBlockResult[]
hashedBytes: number
headerBytes: number
}
export type DigestBytes = (algorithm: 'SHA-256' | 'SHA-512', data: Uint8Array) => Promise<string>
function validateUint32(value: number, label: string): void {
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32_ADDRESS) {
throw new Error(`${label} 必须是 0x00000000 至 0xFFFFFFFF 的整数`)
}
}
function validatePositiveUint32(value: number, label: string): void {
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_UINT32_ADDRESS) {
throw new Error(`${label} 必须是 1 至 0xFFFFFFFF 的整数`)
}
}
function validateMode(mode: FirmwareHashMode): void {
if (mode !== 'data-only' && mode !== 'block-header') throw new Error('Hash 计算模式无效')
}
function validateByteOrder(byteOrder: FirmwareHashByteOrder): void {
if (byteOrder !== 'big-endian' && byteOrder !== 'little-endian') throw new Error('校验头字节序无效')
}
export function encodeFirmwareBlockHeader(
startAddress: number,
dataLength: number,
byteOrder: FirmwareHashByteOrder = 'big-endian',
): Uint8Array {
validateUint32(startAddress, 'Block 起始地址')
validatePositiveUint32(dataLength, 'Block 数据长度')
validateByteOrder(byteOrder)
const header = new Uint8Array(8)
const view = new DataView(header.buffer)
const littleEndian = byteOrder === 'little-endian'
view.setUint32(0, startAddress, littleEndian)
view.setUint32(4, dataLength, littleEndian)
return header
}
function validateAndSortBlocks(blocks: DataBlock[]): DataBlock[] {
if (!Array.isArray(blocks) || blocks.length === 0) throw new Error('Intel HEX 不包含有效数据块')
if (blocks.length > DEFAULT_MAX_INTEL_HEX_BLOCKS) {
throw new Error(`Intel HEX 数据块不能超过 ${DEFAULT_MAX_INTEL_HEX_BLOCKS.toLocaleString()} 个`)
}
const sorted = [...blocks].sort((left, right) => left.startAddress - right.startAddress)
let totalBytes = 0
let previousEnd = -1
for (let index = 0; index < sorted.length; index += 1) {
const block = sorted[index]
if (!(block.data instanceof Uint8Array)) throw new Error(`Block #${index + 1} 数据类型无效`)
validateUint32(block.startAddress, `Block #${index + 1} 起始地址`)
validatePositiveUint32(block.data.length, `Block #${index + 1} 数据长度`)
const endAddressExclusive = block.startAddress + block.data.length
if (!Number.isSafeInteger(endAddressExclusive) || endAddressExclusive > UINT32_ADDRESS_SPACE_SIZE) {
throw new Error(`Block #${index + 1} 地址范围超出 32 位空间`)
}
if (block.startAddress < previousEnd) throw new Error(`Block #${index + 1} 与前一个数据块重叠`)
if (block.startAddress === previousEnd) throw new Error(`Block #${index + 1} 与前一个数据块相邻,必须先合并`)
previousEnd = endAddressExclusive
totalBytes += block.data.length
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_FIRMWARE_FILE_SIZE) {
throw new Error(`有效数据不能超过 ${(MAX_FIRMWARE_FILE_SIZE / 1024 / 1024).toLocaleString()} MiB`)
}
}
return sorted
}
export function buildFirmwareHashInput(
blocks: DataBlock[],
mode: FirmwareHashMode,
byteOrder: FirmwareHashByteOrder = 'big-endian',
): Uint8Array {
validateMode(mode)
validateByteOrder(byteOrder)
const sorted = validateAndSortBlocks(blocks)
const headerBytes = mode === 'block-header' ? sorted.length * 8 : 0
const dataBytes = sorted.reduce((total, block) => total + block.data.length, 0)
const totalLength = dataBytes + headerBytes
if (!Number.isSafeInteger(totalLength)) throw new Error('Hash 输入长度溢出')
const input = new Uint8Array(totalLength)
let offset = 0
for (const block of sorted) {
if (mode === 'block-header') {
const header = encodeFirmwareBlockHeader(block.startAddress, block.data.length, byteOrder)
input.set(header, offset)
offset += header.length
}
input.set(block.data, offset)
offset += block.data.length
}
return input
}
async function webCryptoDigest(algorithm: 'SHA-256' | 'SHA-512', data: Uint8Array): Promise<string> {
if (!globalThis.crypto?.subtle) throw new Error('当前浏览器不支持 Web Crypto Hash')
const input = data.buffer instanceof ArrayBuffer && data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? data.buffer
: data.slice().buffer
const digest = await globalThis.crypto.subtle.digest(algorithm, input)
return formatBytes(new Uint8Array(digest), { format: 'continuous-hex' })
}
async function calculateDigestPair(data: Uint8Array, digestBytes: DigestBytes): Promise<FirmwareHashDigest> {
const sha256 = await digestBytes('SHA-256', data)
const sha512 = await digestBytes('SHA-512', data)
return { sha256, sha512 }
}
export async function calculateFirmwareHashReport(
blocks: DataBlock[],
mode: FirmwareHashMode,
byteOrder: FirmwareHashByteOrder = 'big-endian',
digestBytes: DigestBytes = webCryptoDigest,
): Promise<FirmwareHashReport> {
const sorted = validateAndSortBlocks(blocks)
const combinedInput = buildFirmwareHashInput(sorted, mode, byteOrder)
const combined = await calculateDigestPair(combinedInput, digestBytes)
const blockResults: FirmwareHashBlockResult[] = []
for (const block of sorted) {
const header = encodeFirmwareBlockHeader(block.startAddress, block.data.length, byteOrder)
const digest = sorted.length === 1
? combined
: await calculateDigestPair(buildFirmwareHashInput([block], mode, byteOrder), digestBytes)
blockResults.push({
startAddress: block.startAddress,
endAddress: block.startAddress + block.data.length - 1,
length: block.data.length,
headerHex: mode === 'block-header' ? formatBytes(header, { format: 'spaced-hex' }) : '',
...digest,
})
}
return {
combined,
blocks: blockResults,
hashedBytes: combinedInput.length,
headerBytes: mode === 'block-header' ? sorted.length * 8 : 0,
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { FirmwareHashBlockResult, FirmwareHashByteOrder, FirmwareHashDigest, FirmwareHashMode } from '@/lib/firmwareHash'
export interface FirmwareHashWorkerParseRequest {
type: 'parse'
id: number
file: File
}
export interface FirmwareHashWorkerCalculateRequest {
type: 'calculate'
id: number
mode: FirmwareHashMode
byteOrder: FirmwareHashByteOrder
}
export type FirmwareHashWorkerRequest = FirmwareHashWorkerParseRequest | FirmwareHashWorkerCalculateRequest
export type FirmwareHashWorkerResponse = {
type: 'parsed'
id: number
totalBytes: number
blockCount: number
warnings: string[]
} | {
type: 'calculated'
id: number
mode: FirmwareHashMode
byteOrder: FirmwareHashByteOrder
combined: FirmwareHashDigest
blocks: FirmwareHashBlockResult[]
hashedBytes: number
headerBytes: number
} | {
type: 'error'
id: number
operation: FirmwareHashWorkerRequest['type']
error: string
}
+32 -6
View File
@@ -10,10 +10,14 @@ export interface ParsedIntelHex {
} }
export interface IntelHexLimits { export interface IntelHexLimits {
maxRecords?: number
maxDataRecords?: number maxDataRecords?: number
maxBlocks?: number maxBlocks?: number
} }
export const UINT32_ADDRESS_SPACE_SIZE = 0x1_0000_0000
export const MAX_UINT32_ADDRESS = UINT32_ADDRESS_SPACE_SIZE - 1
export const DEFAULT_MAX_INTEL_HEX_RECORDS = 500_000
export const DEFAULT_MAX_INTEL_HEX_DATA_RECORDS = 500_000 export const DEFAULT_MAX_INTEL_HEX_DATA_RECORDS = 500_000
export const DEFAULT_MAX_INTEL_HEX_BLOCKS = 1_024 export const DEFAULT_MAX_INTEL_HEX_BLOCKS = 1_024
@@ -36,8 +40,26 @@ function parseRecord(line: string, lineNumber: number): Uint8Array {
return bytes return bytes
} }
function readUint16(bytes: Uint8Array, offset = 0): number {
return bytes[offset] * 0x100 + bytes[offset + 1]
}
function getEndAddressExclusive(startAddress: number, length: number, label: string): number {
if (!Number.isSafeInteger(startAddress) || startAddress < 0 || startAddress > MAX_UINT32_ADDRESS) {
throw new Error(`${label} 起始地址超出 32 位范围`)
}
if (!Number.isSafeInteger(length) || length < 0 || length > MAX_UINT32_ADDRESS) {
throw new Error(`${label} 长度超出 32 位范围`)
}
const endAddressExclusive = startAddress + length
if (!Number.isSafeInteger(endAddressExclusive) || endAddressExclusive > UINT32_ADDRESS_SPACE_SIZE) {
throw new Error(`${label} 地址范围超出 32 位空间`)
}
return endAddressExclusive
}
function validateLimit(value: number, label: string): number { function validateLimit(value: number, label: string): number {
if (!Number.isInteger(value) || value < 1) throw new Error(`${label} 必须是正整数`) if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${label} 必须是正整数`)
return value return value
} }
@@ -47,7 +69,7 @@ function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
for (let index = 0; index < sorted.length; index += 1) { for (let index = 0; index < sorted.length; index += 1) {
const record = sorted[index] const record = sorted[index]
const recordEnd = record.startAddress + record.data.length const recordEnd = getEndAddressExclusive(record.startAddress, record.data.length, `第 ${record.line} 行`)
const current = groups.at(-1) const current = groups.at(-1)
if (!current || record.startAddress > current.endAddress) { if (!current || record.startAddress > current.endAddress) {
if (groups.length >= maxBlocks) throw new Error(`Intel HEX 数据块不能超过 ${maxBlocks.toLocaleString()} 个`) if (groups.length >= maxBlocks) throw new Error(`Intel HEX 数据块不能超过 ${maxBlocks.toLocaleString()} 个`)
@@ -78,6 +100,7 @@ function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
} }
export function parseIntelHex(value: string, limits: IntelHexLimits = {}): ParsedIntelHex { export function parseIntelHex(value: string, limits: IntelHexLimits = {}): ParsedIntelHex {
const maxRecords = validateLimit(limits.maxRecords ?? DEFAULT_MAX_INTEL_HEX_RECORDS, 'Intel HEX 记录上限')
const maxDataRecords = validateLimit(limits.maxDataRecords ?? DEFAULT_MAX_INTEL_HEX_DATA_RECORDS, 'Intel HEX 数据记录上限') const maxDataRecords = validateLimit(limits.maxDataRecords ?? DEFAULT_MAX_INTEL_HEX_DATA_RECORDS, 'Intel HEX 数据记录上限')
const maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限') const maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限')
const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/) const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/)
@@ -85,21 +108,24 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse
const warnings: string[] = [] const warnings: string[] = []
let baseAddress = 0 let baseAddress = 0
let eofSeen = false let eofSeen = false
let recordCount = 0
for (let index = 0; index < lines.length; index += 1) { for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim() const line = lines[index].trim()
if (!line) continue if (!line) continue
const lineNumber = index + 1 const lineNumber = index + 1
recordCount += 1
if (recordCount > maxRecords) throw new Error(`Intel HEX 记录不能超过 ${maxRecords.toLocaleString()} 条`)
if (eofSeen) throw new Error(`第 ${lineNumber} 行位于 EOF 记录之后`) if (eofSeen) throw new Error(`第 ${lineNumber} 行位于 EOF 记录之后`)
const bytes = parseRecord(line, lineNumber) const bytes = parseRecord(line, lineNumber)
const byteCount = bytes[0] const byteCount = bytes[0]
const recordAddress = (bytes[1] << 8) | bytes[2] const recordAddress = readUint16(bytes, 1)
const recordType = bytes[3] const recordType = bytes[3]
const data = bytes.slice(4, 4 + byteCount) const data = bytes.slice(4, 4 + byteCount)
if (recordType === 0x00) { if (recordType === 0x00) {
const startAddress = baseAddress + recordAddress const startAddress = baseAddress + recordAddress
if (startAddress + data.length > 0x100000000) throw new Error(`第 ${lineNumber} 行地址超出 32 位范围`) getEndAddressExclusive(startAddress, data.length, `第 ${lineNumber} 行`)
if (data.length) { if (data.length) {
if (records.length >= maxDataRecords) throw new Error(`Intel HEX 数据记录不能超过 ${maxDataRecords.toLocaleString()} 条`) if (records.length >= maxDataRecords) throw new Error(`Intel HEX 数据记录不能超过 ${maxDataRecords.toLocaleString()} 条`)
records.push({ startAddress, data, line: lineNumber }) records.push({ startAddress, data, line: lineNumber })
@@ -109,10 +135,10 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse
eofSeen = true eofSeen = true
} else if (recordType === 0x02) { } else if (recordType === 0x02) {
if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展段地址记录格式无效`) if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展段地址记录格式无效`)
baseAddress = ((data[0] << 8) | data[1]) << 4 baseAddress = readUint16(data) * 0x10
} else if (recordType === 0x04) { } else if (recordType === 0x04) {
if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展线性地址记录格式无效`) if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展线性地址记录格式无效`)
baseAddress = ((data[0] << 8) | data[1]) * 0x10000 baseAddress = readUint16(data) * 0x1_0000
} else if (recordType === 0x03 || recordType === 0x05) { } else if (recordType === 0x03 || recordType === 0x05) {
if (byteCount !== 4 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行启动地址记录格式无效`) if (byteCount !== 4 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行启动地址记录格式无效`)
} else { } else {
+29
View File
@@ -123,6 +123,7 @@ button { color: inherit; }
.mode-tab { min-height: 28px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; font-size: 12px; } .mode-tab { min-height: 28px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; font-size: 12px; }
.mode-tab:hover { color: var(--text); } .mode-tab:hover { color: var(--text); }
.mode-tab.is-active { color: var(--text); background: var(--surface); box-shadow: 0 0 0 1px var(--border); } .mode-tab.is-active { color: var(--text); background: var(--surface); box-shadow: 0 0 0 1px var(--border); }
.mode-tab:disabled { cursor: not-allowed; opacity: .55; }
.tool-controls { margin-bottom: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } .tool-controls { margin-bottom: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.control-spacer, .action-spacer { flex: 1; } .control-spacer, .action-spacer { flex: 1; }
.compact-field { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; } .compact-field { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
@@ -221,6 +222,28 @@ button { color: inherit; }
.result-table td { color: var(--muted); } .result-table td { color: var(--muted); }
.result-table tbody tr:last-child td { border-bottom: 0; } .result-table tbody tr:last-child td { border-bottom: 0; }
.crc-value { color: var(--text) !important; font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; } .crc-value { color: var(--text) !important; font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; }
.hash-config { margin-bottom: 14px; display: flex; align-items: flex-end; flex-wrap: wrap; gap: 12px; }
.hash-config .parameter-field { min-width: 270px; }
.hash-config .mode-tabs { width: 100%; }
.hash-results { margin-top: 16px; }
.hash-summary { min-height: 58px; padding: 10px 13px; display: flex; align-items: center; gap: 12px; color: var(--muted); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 7px; font-size: 11px; }
.hash-summary > div { min-width: 0; }
.hash-summary strong, .hash-summary span { display: block; }
.hash-summary strong { color: var(--text); font-size: 12px; }
.hash-summary span { margin-top: 3px; overflow-wrap: anywhere; }
.hash-summary-mode { margin-left: auto; color: var(--accent) !important; font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; white-space: nowrap; }
.hash-digest-list { margin-top: 10px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.hash-digest-row, .hash-field-row { min-width: 0; min-height: 48px; padding: 7px 0; display: grid; grid-template-columns: 78px minmax(0, 1fr) 30px; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); }
.hash-digest-row:last-child, .hash-field-row:last-child { border-bottom: 0; }
.hash-digest-row strong, .hash-field-row > span { color: var(--muted); font-size: 11px; font-weight: 600; }
.hash-digest-row code, .hash-field-row code { min-width: 0; color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
.hash-block-list { margin-top: 16px; display: grid; gap: 10px; }
.hash-block-result { min-width: 0; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 7px; }
.hash-block-result > header { min-height: 42px; display: flex; align-items: center; gap: 10px; color: var(--muted); border-bottom: 1px solid var(--border); font-size: 11px; }
.hash-block-result > header strong { color: var(--text); font-size: 12px; white-space: nowrap; }
.hash-block-result > header span { min-width: 0; font-family: "SFMono-Regular", Consolas, monospace; overflow-wrap: anywhere; }
.hash-block-result > header small { margin-left: auto; color: var(--faint); white-space: nowrap; }
.hash-field-row { grid-template-columns: 78px minmax(0, 1fr) 30px; }
.fixed-parameter { min-height: 34px; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; } .fixed-parameter { min-height: 34px; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; }
.fixed-parameter strong { color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; } .fixed-parameter strong { color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; }
.rsa-generate-row { margin-top: 0; margin-bottom: 18px; } .rsa-generate-row { margin-top: 0; margin-bottom: 18px; }
@@ -296,6 +319,12 @@ button { color: inherit; }
.firmware-summary > div { width: 100%; } .firmware-summary > div { width: 100%; }
.crc-total { align-items: flex-start; flex-direction: column; } .crc-total { align-items: flex-start; flex-direction: column; }
.crc-total strong { margin-left: 0; font-size: 18px; overflow-wrap: anywhere; } .crc-total strong { margin-left: 0; font-size: 18px; overflow-wrap: anywhere; }
.hash-config { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.hash-config .parameter-field { min-width: 0; }
.hash-summary { align-items: flex-start; flex-direction: column; }
.hash-summary-mode { margin-left: 0; }
.hash-block-result > header { align-items: flex-start; flex-wrap: wrap; padding: 10px 0; }
.hash-block-result > header small { width: 100%; margin-left: 0; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { AlertCircle, Calculator, CheckCircle2, Copy, FileKey2, Trash2, Upload } from '@lucide/vue'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import ModeTabs from '@/components/ModeTabs.vue'
import ToolPage from '@/components/ToolPage.vue'
import { copyText } from '@/lib/browser'
import type { FirmwareHashBlockResult, FirmwareHashByteOrder, FirmwareHashDigest, FirmwareHashMode } from '@/lib/firmwareHash'
import type { FirmwareHashWorkerRequest, FirmwareHashWorkerResponse } from '@/lib/firmwareHashWorker'
import { MAX_FIRMWARE_FILE_SIZE } from '@/lib/firmware'
interface FirmwareSummary {
fileName: string
fileSize: number
totalBytes: number
blockCount: number
warnings: string[]
}
const fileInput = ref<HTMLInputElement>()
const dragging = ref(false)
const firmware = shallowRef<FirmwareSummary>()
const mode = ref<FirmwareHashMode>('data-only')
const byteOrder = ref<FirmwareHashByteOrder>('big-endian')
const combined = shallowRef<FirmwareHashDigest>()
const blockResults = ref<FirmwareHashBlockResult[]>([])
const hashedBytes = ref(0)
const headerBytes = ref(0)
const parsing = ref(false)
const calculating = ref(false)
const error = ref('')
const success = ref('')
let worker: Worker | undefined
let requestSequence = 0
let activeParseRequest: number | undefined
let activeCalculationRequest: number | undefined
const modeOptions = [
{ value: 'data-only' as const, label: '有效数据' },
{ value: 'block-header' as const, label: 'Block 校验头' },
]
const byteOrderOptions = [
{ value: 'big-endian' as const, label: 'Big Endian' },
{ value: 'little-endian' as const, label: 'Little Endian' },
]
function formatAddress(address: number): string {
return `0x${address.toString(16).toUpperCase().padStart(8, '0')}`
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size.toLocaleString()} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`
return `${(size / 1024 / 1024).toFixed(1)} MiB`
}
function clearResults() {
activeCalculationRequest = undefined
calculating.value = false
combined.value = undefined
blockResults.value = []
hashedBytes.value = 0
headerBytes.value = 0
success.value = ''
}
function invalidateResults() {
clearResults()
error.value = ''
}
function destroyWorker() {
worker?.terminate()
worker = undefined
activeParseRequest = undefined
activeCalculationRequest = undefined
parsing.value = false
calculating.value = false
}
function createWorker(): Worker {
const nextWorker = new Worker(new URL('../workers/firmwareHash.worker.ts', import.meta.url), { type: 'module' })
nextWorker.onmessage = (event: MessageEvent<FirmwareHashWorkerResponse>) => {
if (nextWorker !== worker) return
const response = event.data
if (response.type === 'parsed') {
if (response.id !== activeParseRequest) return
activeParseRequest = undefined
parsing.value = false
firmware.value = {
fileName: pendingFileName,
fileSize: pendingFileSize,
totalBytes: response.totalBytes,
blockCount: response.blockCount,
warnings: response.warnings,
}
success.value = `已解析 ${response.blockCount} 个数据块`
return
}
if (response.type === 'calculated') {
if (response.id !== activeCalculationRequest) return
activeCalculationRequest = undefined
calculating.value = false
combined.value = response.combined
blockResults.value = response.blocks
hashedBytes.value = response.hashedBytes
headerBytes.value = response.headerBytes
success.value = 'SHA-256 / SHA-512 计算完成'
return
}
if (response.operation === 'parse' && response.id === activeParseRequest) {
activeParseRequest = undefined
parsing.value = false
firmware.value = undefined
error.value = response.error
} else if (response.operation === 'calculate' && response.id === activeCalculationRequest) {
activeCalculationRequest = undefined
calculating.value = false
error.value = response.error
}
}
nextWorker.onerror = () => {
if (nextWorker !== worker) return
destroyWorker()
firmware.value = undefined
clearResults()
error.value = 'Hash 后台线程异常终止,请重新选择文件'
}
worker = nextWorker
return nextWorker
}
let pendingFileName = ''
let pendingFileSize = 0
function selectFile(selected?: File) {
if (!selected) return
destroyWorker()
firmware.value = undefined
invalidateResults()
if (selected.size === 0) { error.value = '文件为空'; return }
if (selected.size > MAX_FIRMWARE_FILE_SIZE) { error.value = '文件不能超过 64 MiB'; return }
if (!/\.(?:hex|ihx)$/i.test(selected.name)) { error.value = '请选择 .hex 或 .ihx 文件'; return }
pendingFileName = selected.name
pendingFileSize = selected.size
const id = ++requestSequence
activeParseRequest = id
parsing.value = true
const request: FirmwareHashWorkerRequest = { type: 'parse', id, file: selected }
createWorker().postMessage(request)
}
function onFileInput(event: Event) {
const input = event.target as HTMLInputElement
const selected = input.files?.[0]
input.value = ''
selectFile(selected)
}
function onDrop(event: DragEvent) {
dragging.value = false
selectFile(event.dataTransfer?.files[0])
}
function calculate() {
invalidateResults()
if (!firmware.value) { error.value = '请先选择 Intel HEX 文件'; return }
if (!worker) { error.value = '文件数据已失效,请重新选择文件'; return }
const id = ++requestSequence
activeCalculationRequest = id
calculating.value = true
const request: FirmwareHashWorkerRequest = { type: 'calculate', id, mode: mode.value, byteOrder: byteOrder.value }
worker.postMessage(request)
}
async function copyDigest(label: string, value: string) {
try { await copyText(value); success.value = `${label} 已复制`; error.value = '' }
catch { error.value = '复制失败' }
}
function clearFile() {
destroyWorker()
firmware.value = undefined
invalidateResults()
if (fileInput.value) fileInput.value.value = ''
}
watch([mode, byteOrder], invalidateResults)
onBeforeUnmount(destroyWorker)
</script>
<template>
<ToolPage category="校验计算" title="固件 Hash" description="解析 Intel HEX 有效数据,计算 SHA-256 / SHA-512,可选择加入每个 Block 的地址和长度校验头。">
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
<input ref="fileInput" type="file" accept=".hex,.ihx,text/plain" :disabled="parsing" @change="onFileInput" />
<span><FileKey2 :size="28" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 Intel HEX 文件' }}</strong><span>校验记录、识别多个数据块,不填充地址空洞</span></span>
</label>
<div v-else class="firmware-summary">
<div><strong>{{ firmware.fileName }}</strong><span>Intel HEX · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block · {{ formatFileSize(firmware.fileSize) }}</span></div>
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,text/plain" @change="onFileInput" /></label>
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
</div>
<div class="hash-config">
<div class="parameter-field"><span>计算模式</span><ModeTabs v-model="mode" :options="modeOptions" label="Hash 计算模式" :disabled="calculating || parsing" /></div>
<div v-if="mode === 'block-header'" class="parameter-field"><span>校验头字节序</span><ModeTabs v-model="byteOrder" :options="byteOrderOptions" label="校验头字节序" :disabled="calculating || parsing" /></div>
</div>
<div class="action-row">
<button class="action-button primary" type="button" :disabled="!firmware || parsing || calculating" @click="calculate"><Calculator :size="15" />{{ calculating ? '正在计算…' : '计算 SHA-256 / SHA-512' }}</button>
</div>
<section v-if="combined" class="hash-results" aria-live="polite">
<div class="hash-summary">
<div><strong>总 Hash</strong><span>{{ mode === 'block-header' ? '按 Block 顺序加入 8 字节校验头' : '按地址升序拼接有效数据,不填充空洞' }} · {{ hashedBytes.toLocaleString() }} 字节{{ headerBytes ? `(含 ${headerBytes} 字节校验头)` : '' }}</span></div>
<span class="hash-summary-mode">{{ mode === 'block-header' ? byteOrder === 'big-endian' ? 'Big Endian' : 'Little Endian' : 'Data Only' }}</span>
</div>
<div class="hash-digest-list">
<div class="hash-digest-row"><strong>SHA-256</strong><code>{{ combined.sha256 }}</code><button class="row-action" type="button" title="复制 SHA-256" aria-label="复制 SHA-256" @click="copyDigest('总 SHA-256', combined.sha256)"><Copy :size="15" /></button></div>
<div class="hash-digest-row"><strong>SHA-512</strong><code>{{ combined.sha512 }}</code><button class="row-action" type="button" title="复制 SHA-512" aria-label="复制 SHA-512" @click="copyDigest('总 SHA-512', combined.sha512)"><Copy :size="15" /></button></div>
</div>
<div class="hash-block-list">
<article v-for="(block, index) in blockResults" :key="block.startAddress" class="hash-block-result">
<header><strong>Block #{{ index + 1 }}</strong><span>{{ formatAddress(block.startAddress) }} - {{ formatAddress(block.endAddress) }}</span><small>{{ block.length.toLocaleString() }} bytes</small></header>
<div v-if="block.headerHex" class="hash-field-row"><span>校验头</span><code>{{ block.headerHex }}</code><button class="row-action" type="button" title="复制校验头" aria-label="复制校验头" @click="copyDigest(`Block #${index + 1} 校验头`, block.headerHex)"><Copy :size="15" /></button></div>
<div class="hash-field-row"><span>SHA-256</span><code>{{ block.sha256 }}</code><button class="row-action" type="button" title="复制 Block SHA-256" aria-label="复制 Block SHA-256" @click="copyDigest(`Block #${index + 1} SHA-256`, block.sha256)"><Copy :size="15" /></button></div>
<div class="hash-field-row"><span>SHA-512</span><code>{{ block.sha512 }}</code><button class="row-action" type="button" title="复制 Block SHA-512" aria-label="复制 Block SHA-512" @click="copyDigest(`Block #${index + 1} SHA-512`, block.sha512)"><Copy :size="15" /></button></div>
</article>
</div>
</section>
<div v-if="firmware?.warnings.length" class="status-message warning"><AlertCircle :size="16" />{{ firmware.warnings.join(';') }}</div>
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
</ToolPage>
</template>
+3
View File
@@ -5,6 +5,7 @@ import {
Code2, Code2,
Dices, Dices,
FileCode2, FileCode2,
Hash,
Image, Image,
KeyRound, KeyRound,
ShieldCheck, ShieldCheck,
@@ -20,6 +21,7 @@ export const iconComponents: Record<string, Component> = {
Code2, Code2,
Dices, Dices,
FileCode2, FileCode2,
Hash,
Image, Image,
KeyRound, KeyRound,
ShieldCheck, ShieldCheck,
@@ -42,6 +44,7 @@ export const toolComponents: Record<string, Component> = {
'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')), 'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')),
random: defineAsyncComponent(() => import('./RandomTool.vue')), random: defineAsyncComponent(() => import('./RandomTool.vue')),
crc: defineAsyncComponent(() => import('./CrcTool.vue')), crc: defineAsyncComponent(() => import('./CrcTool.vue')),
'firmware-hash': defineAsyncComponent(() => import('./FirmwareHashTool.vue')),
json: defineAsyncComponent(() => import('./JsonTool.vue')), json: defineAsyncComponent(() => import('./JsonTool.vue')),
xml: defineAsyncComponent(() => import('./XmlTool.vue')), xml: defineAsyncComponent(() => import('./XmlTool.vue')),
'png-ico': defineAsyncComponent(() => import('./PngIcoTool.vue')), 'png-ico': defineAsyncComponent(() => import('./PngIcoTool.vue')),
+49
View File
@@ -0,0 +1,49 @@
import { calculateFirmwareHashReport } from '@/lib/firmwareHash'
import type { FirmwareHashWorkerRequest, FirmwareHashWorkerResponse } from '@/lib/firmwareHashWorker'
import { MAX_FIRMWARE_FILE_SIZE } from '@/lib/firmware'
import { parseIntelHex, type DataBlock } from '@/lib/intelHex'
const workerScope = globalThis as unknown as {
onmessage: ((event: MessageEvent<FirmwareHashWorkerRequest>) => void) | null
postMessage(message: FirmwareHashWorkerResponse): void
}
let firmwareBlocks: DataBlock[] | undefined
workerScope.onmessage = async (event) => {
const request = event.data
try {
if (request.type === 'parse') {
if (request.file.size === 0) throw new Error('文件为空')
if (request.file.size > MAX_FIRMWARE_FILE_SIZE) throw new Error('文件不能超过 64 MiB')
if (!/\.(?:hex|ihx)$/i.test(request.file.name)) throw new Error('请选择 .hex 或 .ihx 文件')
const parsed = parseIntelHex(await request.file.text())
firmwareBlocks = parsed.blocks
workerScope.postMessage({
type: 'parsed',
id: request.id,
totalBytes: parsed.totalBytes,
blockCount: parsed.blocks.length,
warnings: parsed.warnings,
})
return
}
if (!firmwareBlocks) throw new Error('请先选择 Intel HEX 文件')
const report = await calculateFirmwareHashReport(firmwareBlocks, request.mode, request.byteOrder)
workerScope.postMessage({
type: 'calculated',
id: request.id,
mode: request.mode,
byteOrder: request.byteOrder,
...report,
})
} catch (reason) {
workerScope.postMessage({
type: 'error',
id: request.id,
operation: request.type,
error: reason instanceof Error ? reason.message : '固件 Hash 计算失败',
})
}
}
+133
View File
@@ -0,0 +1,133 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
buildFirmwareHashInput,
calculateFirmwareHashReport,
encodeFirmwareBlockHeader,
type FirmwareHashByteOrder,
type FirmwareHashMode,
} from '../src/lib/firmwareHash'
import { textToBytes } from '../src/lib/bytes'
function hash(algorithm: 'sha256' | 'sha512', data: Uint8Array): string {
return createHash(algorithm).update(data).digest('hex').toUpperCase()
}
describe('firmware block headers', () => {
it('encodes two uint32 fields as big endian by default', () => {
expect(encodeFirmwareBlockHeader(0x08000000, 0x123)).toEqual(Uint8Array.from([
0x08, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x23,
]))
})
it('supports explicit little endian serialization', () => {
expect(encodeFirmwareBlockHeader(0x08000000, 0x123, 'little-endian')).toEqual(Uint8Array.from([
0x00, 0x00, 0x00, 0x08,
0x23, 0x01, 0x00, 0x00,
]))
})
it('accepts uint32 boundaries and rejects invalid values', () => {
expect(encodeFirmwareBlockHeader(0xffffffff, 0xffffffff)).toEqual(Uint8Array.from([
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
]))
expect(() => encodeFirmwareBlockHeader(-1, 1)).toThrow('起始地址')
expect(() => encodeFirmwareBlockHeader(0x1_0000_0000, 1)).toThrow('起始地址')
expect(() => encodeFirmwareBlockHeader(0, 0)).toThrow('数据长度')
expect(() => encodeFirmwareBlockHeader(0, 0x1_0000_0000)).toThrow('数据长度')
expect(() => encodeFirmwareBlockHeader(0, 1, 'middle-endian' as FirmwareHashByteOrder)).toThrow('字节序')
})
})
describe('firmware hash input', () => {
const blocks = [
{ startAddress: 0x20, data: Uint8Array.from([0xaa, 0xbb]) },
{ startAddress: 0, data: Uint8Array.from([0x01, 0x02]) },
]
it('sorts blocks and concatenates only effective bytes without filling gaps', () => {
expect(buildFirmwareHashInput(blocks, 'data-only')).toEqual(Uint8Array.from([0x01, 0x02, 0xaa, 0xbb]))
})
it('prefixes every sorted block with its binary address and data length', () => {
expect(buildFirmwareHashInput(blocks, 'block-header')).toEqual(Uint8Array.from([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x02,
0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x02, 0xaa, 0xbb,
]))
})
it('uses explicit little endian fields without changing block data', () => {
expect(buildFirmwareHashInput(blocks, 'block-header', 'little-endian')).toEqual(Uint8Array.from([
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x02,
0x20, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0xaa, 0xbb,
]))
})
it('accepts one byte at the highest uint32 address', () => {
expect(buildFirmwareHashInput([
{ startAddress: 0xffffffff, data: Uint8Array.of(0xaa) },
], 'block-header')).toEqual(Uint8Array.from([
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x01, 0xaa,
]))
})
it('rejects invalid, overlapping and overflowing blocks', () => {
expect(() => buildFirmwareHashInput([], 'data-only')).toThrow('有效数据块')
expect(() => buildFirmwareHashInput([{ startAddress: 0, data: new Uint8Array() }], 'data-only')).toThrow('数据长度')
expect(() => buildFirmwareHashInput([{ startAddress: 0xffffffff, data: Uint8Array.of(1, 2) }], 'data-only')).toThrow('32 位空间')
expect(() => buildFirmwareHashInput([
{ startAddress: 0, data: Uint8Array.of(1, 2) },
{ startAddress: 1, data: Uint8Array.of(2, 3) },
], 'data-only')).toThrow('重叠')
expect(() => buildFirmwareHashInput([
{ startAddress: 0, data: Uint8Array.of(1, 2) },
{ startAddress: 2, data: Uint8Array.of(3) },
], 'block-header')).toThrow('必须先合并')
expect(() => buildFirmwareHashInput([{ startAddress: 0, data: Uint8Array.of(1) }], 'unknown' as FirmwareHashMode)).toThrow('模式')
})
})
describe('firmware SHA report', () => {
const blocks = [
{ startAddress: 0x1000, data: textToBytes('1234') },
{ startAddress: 0x2000, data: textToBytes('56789') },
]
it('matches standard SHA-256 and SHA-512 values for data-only mode', async () => {
const report = await calculateFirmwareHashReport(blocks, 'data-only')
expect(report.combined.sha256).toBe('15E2B0D3C33891EBB0F1EF609EC419420C20E320CE94C65FBC8C3312448EB225')
expect(report.combined.sha512).toBe('D9E6762DD1C8EAF6D61B3C6192FC408D4D6D5F1176D0C29169BC24E71C3F274AD27FCD5811B313D681F7E55EC02D73D499C95455B6B5BB503ACF574FBA8FFE85')
expect(report).toMatchObject({ hashedBytes: 9, headerBytes: 0 })
expect(report.blocks[0].headerHex).toBe('')
expect(report.blocks[0].sha256).toBe(hash('sha256', textToBytes('1234')))
expect(report.blocks[1].sha512).toBe(hash('sha512', textToBytes('56789')))
})
it('hashes each binary block header with its data and reports exact byte counts', async () => {
const combinedInput = buildFirmwareHashInput(blocks, 'block-header', 'big-endian')
const report = await calculateFirmwareHashReport(blocks, 'block-header', 'big-endian')
expect(report.combined).toEqual({
sha256: hash('sha256', combinedInput),
sha512: hash('sha512', combinedInput),
})
expect(report).toMatchObject({ hashedBytes: 25, headerBytes: 16 })
expect(report.blocks.map((block) => block.headerHex)).toEqual([
'00 00 10 00 00 00 00 04',
'00 00 20 00 00 00 00 05',
])
expect(report.blocks[0].sha256).toBe(hash('sha256', buildFirmwareHashInput([blocks[0]], 'block-header')))
expect(report.blocks[1].sha512).toBe(hash('sha512', buildFirmwareHashInput([blocks[1]], 'block-header')))
})
it('binds addresses only in block-header mode', async () => {
const relocated = blocks.map((block) => ({ ...block, startAddress: block.startAddress + 0x10000 }))
const direct = await calculateFirmwareHashReport(blocks, 'data-only')
const relocatedDirect = await calculateFirmwareHashReport(relocated, 'data-only')
const withHeaders = await calculateFirmwareHashReport(blocks, 'block-header')
const relocatedWithHeaders = await calculateFirmwareHashReport(relocated, 'block-header')
expect(relocatedDirect.combined).toEqual(direct.combined)
expect(relocatedWithHeaders.combined).not.toEqual(withHeaders.combined)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_MAX_INTEL_HEX_RECORDS,
MAX_UINT32_ADDRESS,
parseIntelHex,
} from '../src/lib/intelHex'
function record(address: number, type: number, data: number[]): string {
const bytes = [data.length, Math.floor(address / 0x100) & 0xff, address & 0xff, type, ...data]
const checksum = (-bytes.reduce((sum, byte) => sum + byte, 0)) & 0xff
return `:${[...bytes, checksum].map((byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase()}`
}
const eof = record(0, 1, [])
describe('Intel HEX 32-bit boundaries', () => {
it('accepts a single byte at the highest uint32 address', () => {
const parsed = parseIntelHex([
record(0, 4, [0xff, 0xff]),
record(0xffff, 0, [0xaa]),
eof,
].join('\n'))
expect(parsed.blocks).toEqual([{ startAddress: MAX_UINT32_ADDRESS, data: Uint8Array.of(0xaa) }])
})
it('merges adjacent records ending exactly at the address-space boundary', () => {
const parsed = parseIntelHex([
record(0, 4, [0xff, 0xff]),
record(0xfffd, 0, [0x11, 0x22]),
record(0xffff, 0, [0x33]),
eof,
].join('\n'))
expect(parsed.blocks).toEqual([{
startAddress: 0xfffffffd,
data: Uint8Array.of(0x11, 0x22, 0x33),
}])
})
it('rejects a data record that extends beyond the uint32 address space', () => {
expect(() => parseIntelHex([
record(0, 4, [0xff, 0xff]),
record(0xffff, 0, [0xaa, 0xbb]),
eof,
].join('\n'))).toThrow('地址范围超出 32 位空间')
})
it('calculates segment and linear base addresses without signed bitwise overflow', () => {
const segment = parseIntelHex([record(0, 2, [0xff, 0xff]), record(0xffff, 0, [1]), eof].join('\n'))
const linear = parseIntelHex([record(0, 4, [0x80, 0x00]), record(0, 0, [2]), eof].join('\n'))
expect(segment.blocks[0].startAddress).toBe(0x0010ffef)
expect(linear.blocks[0].startAddress).toBe(0x80000000)
})
})
describe('Intel HEX structural limits', () => {
it('counts all non-empty records, including control records', () => {
expect(DEFAULT_MAX_INTEL_HEX_RECORDS).toBeGreaterThan(0)
expect(() => parseIntelHex([record(0, 0, [1]), eof].join('\n'), { maxRecords: 1 })).toThrow('记录不能超过 1 条')
})
it('rejects invalid limits before parsing', () => {
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: 0 })).toThrow('正整数')
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: 1.5 })).toThrow('正整数')
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.POSITIVE_INFINITY })).toThrow('正整数')
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.MAX_SAFE_INTEGER + 1 })).toThrow('正整数')
})
it('rejects records after EOF and malformed control records', () => {
expect(() => parseIntelHex([record(0, 0, [1]), eof, record(1, 0, [2])].join('\n'))).toThrow('EOF 记录之后')
expect(() => parseIntelHex([record(1, 1, []), record(0, 0, [1])].join('\n'))).toThrow('EOF 记录格式无效')
expect(() => parseIntelHex([record(0, 4, [0]), record(0, 0, [1]), eof].join('\n'))).toThrow('扩展线性地址记录格式无效')
expect(() => parseIntelHex([record(0, 6, []), record(0, 0, [1]), eof].join('\n'))).toThrow('不支持的记录类型')
})
it('rejects files without effective data and lines with inconsistent declared lengths', () => {
expect(() => parseIntelHex(eof)).toThrow('不包含有效数据记录')
expect(() => parseIntelHex(':0200000001FC')).toThrow('数据长度与记录声明不一致')
})
})