diff --git a/components/partition-table-editor/PartitionTableEditor.vue b/components/partition-table-editor/PartitionTableEditor.vue new file mode 100644 index 0000000..7734894 --- /dev/null +++ b/components/partition-table-editor/PartitionTableEditor.vue @@ -0,0 +1,457 @@ + + + diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..77d4b7a --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,4 @@ +export * from './shared'; +export * as nvs from './nvs'; +export * as partitionTable from './partition-table'; +export * as appImage from './app-image'; diff --git a/lib/partition-table/constants.ts b/lib/partition-table/constants.ts new file mode 100644 index 0000000..a0f72ed --- /dev/null +++ b/lib/partition-table/constants.ts @@ -0,0 +1,20 @@ +/** Each partition entry is 32 bytes */ +export const ENTRY_SIZE = 32; + +/** Magic bytes marking a valid entry (little-endian 0xAA50) */ +export const ENTRY_MAGIC = 0x50AA; + +/** MD5 checksum marker (little-endian 0xEBEB) */ +export const MD5_MAGIC = 0xEBEB; + +/** Default partition table offset in flash */ +export const DEFAULT_TABLE_OFFSET = 0x8000; + +/** Maximum number of partition entries */ +export const MAX_ENTRIES = 95; + +/** Name field size in bytes */ +export const NAME_FIELD_SIZE = 16; + +/** Partition table maximum binary size (3KB) */ +export const TABLE_MAX_SIZE = 0xC00; diff --git a/lib/partition-table/csv-parser.ts b/lib/partition-table/csv-parser.ts new file mode 100644 index 0000000..f8dee38 --- /dev/null +++ b/lib/partition-table/csv-parser.ts @@ -0,0 +1,144 @@ +import { PartitionEntry, PartitionFlags, PartitionTable, PartitionType, NAME_TO_TYPE, subtypeFromName } from './types'; + +const U32_MAX = 0xFFFF_FFFF; + +/** Parse partition flags: handles numeric values, single names, and combined forms like "encrypted readonly" */ +function parseFlags(str: string): number { + if (!str) return 0; + const normalized = str.trim().toLowerCase(); + // Numeric values (hex or decimal) take priority + if (/^0x[\da-f]+$/i.test(normalized) || /^\d+$/.test(normalized)) { + return parseSize(str); + } + // Split on whitespace, pipe, or comma to support combined flags + let result = 0; + for (const part of normalized.split(/[\s|,]+/).filter(Boolean)) { + if (part === 'encrypted') result |= PartitionFlags.ENCRYPTED; + else if (part === 'readonly') result |= PartitionFlags.READONLY; + else throw new Error(`未知标志: "${part}"`); + } + return result; +} + +/** Parse a size string like "0x6000", "1M", "32K", "4096" */ +function parseSize(str: string): number { + str = str.trim(); + if (!str) return 0; + if (str.startsWith('0x') || str.startsWith('0X')) { + if (!/^0x[0-9a-f]+$/i.test(str)) throw new Error(`无效的大小/偏移值: "${str}"`); + const v = parseInt(str, 16); + if (isNaN(v) || v < 0 || v > U32_MAX) throw new Error(`无效的大小/偏移值: "${str}"`); + return v; + } + + const match = str.match(/^(\d+(?:\.\d+)?)\s*([KkMm])?$/); + if (match) { + const num = parseFloat(match[1]); + const unit = (match[2] || '').toUpperCase(); + let result: number; + if (unit === 'K') result = Math.floor(num * 1024); + else if (unit === 'M') result = Math.floor(num * 1024 * 1024); + else result = Math.floor(num); + if (result > U32_MAX) throw new Error(`无效的大小/偏移值: "${str}" (超出 32 位范围)`); + return result; + } + + throw new Error(`无效的大小/偏移值: "${str}"`); +} + +/** + * Split one CSV line into trimmed fields, respecting RFC-4180 quoting and + * treating an unquoted '#' as the start of an inline comment (discards rest). + */ +function splitCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + let i = 0; + + while (i < line.length) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { // escaped quote "" + current += '"'; + i += 2; + } else if (ch === '"') { + inQuotes = false; + i++; + } else { + current += ch; + i++; + } + } else { + if (ch === '"') { + inQuotes = true; + i++; + } else if (ch === ',') { + fields.push(current.trim()); + current = ''; + i++; + } else if (ch === '#') { + break; // inline comment — discard rest of line + } else { + current += ch; + i++; + } + } + } + if (inQuotes) throw new Error('引号未闭合'); + fields.push(current.trim()); + return fields; +} + +/** + * Parse ESP-IDF partition table CSV format. + * + * Format: + * # Name, Type, SubType, Offset, Size, Flags + * nvs, data, nvs, 0x9000, 0x6000, + * phy_init, data, phy, 0xf000, 0x1000, + * factory, app, factory, 0x10000, 1M, + */ +export function parseCsv(text: string, onWarning?: (line: number, message: string) => void): PartitionTable { + const lines = text.split(/\r?\n/); + const entries: PartitionEntry[] = []; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx].trim(); + if (!line || line.startsWith('#')) continue; + + try { + const fields = splitCsvLine(line); + if (fields.length < 5) { + onWarning?.(lineIdx + 1, `字段数量不足 (需要 5,实际 ${fields.length}): "${line}"`); + continue; + } + + // Skip header line + if (fields[0].toLowerCase() === 'name' && fields[1].toLowerCase() === 'type') continue; + + const name = fields[0]; + const typeName = fields[1].toLowerCase(); + const subtypeName = fields[2]; + const offsetStr = fields[3]; + const sizeStr = fields[4]; + const flagsStr = fields[5] || ''; + + const type = NAME_TO_TYPE[typeName]; + if (type === undefined) { + onWarning?.(lineIdx + 1, `未知分区类型: "${typeName}"`); + continue; + } + + const subtype = subtypeFromName(type, subtypeName); + const offset = parseSize(offsetStr); + const size = parseSize(sizeStr); + const flags = parseFlags(flagsStr); + entries.push({ name, type, subtype, offset, size, flags }); + } catch (e) { + onWarning?.(lineIdx + 1, `解析失败: ${(e as Error).message}`); + } + } + + return { entries, md5Valid: false }; +} diff --git a/lib/partition-table/csv-serializer.ts b/lib/partition-table/csv-serializer.ts new file mode 100644 index 0000000..4677730 --- /dev/null +++ b/lib/partition-table/csv-serializer.ts @@ -0,0 +1,40 @@ +import { PartitionTable, TYPE_NAMES, getSubtypeName } from './types'; + +/** Format a number as hex with 0x prefix */ +function toHex(val: number): string { + return '0x' + val.toString(16); +} + +/** Format size with human-readable suffix if aligned */ +function formatSize(size: number): string { + if (size >= 1024 * 1024 && size % (1024 * 1024) === 0) { + return `${size / (1024 * 1024)}M`; + } + if (size >= 1024 && size % 1024 === 0) { + return `${size / 1024}K`; + } + return toHex(size); +} + +/** + * Serialize a PartitionTable to ESP-IDF CSV format. + * + * Output: + * # Name, Type, SubType, Offset, Size, Flags + * nvs, data, nvs, 0x9000, 0x6000, + */ +export function serializeCsv(table: PartitionTable): string { + const lines: string[] = ['# Name, Type, SubType, Offset, Size, Flags']; + + for (const entry of table.entries) { + const typeName = TYPE_NAMES[entry.type] ?? `0x${entry.type.toString(16).padStart(2, '0')}`; + const subtypeName = getSubtypeName(entry.type, entry.subtype); + const offset = toHex(entry.offset); + const size = formatSize(entry.size); + const flags = entry.flags ? toHex(entry.flags) : ''; + + lines.push(`${entry.name}, ${typeName}, ${subtypeName}, ${offset}, ${size}, ${flags}`); + } + + return lines.join('\n') + '\n'; +} diff --git a/lib/partition-table/index.ts b/lib/partition-table/index.ts new file mode 100644 index 0000000..bbb33a1 --- /dev/null +++ b/lib/partition-table/index.ts @@ -0,0 +1,26 @@ +// Types and interfaces +export type { PartitionEntry, PartitionTable } from './types'; + +export { + PartitionType, AppSubtype, DataSubtype, PartitionFlags, + TYPE_NAMES, APP_SUBTYPE_NAMES, DATA_SUBTYPE_NAMES, + NAME_TO_TYPE, + getSubtypeName, subtypeFromName, +} from './types'; + +// Constants +export { + ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, + DEFAULT_TABLE_OFFSET, MAX_ENTRIES, NAME_FIELD_SIZE, TABLE_MAX_SIZE, +} from './constants'; + +// Binary operations +export { parseBinary } from './parser'; +export { serializeBinary } from './serializer'; + +// CSV operations +export { parseCsv } from './csv-parser'; +export { serializeCsv } from './csv-serializer'; + +// Validation +export { validateTable, type PartitionValidationError } from './validator'; diff --git a/lib/partition-table/md5.ts b/lib/partition-table/md5.ts new file mode 100644 index 0000000..caafabd --- /dev/null +++ b/lib/partition-table/md5.ts @@ -0,0 +1,153 @@ +/** + * Lightweight MD5 implementation for partition table checksum validation. + * Web Crypto API does not support MD5, so we need a pure JS implementation. + */ + +function md5cycle(x: number[], k: number[]) { + let a = x[0], b = x[1], c = x[2], d = x[3]; + + a = ff(a, b, c, d, k[0], 7, -680876936); + d = ff(d, a, b, c, k[1], 12, -389564586); + c = ff(c, d, a, b, k[2], 17, 606105819); + b = ff(b, c, d, a, k[3], 22, -1044525330); + a = ff(a, b, c, d, k[4], 7, -176418897); + d = ff(d, a, b, c, k[5], 12, 1200080426); + c = ff(c, d, a, b, k[6], 17, -1473231341); + b = ff(b, c, d, a, k[7], 22, -45705983); + a = ff(a, b, c, d, k[8], 7, 1770035416); + d = ff(d, a, b, c, k[9], 12, -1958414417); + c = ff(c, d, a, b, k[10], 17, -42063); + b = ff(b, c, d, a, k[11], 22, -1990404162); + a = ff(a, b, c, d, k[12], 7, 1804603682); + d = ff(d, a, b, c, k[13], 12, -40341101); + c = ff(c, d, a, b, k[14], 17, -1502002290); + b = ff(b, c, d, a, k[15], 22, 1236535329); + + a = gg(a, b, c, d, k[1], 5, -165796510); + d = gg(d, a, b, c, k[6], 9, -1069501632); + c = gg(c, d, a, b, k[11], 14, 643717713); + b = gg(b, c, d, a, k[0], 20, -373897302); + a = gg(a, b, c, d, k[5], 5, -701558691); + d = gg(d, a, b, c, k[10], 9, 38016083); + c = gg(c, d, a, b, k[15], 14, -660478335); + b = gg(b, c, d, a, k[4], 20, -405537848); + a = gg(a, b, c, d, k[9], 5, 568446438); + d = gg(d, a, b, c, k[14], 9, -1019803690); + c = gg(c, d, a, b, k[3], 14, -187363961); + b = gg(b, c, d, a, k[8], 20, 1163531501); + a = gg(a, b, c, d, k[13], 5, -1444681467); + d = gg(d, a, b, c, k[2], 9, -51403784); + c = gg(c, d, a, b, k[7], 14, 1735328473); + b = gg(b, c, d, a, k[12], 20, -1926607734); + + a = hh(a, b, c, d, k[5], 4, -378558); + d = hh(d, a, b, c, k[8], 11, -2022574463); + c = hh(c, d, a, b, k[11], 16, 1839030562); + b = hh(b, c, d, a, k[14], 23, -35309556); + a = hh(a, b, c, d, k[1], 4, -1530992060); + d = hh(d, a, b, c, k[4], 11, 1272893353); + c = hh(c, d, a, b, k[7], 16, -155497632); + b = hh(b, c, d, a, k[10], 23, -1094730640); + a = hh(a, b, c, d, k[13], 4, 681279174); + d = hh(d, a, b, c, k[0], 11, -358537222); + c = hh(c, d, a, b, k[3], 16, -722521979); + b = hh(b, c, d, a, k[6], 23, 76029189); + a = hh(a, b, c, d, k[9], 4, -640364487); + d = hh(d, a, b, c, k[12], 11, -421815835); + c = hh(c, d, a, b, k[15], 16, 530742520); + b = hh(b, c, d, a, k[2], 23, -995338651); + + a = ii(a, b, c, d, k[0], 6, -198630844); + d = ii(d, a, b, c, k[7], 10, 1126891415); + c = ii(c, d, a, b, k[14], 15, -1416354905); + b = ii(b, c, d, a, k[5], 21, -57434055); + a = ii(a, b, c, d, k[12], 6, 1700485571); + d = ii(d, a, b, c, k[3], 10, -1894986606); + c = ii(c, d, a, b, k[10], 15, -1051523); + b = ii(b, c, d, a, k[1], 21, -2054922799); + a = ii(a, b, c, d, k[8], 6, 1873313359); + d = ii(d, a, b, c, k[15], 10, -30611744); + c = ii(c, d, a, b, k[6], 15, -1560198380); + b = ii(b, c, d, a, k[13], 21, 1309151649); + a = ii(a, b, c, d, k[4], 6, -145523070); + d = ii(d, a, b, c, k[11], 10, -1120210379); + c = ii(c, d, a, b, k[2], 15, 718787259); + b = ii(b, c, d, a, k[9], 21, -343485551); + + x[0] = add32(a, x[0]); + x[1] = add32(b, x[1]); + x[2] = add32(c, x[2]); + x[3] = add32(d, x[3]); +} + +function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number { + a = add32(add32(a, q), add32(x, t)); + return add32((a << s) | (a >>> (32 - s)), b); +} + +function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn((b & c) | ((~b) & d), a, b, x, s, t); +} + +function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn((b & d) | (c & (~d)), a, b, x, s, t); +} + +function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn(b ^ c ^ d, a, b, x, s, t); +} + +function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn(c ^ (b | (~d)), a, b, x, s, t); +} + +function add32(a: number, b: number): number { + return (a + b) & 0xFFFFFFFF; +} + +/** Compute MD5 hash of a Uint8Array. Returns 16-byte Uint8Array. */ +export function md5(data: Uint8Array): Uint8Array { + const n = data.length; + const state = [1732584193, -271733879, -1732584194, 271733878]; + let i: number; + + // Pre-processing: pad message + // Message length in bits = n * 8 + // Append 0x80, then zeros, then 64-bit length + const padLen = ((56 - (n + 1) % 64) + 64) % 64; + const totalLen = n + 1 + padLen + 8; + const buf = new Uint8Array(totalLen); + buf.set(data); + buf[n] = 0x80; + // Write length in bits as 64-bit LE + const bitLen = n * 8; + buf[totalLen - 8] = bitLen & 0xFF; + buf[totalLen - 7] = (bitLen >> 8) & 0xFF; + buf[totalLen - 6] = (bitLen >> 16) & 0xFF; + buf[totalLen - 5] = (bitLen >> 24) & 0xFF; + // Upper 32 bits of bit length (for messages < 512MB, these are 0) + buf[totalLen - 4] = 0; + buf[totalLen - 3] = 0; + buf[totalLen - 2] = 0; + buf[totalLen - 1] = 0; + + // Process each 64-byte block + for (i = 0; i < totalLen; i += 64) { + const k = new Array(16); + for (let j = 0; j < 16; j++) { + const off = i + j * 4; + k[j] = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24); + } + md5cycle(state, k); + } + + // Convert state to bytes (LE) + const result = new Uint8Array(16); + for (let j = 0; j < 4; j++) { + result[j * 4] = state[j] & 0xFF; + result[j * 4 + 1] = (state[j] >> 8) & 0xFF; + result[j * 4 + 2] = (state[j] >> 16) & 0xFF; + result[j * 4 + 3] = (state[j] >> 24) & 0xFF; + } + return result; +} diff --git a/lib/partition-table/parser.ts b/lib/partition-table/parser.ts new file mode 100644 index 0000000..db42ef0 --- /dev/null +++ b/lib/partition-table/parser.ts @@ -0,0 +1,73 @@ +import { readU16, readU32, readNullTermString } from '../shared/binary-reader'; +import { PartitionEntry, PartitionTable, PartitionType } from './types'; +import { ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, NAME_FIELD_SIZE, MAX_ENTRIES } from './constants'; +import { md5 } from './md5'; + +/** + * Parse ESP32 partition table binary. + * @param data Raw binary data (the partition table region, typically 3KB at offset 0x8000) + */ +export function parseBinary(data: Uint8Array): PartitionTable { + const entries: PartitionEntry[] = []; + let md5Valid = false; + let corrupted = false; + let offset = 0; + + for (let i = 0; i < MAX_ENTRIES && offset + ENTRY_SIZE <= data.length; i++) { + const magic = readU16(data, offset); + + if (magic === 0xFFFF) { + // Empty entry — end of table (erased flash) + break; + } + + if (magic === MD5_MAGIC) { + // MD5 checksum entry + // Bytes [16..31] contain the stored MD5 hash of all preceding bytes + const storedMd5 = data.subarray(offset + 16, offset + 32); + const computedMd5 = md5(data.subarray(0, offset)); + + md5Valid = storedMd5.length === 16 && computedMd5.length === 16 && + storedMd5.every((v, j) => v === computedMd5[j]); + break; + } + + if (magic !== ENTRY_MAGIC) { + // Unknown magic — binary is corrupted + corrupted = true; + break; + } + + // Parse 32-byte entry: + // [0..1] magic (already read) + // [2] type + // [3] subtype + // [4..7] offset (LE uint32) + // [8..11] size (LE uint32) + // [12..27] name (null-terminated, 16 bytes) + // [28..31] flags (LE uint32) + const type = data[offset + 2] as PartitionType; + const subtype = data[offset + 3]; + const partOffset = readU32(data, offset + 4); + const size = readU32(data, offset + 8); + const name = readNullTermString(data, offset + 12, NAME_FIELD_SIZE); + const flags = readU32(data, offset + 28); + + entries.push({ name, type, subtype, offset: partOffset, size, flags }); + offset += ENTRY_SIZE; + } + + // When the table is exactly full (MAX_ENTRIES entries), the loop exits on + // i >= MAX_ENTRIES without ever seeing the MD5 slot. Check it explicitly. + if (!md5Valid && offset + ENTRY_SIZE <= data.length) { + const magic = readU16(data, offset); + if (magic === MD5_MAGIC) { + const storedMd5 = data.subarray(offset + 16, offset + 32); + const computedMd5 = md5(data.subarray(0, offset)); + md5Valid = storedMd5.length === 16 && computedMd5.length === 16 && + storedMd5.every((v, j) => v === computedMd5[j]); + } + } + + return { entries, md5Valid, ...(corrupted ? { corrupted } : {}) }; +} diff --git a/lib/partition-table/serializer.ts b/lib/partition-table/serializer.ts new file mode 100644 index 0000000..4591e7c --- /dev/null +++ b/lib/partition-table/serializer.ts @@ -0,0 +1,56 @@ +import { writeU16, writeU32, writeNullTermString } from '../shared/binary-writer'; +import { PartitionTable } from './types'; +import { ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, NAME_FIELD_SIZE, TABLE_MAX_SIZE } from './constants'; +import { md5 } from './md5'; + +const U32_MAX = 0xFFFF_FFFF; + +function assertU8(val: number, field: string): void { + if (!Number.isInteger(val) || val < 0 || val > 0xFF) + throw new Error(`"${field}" 不是有效的字节值 (0–255): ${val}`); +} + +function assertU32(val: number, field: string): void { + if (!Number.isInteger(val) || val < 0 || val > U32_MAX) + throw new Error(`"${field}" 不是有效的 32 位无符号整数 (0–0xFFFFFFFF): ${val}`); +} + +/** + * Serialize a PartitionTable to ESP32 binary format. + * Returns a Uint8Array of TABLE_MAX_SIZE filled with 0xFF for empty space. + */ +export function serializeBinary(table: PartitionTable): Uint8Array { + const buf = new Uint8Array(TABLE_MAX_SIZE); + buf.fill(0xFF); + + let offset = 0; + for (const entry of table.entries) { + if (offset + ENTRY_SIZE > TABLE_MAX_SIZE - ENTRY_SIZE) { + throw new Error(`分区表条目过多,超过最大容量 (最多 ${Math.floor((TABLE_MAX_SIZE - ENTRY_SIZE) / ENTRY_SIZE)} 条)`); + } + + assertU8(entry.type, '类型'); + assertU8(entry.subtype, '子类型'); + assertU32(entry.offset, '偏移量'); + assertU32(entry.size, '大小'); + assertU32(entry.flags, '标志'); + + writeU16(buf, offset, ENTRY_MAGIC); + buf[offset + 2] = entry.type; + buf[offset + 3] = entry.subtype; + writeU32(buf, offset + 4, entry.offset); + writeU32(buf, offset + 8, entry.size); + writeNullTermString(buf, offset + 12, entry.name, NAME_FIELD_SIZE); + writeU32(buf, offset + 28, entry.flags); + offset += ENTRY_SIZE; + } + + // Write MD5 checksum entry + // [0..1] = MD5_MAGIC, [2..15] = 0xFF padding, [16..31] = MD5 hash + writeU16(buf, offset, MD5_MAGIC); + // [2..15] already 0xFF from fill + const hash = md5(buf.subarray(0, offset)); + buf.set(hash, offset + 16); + + return buf; +} diff --git a/lib/partition-table/types.ts b/lib/partition-table/types.ts new file mode 100644 index 0000000..20ff8f9 --- /dev/null +++ b/lib/partition-table/types.ts @@ -0,0 +1,137 @@ +/** Partition type (top-level: app or data) */ +export enum PartitionType { + APP = 0x00, + DATA = 0x01, +} + +/** App subtypes */ +export enum AppSubtype { + FACTORY = 0x00, + OTA_0 = 0x10, + OTA_1 = 0x11, + OTA_2 = 0x12, + OTA_3 = 0x13, + OTA_4 = 0x14, + OTA_5 = 0x15, + OTA_6 = 0x16, + OTA_7 = 0x17, + OTA_8 = 0x18, + OTA_9 = 0x19, + OTA_10 = 0x1A, + OTA_11 = 0x1B, + OTA_12 = 0x1C, + OTA_13 = 0x1D, + OTA_14 = 0x1E, + OTA_15 = 0x1F, + TEST = 0x20, +} + +/** Data subtypes */ +export enum DataSubtype { + OTA = 0x00, + PHY = 0x01, + NVS = 0x02, + COREDUMP = 0x03, + NVS_KEYS = 0x04, + EFUSE_EM = 0x05, + UNDEFINED = 0x06, + FAT = 0x81, + SPIFFS = 0x82, + LITTLEFS = 0x83, +} + +/** Partition flags */ +export enum PartitionFlags { + NONE = 0x00, + ENCRYPTED = 0x01, + READONLY = 0x02, +} + +/** A single parsed partition entry */ +export interface PartitionEntry { + name: string; + type: PartitionType; + subtype: number; + offset: number; + size: number; + flags: number; +} + +/** The complete partition table */ +export interface PartitionTable { + entries: PartitionEntry[]; + md5Valid: boolean; + /** True if an unexpected magic value was found mid-table (indicates binary corruption) */ + corrupted?: boolean; +} + +/** Human-readable type name map */ +export const TYPE_NAMES: Record = { + [PartitionType.APP]: 'app', + [PartitionType.DATA]: 'data', +}; + +/** Human-readable app subtype name map */ +export const APP_SUBTYPE_NAMES: Record = { + [AppSubtype.FACTORY]: 'factory', + [AppSubtype.TEST]: 'test', +}; +// OTA_0..OTA_15 +for (let i = 0; i <= 15; i++) { + APP_SUBTYPE_NAMES[0x10 + i] = `ota_${i}`; +} + +/** Human-readable data subtype name map */ +export const DATA_SUBTYPE_NAMES: Record = { + [DataSubtype.OTA]: 'ota', + [DataSubtype.PHY]: 'phy', + [DataSubtype.NVS]: 'nvs', + [DataSubtype.COREDUMP]: 'coredump', + [DataSubtype.NVS_KEYS]: 'nvs_keys', + [DataSubtype.EFUSE_EM]: 'efuse', + [DataSubtype.UNDEFINED]: 'undefined', + [DataSubtype.FAT]: 'fat', + [DataSubtype.SPIFFS]: 'spiffs', + [DataSubtype.LITTLEFS]: 'littlefs', +}; + +/** Get human-readable subtype name */ +export function getSubtypeName(type: PartitionType, subtype: number): string { + if (type === PartitionType.APP) { + return APP_SUBTYPE_NAMES[subtype] ?? `0x${subtype.toString(16).padStart(2, '0')}`; + } + return DATA_SUBTYPE_NAMES[subtype] ?? `0x${subtype.toString(16).padStart(2, '0')}`; +} + +/** Reverse lookup: type name string → PartitionType */ +export const NAME_TO_TYPE: Record = { + 'app': PartitionType.APP, + 'data': PartitionType.DATA, +}; + +/** Reverse lookup: subtype name → number, keyed by parent type */ +export function subtypeFromName(type: PartitionType, name: string): number { + const normalized = name.trim().toLowerCase(); + if (type === PartitionType.APP) { + for (const [val, n] of Object.entries(APP_SUBTYPE_NAMES)) { + if (n === normalized) return Number(val); + } + } else { + for (const [val, n] of Object.entries(DATA_SUBTYPE_NAMES)) { + if (n === normalized) return Number(val); + } + } + // Try numeric parse (decimal or hex) — full-string, byte-range validated + if (normalized.startsWith('0x')) { + if (!/^0x[0-9a-f]+$/i.test(normalized)) throw new Error(`Unknown partition subtype: "${name}"`); + const v = parseInt(normalized, 16); + if (isNaN(v) || v < 0 || v > 255) throw new Error(`Subtype value out of byte range: "${name}"`); + return v; + } else { + if (!/^\d+$/.test(normalized)) throw new Error(`Unknown partition subtype: "${name}"`); + const v = parseInt(normalized, 10); + if (v > 255) throw new Error(`Subtype value out of byte range: "${name}"`); + return v; + } + throw new Error(`Unknown partition subtype: "${name}"`); +} diff --git a/lib/partition-table/validator.ts b/lib/partition-table/validator.ts new file mode 100644 index 0000000..9710561 --- /dev/null +++ b/lib/partition-table/validator.ts @@ -0,0 +1,69 @@ +import { PartitionEntry, PartitionTable } from './types'; + +export interface PartitionValidationError { + type: 'overlap' | 'alignment' | 'duplicate_name'; + message: string; + entryA?: PartitionEntry; + entryB?: PartitionEntry; +} + +const SECTOR_SIZE = 0x1000; // 4KB + +export function validateTable(table: PartitionTable): PartitionValidationError[] { + const errors: PartitionValidationError[] = []; + const entries = table.entries; + + // Duplicate name detection + const names = new Map(); + for (const entry of entries) { + if (names.has(entry.name)) { + errors.push({ + type: 'duplicate_name', + message: `分区名称重复: "${entry.name}"`, + entryA: names.get(entry.name), + entryB: entry, + }); + } else { + names.set(entry.name, entry); + } + } + + // Alignment validation + for (const entry of entries) { + if (entry.offset !== 0 && entry.offset % SECTOR_SIZE !== 0) { + errors.push({ + type: 'alignment', + message: `"${entry.name}" 偏移 0x${entry.offset.toString(16)} 未对齐到 4KB 边界`, + entryA: entry, + }); + } + if (entry.size !== 0 && entry.size % SECTOR_SIZE !== 0) { + errors.push({ + type: 'alignment', + message: `"${entry.name}" 大小 0x${entry.size.toString(16)} 未对齐到 4KB 边界`, + entryA: entry, + }); + } + } + + // Overlap detection (skip entries with offset === 0 — they may be auto-assigned) + const nonZeroEntries = entries.filter(e => e.offset !== 0 && e.size !== 0); + for (let i = 0; i < nonZeroEntries.length; i++) { + const a = nonZeroEntries[i]; + const aEnd = a.offset + a.size; + for (let j = i + 1; j < nonZeroEntries.length; j++) { + const b = nonZeroEntries[j]; + const bEnd = b.offset + b.size; + if (a.offset < bEnd && b.offset < aEnd) { + errors.push({ + type: 'overlap', + message: `"${a.name}" [0x${a.offset.toString(16)}..0x${aEnd.toString(16)}] 与 "${b.name}" [0x${b.offset.toString(16)}..0x${bEnd.toString(16)}] 重叠`, + entryA: a, + entryB: b, + }); + } + } + } + + return errors; +}