diff --git a/AGENTS.md b/AGENTS.md index a0471a9..a1cdf67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ Key rules: - Keep each tool independently lazy-loaded through `src/tools/registry.ts`. - 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 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. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b75041..ec76dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,23 @@ 本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。 -## Unreleased +## 0.0.6 - 2026-08-08 ### Added - 增加统一的 Windows PowerShell 启动与更新脚本,提供固定的生产配置、Git 快进更新、锁定依赖重装和构建后启动流程。 - 增加 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 35f2799..5584bb9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ 一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。 -当前应用版本:`0.0.5`。 +当前应用版本:`0.0.6`。 ## 内置工具 @@ -10,6 +10,7 @@ - Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组 - AES-128-CMAC(RFC 4493) - CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX +- 固件 SHA-256 / SHA-512,支持 Intel HEX 有效数据和带地址/长度的 Block 校验头 - RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组 - 密码学安全随机数,支持连续 Hex、空格 Hex 和代码字节数组 - JSON 格式化、压缩、校验和语法着色 @@ -55,7 +56,7 @@ CHANGELOG.md 版本变更、修复和验证记录 ## 多人使用与状态边界 - 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。 -- CRC、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。 +- CRC、固件 Hash、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。 - `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。 - 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。 - 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。 @@ -68,8 +69,9 @@ CHANGELOG.md 版本变更、修复和验证记录 | 功能 | 当前限制 | 超限行为 | | --- | --- | --- | | CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 | -| Intel HEX 数据记录 | 最多 500,000 条 | Worker 停止解析并显示错误 | +| Intel HEX 非空记录 | 最多 500,000 条 | Worker 停止解析并显示错误 | | Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 | +| 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 选择或计算时拒绝 | | PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 | | PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 | | 安全随机数 | 每次 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 -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 ``` @@ -220,10 +222,23 @@ npm audit --audit-level=low `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. 先定义稳定标识 为工具选择永久的 kebab-case `id`,例如 `url-encode`。已发布工具的 `id` 不应修改,因为路由、懒加载映射和用户保存的导航配置都依赖它。 diff --git a/docker-compose.yml b/docker-compose.yml index 4a2e8bf..c5fa72b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: web-tools: - image: personal-web-tools:0.0.5 + image: personal-web-tools:0.0.6 build: context: . dockerfile: Dockerfile diff --git a/package-lock.json b/package-lock.json index 7be0345..35a74db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "personal-web-tools", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "personal-web-tools", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@lucide/vue": "^1.29.0", "@stablelib/aes": "^2.0.1", diff --git a/package.json b/package.json index 43f5d13..3d26fae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "personal-web-tools", - "version": "0.0.5", + "version": "0.0.6", "private": true, "type": "module", "engines": { diff --git a/shared/default-navigation.json b/shared/default-navigation.json index 7a33150..f1918c6 100644 --- a/shared/default-navigation.json +++ b/shared/default-navigation.json @@ -2,7 +2,7 @@ "version": 1, "categories": [ { "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": "formatting", "tools": ["json", "xml"] }, { "id": "image", "tools": ["png-ico"] } diff --git a/shared/tool-catalog.json b/shared/tool-catalog.json index f60c019..44d4d9d 100644 --- a/shared/tool-catalog.json +++ b/shared/tool-catalog.json @@ -50,6 +50,13 @@ "description": "解析 BIN 或 Intel HEX 数据并计算 CRC。", "icon": "BadgeCheck" }, + { + "id": "firmware-hash", + "title": "固件 Hash", + "category": "checksum", + "description": "解析 Intel HEX 有效数据并计算 SHA-256 / SHA-512。", + "icon": "Hash" + }, { "id": "json", "title": "JSON", diff --git a/src/components/ModeTabs.vue b/src/components/ModeTabs.vue index 70b938a..68770f2 100644 --- a/src/components/ModeTabs.vue +++ b/src/components/ModeTabs.vue @@ -5,6 +5,7 @@ defineProps<{ modelValue: T options: ModeOption[] label: string + disabled?: boolean }>() const emit = defineEmits<{ 'update:modelValue': [value: T] }>() @@ -19,6 +20,7 @@ const emit = defineEmits<{ 'update:modelValue': [value: T] }>() class="mode-tab" :class="{ 'is-active': modelValue === option.value }" :aria-pressed="modelValue === option.value" + :disabled="disabled" @click="emit('update:modelValue', option.value)" > {{ option.label }} diff --git a/src/lib/firmwareHash.ts b/src/lib/firmwareHash.ts new file mode 100644 index 0000000..0704f0c --- /dev/null +++ b/src/lib/firmwareHash.ts @@ -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 + +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 { + 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 { + 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 { + 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, + } +} diff --git a/src/lib/firmwareHashWorker.ts b/src/lib/firmwareHashWorker.ts new file mode 100644 index 0000000..d4e4f97 --- /dev/null +++ b/src/lib/firmwareHashWorker.ts @@ -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 +} diff --git a/src/lib/intelHex.ts b/src/lib/intelHex.ts index c5ca843..70d923e 100644 --- a/src/lib/intelHex.ts +++ b/src/lib/intelHex.ts @@ -10,10 +10,14 @@ export interface ParsedIntelHex { } export interface IntelHexLimits { + maxRecords?: number maxDataRecords?: 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_BLOCKS = 1_024 @@ -36,8 +40,26 @@ function parseRecord(line: string, lineNumber: number): Uint8Array { 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 { - if (!Number.isInteger(value) || value < 1) throw new Error(`${label} 必须是正整数`) + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${label} 必须是正整数`) return value } @@ -47,7 +69,7 @@ function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] { for (let index = 0; index < sorted.length; index += 1) { 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) if (!current || record.startAddress > current.endAddress) { 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 { + 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 maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限') const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/) @@ -85,21 +108,24 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse const warnings: string[] = [] let baseAddress = 0 let eofSeen = false + let recordCount = 0 for (let index = 0; index < lines.length; index += 1) { const line = lines[index].trim() if (!line) continue const lineNumber = index + 1 + recordCount += 1 + if (recordCount > maxRecords) throw new Error(`Intel HEX 记录不能超过 ${maxRecords.toLocaleString()} 条`) if (eofSeen) throw new Error(`第 ${lineNumber} 行位于 EOF 记录之后`) const bytes = parseRecord(line, lineNumber) const byteCount = bytes[0] - const recordAddress = (bytes[1] << 8) | bytes[2] + const recordAddress = readUint16(bytes, 1) const recordType = bytes[3] const data = bytes.slice(4, 4 + byteCount) if (recordType === 0x00) { const startAddress = baseAddress + recordAddress - if (startAddress + data.length > 0x100000000) throw new Error(`第 ${lineNumber} 行地址超出 32 位范围`) + getEndAddressExclusive(startAddress, data.length, `第 ${lineNumber} 行`) if (data.length) { if (records.length >= maxDataRecords) throw new Error(`Intel HEX 数据记录不能超过 ${maxDataRecords.toLocaleString()} 条`) records.push({ startAddress, data, line: lineNumber }) @@ -109,10 +135,10 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse eofSeen = true } else if (recordType === 0x02) { if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展段地址记录格式无效`) - baseAddress = ((data[0] << 8) | data[1]) << 4 + baseAddress = readUint16(data) * 0x10 } else if (recordType === 0x04) { 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) { if (byteCount !== 4 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行启动地址记录格式无效`) } else { diff --git a/src/styles.css b/src/styles.css index add2874..165304b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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:hover { color: var(--text); } .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; } .control-spacer, .action-spacer { flex: 1; } .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 tbody tr:last-child td { border-bottom: 0; } .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 strong { color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; } .rsa-generate-row { margin-top: 0; margin-bottom: 18px; } @@ -296,6 +319,12 @@ button { color: inherit; } .firmware-summary > div { width: 100%; } .crc-total { align-items: flex-start; flex-direction: column; } .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) { diff --git a/src/tools/FirmwareHashTool.vue b/src/tools/FirmwareHashTool.vue new file mode 100644 index 0000000..f006d37 --- /dev/null +++ b/src/tools/FirmwareHashTool.vue @@ -0,0 +1,237 @@ + + + diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 393c185..5e3b184 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -5,6 +5,7 @@ import { Code2, Dices, FileCode2, + Hash, Image, KeyRound, ShieldCheck, @@ -20,6 +21,7 @@ export const iconComponents: Record = { Code2, Dices, FileCode2, + Hash, Image, KeyRound, ShieldCheck, @@ -42,6 +44,7 @@ export const toolComponents: Record = { 'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')), random: defineAsyncComponent(() => import('./RandomTool.vue')), crc: defineAsyncComponent(() => import('./CrcTool.vue')), + 'firmware-hash': defineAsyncComponent(() => import('./FirmwareHashTool.vue')), json: defineAsyncComponent(() => import('./JsonTool.vue')), xml: defineAsyncComponent(() => import('./XmlTool.vue')), 'png-ico': defineAsyncComponent(() => import('./PngIcoTool.vue')), diff --git a/src/workers/firmwareHash.worker.ts b/src/workers/firmwareHash.worker.ts new file mode 100644 index 0000000..cddd100 --- /dev/null +++ b/src/workers/firmwareHash.worker.ts @@ -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) => 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 计算失败', + }) + } +} diff --git a/tests/firmwareHash.test.ts b/tests/firmwareHash.test.ts new file mode 100644 index 0000000..b45af73 --- /dev/null +++ b/tests/firmwareHash.test.ts @@ -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) + }) +}) diff --git a/tests/intelHexBoundaries.test.ts b/tests/intelHexBoundaries.test.ts new file mode 100644 index 0000000..77a70fb --- /dev/null +++ b/tests/intelHexBoundaries.test.ts @@ -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('数据长度与记录声明不一致') + }) +})