Add firmware SHA hash tool
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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 计算失败',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('数据长度与记录声明不一致')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user