yunsi-toolbox-vue/lib/nvs/nvs-partition.ts

506 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { NvsEntry, NvsPartition, NvsFlashStats } from './types';
import { NvsType, NvsVersion, isPrimitiveType } from './types';
import { ENTRIES_PER_PAGE, ENTRY_SIZE, PAGE_SIZE, MAX_KEY_LENGTH, MAX_NAMESPACES, MAX_STRING_LENGTH, MAX_BLOB_SIZE_V1, MAX_BLOB_SIZE_V2 } from './constants';
/** Result of normalizing a raw deserialized partition. */
export interface NormalizeResult {
partition: NvsPartition;
/** Entries that were completely unsalvageable and removed. */
dropped: number;
/** Entries whose numeric values were clamped to fit the type range. */
clamped: number;
}
/** Generate a random unique ID for client-side entry tracking */
export function generateEntryId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
/** Create an empty partition with default V2 version */
export function createEmptyPartition(version: NvsVersion = NvsVersion.V2): NvsPartition {
return { entries: [], namespaces: [], version };
}
/** Add a new entry. Returns a new NvsPartition (immutable). */
export function addEntry(
partition: NvsPartition,
entry: Omit<NvsEntry, 'id'>,
): NvsPartition {
const newEntry: NvsEntry = { ...entry, id: generateEntryId() };
const namespaces = partition.namespaces.includes(entry.namespace)
? partition.namespaces
: [...partition.namespaces, entry.namespace];
return {
...partition,
entries: [...partition.entries, newEntry],
namespaces,
};
}
/** Remove an entry by ID. Returns a new NvsPartition. */
export function removeEntry(partition: NvsPartition, entryId: string): NvsPartition {
const entries = partition.entries.filter(e => e.id !== entryId);
// Clean up namespaces that have no remaining entries
const usedNs = new Set(entries.map(e => e.namespace));
const namespaces = partition.namespaces.filter(ns => usedNs.has(ns));
return { ...partition, entries, namespaces };
}
/** Update an existing entry. Returns a new NvsPartition. */
export function updateEntry(
partition: NvsPartition,
entryId: string,
updates: Partial<Omit<NvsEntry, 'id'>>,
): NvsPartition {
const entries = partition.entries.map(e =>
e.id === entryId ? { ...e, ...updates } : e,
);
// If namespace changed, add it. Intentionally does NOT remove the old namespace:
// partition.namespaces doubles as a UI dropdown convenience list; orphaned entries
// are silently filtered out at serialization/validation/restore boundaries.
let namespaces = partition.namespaces;
if (updates.namespace && !namespaces.includes(updates.namespace)) {
namespaces = [...namespaces, updates.namespace];
}
return { ...partition, entries, namespaces };
}
/** Duplicate an entry with a new ID. */
export function duplicateEntry(partition: NvsPartition, entryId: string): NvsPartition {
const source = partition.entries.find(e => e.id === entryId);
if (!source) return partition;
const clone: NvsEntry = {
...source,
id: generateEntryId(),
key: source.key,
// Deep copy Uint8Array values
value: source.value instanceof Uint8Array
? new Uint8Array(source.value)
: source.value,
};
return { ...partition, entries: [...partition.entries, clone] };
}
/**
* Merge source into target by (namespace, key) match.
* @param mode 'overwrite' replaces matching entries; 'skip' keeps target's value
*/
export function mergePartitions(
target: NvsPartition,
source: NvsPartition,
mode: 'overwrite' | 'skip' = 'overwrite',
): NvsPartition {
const entries = [...target.entries];
const namespaces = [...target.namespaces];
for (const srcEntry of source.entries) {
if (!namespaces.includes(srcEntry.namespace)) {
namespaces.push(srcEntry.namespace);
}
const idx = entries.findIndex(
e => e.namespace === srcEntry.namespace && e.key === srcEntry.key,
);
if (idx >= 0) {
if (mode === 'overwrite') {
entries[idx] = { ...srcEntry, id: entries[idx].id };
}
// skip: do nothing
} else {
entries.push({ ...srcEntry, id: generateEntryId() });
}
}
return { ...target, entries: reconcileBlobTypes(entries, target.version), namespaces };
}
/** Calculate the entry span for a single NvsEntry */
export function entrySpan(entry: NvsEntry, version: NvsVersion): number {
if (isPrimitiveType(entry.type)) return 1;
if (entry.type === NvsType.SZ) {
const strBytes = new TextEncoder().encode(entry.value as string);
const dataLen = strBytes.length + 1; // +1 for null terminator
return 1 + Math.ceil(dataLen / ENTRY_SIZE);
}
// BLOB / BLOB_DATA
const data = entry.value as Uint8Array;
if (version === NvsVersion.V1 || entry.type === NvsType.BLOB) {
return 1 + Math.ceil(data.length / ENTRY_SIZE);
}
// V2: BLOB_DATA chunks + BLOB_IDX — each chunk has its own header entry
const maxChunkPayload = (ENTRIES_PER_PAGE - 1) * ENTRY_SIZE;
const chunkCount = Math.max(1, Math.ceil(data.length / maxChunkPayload));
let totalSpan = 0;
let remaining = data.length;
for (let i = 0; i < chunkCount; i++) {
const chunkLen = Math.min(remaining, maxChunkPayload);
totalSpan += 1 + Math.ceil(chunkLen / ENTRY_SIZE);
remaining -= chunkLen;
}
return totalSpan + 1; // +1 for BLOB_IDX entry
}
/** Calculate flash usage statistics for a given partition at a target size */
export function calculateFlashStats(
partition: NvsPartition,
targetSizeBytes: number,
): NvsFlashStats {
const totalPages = Math.floor(targetSizeBytes / PAGE_SIZE);
const usablePages = Math.max(totalPages - 1, 0); // reserve 1 for GC
const maxEntries = usablePages * ENTRIES_PER_PAGE;
// Build a flat list of entry spans (namespace defs + data entries)
// Derive used namespaces from entries (ignores orphaned namespaces)
const usedNs = new Set(partition.entries.map(e => e.namespace));
const activeNs = partition.namespaces.filter(ns => usedNs.has(ns));
const spans: number[] = [];
for (const _ns of activeNs) spans.push(1);
for (const entry of partition.entries) spans.push(entrySpan(entry, partition.version));
// Simulate page-packing to count actual slot consumption (including fragmentation waste).
// Entries cannot span page boundaries; remaining slots on a page are wasted when an entry
// doesn't fit, identical to the serializer's behaviour.
let currentEntryIdx = 0;
let totalSlotsUsed = 0;
for (const span of spans) {
if (currentEntryIdx + span > ENTRIES_PER_PAGE) {
totalSlotsUsed += ENTRIES_PER_PAGE - currentEntryIdx; // wasted slots
currentEntryIdx = 0;
}
totalSlotsUsed += span;
currentEntryIdx += span;
if (currentEntryIdx >= ENTRIES_PER_PAGE) currentEntryIdx = 0;
}
const logicalEntries = spans.reduce((a, b) => a + b, 0);
const usedBytes = totalSlotsUsed * ENTRY_SIZE + totalPages * 64; // 64 = header + bitmap
const usagePercent = maxEntries > 0 ? Math.min((totalSlotsUsed / maxEntries) * 100, 100) : 0;
return {
totalBytes: targetSizeBytes,
totalPages,
usedEntries: logicalEntries,
maxEntries,
usedBytes,
usagePercent: Math.round(usagePercent * 10) / 10,
};
}
/** Structured validation error with optional entry ID for precise highlighting. */
export interface ValidationError {
message: string;
/** Entry ID that caused the error, undefined for partition-level errors. */
entryId?: string;
}
/** Validate partition data. Returns array of validation errors (empty = valid). */
export function validatePartition(partition: NvsPartition): ValidationError[] {
const errors: ValidationError[] = [];
// Derive active namespaces from entries (ignore orphaned namespaces left by updateEntry)
const usedNs = new Set(partition.entries.map(e => e.namespace));
const activeNs = partition.namespaces.filter(ns => usedNs.has(ns));
if (activeNs.length > MAX_NAMESPACES) {
errors.push({ message: `Namespace count exceeds limit ${MAX_NAMESPACES}` });
}
for (const ns of activeNs) {
if (ns.length === 0) {
errors.push({ message: 'Namespace name cannot be empty' });
}
if (ns.length > MAX_KEY_LENGTH) {
errors.push({ message: `Namespace "${ns}" exceeds ${MAX_KEY_LENGTH} characters` });
}
if ([...ns].some(c => c.charCodeAt(0) > 0xFF)) {
errors.push({ message: `Namespace "${ns}" contains non-Latin-1 characters (binary format only supports 8-bit characters)` });
}
}
for (const entry of partition.entries) {
const eid = entry.id;
if (entry.key.length === 0) {
errors.push({ message: `Empty key in namespace "${entry.namespace}"`, entryId: eid });
}
if (entry.key.length > MAX_KEY_LENGTH) {
errors.push({ message: `Key "${entry.key}" exceeds ${MAX_KEY_LENGTH} characters`, entryId: eid });
}
if ([...entry.key].some(c => c.charCodeAt(0) > 0xFF)) {
errors.push({ message: `Key "${entry.key}" contains non-Latin-1 characters`, entryId: eid });
}
if (!partition.namespaces.includes(entry.namespace)) {
errors.push({ message: `Key "${entry.key}" references unregistered namespace "${entry.namespace}"`, entryId: eid });
}
// Validate value ranges for primitive types
if (isPrimitiveType(entry.type)) {
if (typeof entry.value === 'number') {
const v = entry.value;
switch (entry.type) {
case NvsType.U8: if (v < 0 || v > 0xFF) errors.push({ message: `"${entry.key}" U8 value out of range`, entryId: eid }); break;
case NvsType.I8: if (v < -128 || v > 127) errors.push({ message: `"${entry.key}" I8 value out of range`, entryId: eid }); break;
case NvsType.U16: if (v < 0 || v > 0xFFFF) errors.push({ message: `"${entry.key}" U16 value out of range`, entryId: eid }); break;
case NvsType.I16: if (v < -32768 || v > 32767) errors.push({ message: `"${entry.key}" I16 value out of range`, entryId: eid }); break;
case NvsType.U32: if (v < 0 || v > 0xFFFFFFFF) errors.push({ message: `"${entry.key}" U32 value out of range`, entryId: eid }); break;
case NvsType.I32: if (v < -2147483648 || v > 2147483647) errors.push({ message: `"${entry.key}" I32 value out of range`, entryId: eid }); break;
}
} else if (typeof entry.value === 'bigint') {
const v = entry.value;
switch (entry.type) {
case NvsType.U64:
if (v < 0n || v > 0xFFFFFFFFFFFFFFFFn) errors.push({ message: `"${entry.key}" U64 value out of range`, entryId: eid });
break;
case NvsType.I64:
if (v < -9223372036854775808n || v > 9223372036854775807n) errors.push({ message: `"${entry.key}" I64 value out of range`, entryId: eid });
break;
}
}
}
// Validate string length
if (entry.type === NvsType.SZ && typeof entry.value === 'string') {
const byteLen = new TextEncoder().encode(entry.value).length;
if (byteLen >= MAX_STRING_LENGTH) {
errors.push({ message: `"${entry.key}" string length ${byteLen} bytes exceeds limit ${MAX_STRING_LENGTH - 1}`, entryId: eid });
}
}
// Validate blob size
if (entry.type === NvsType.BLOB && entry.value instanceof Uint8Array) {
if (entry.value.length > MAX_BLOB_SIZE_V1) {
errors.push({ message: `"${entry.key}" BLOB ${entry.value.length} bytes exceeds limit ${MAX_BLOB_SIZE_V1}`, entryId: eid });
}
} else if (entry.type === NvsType.BLOB_DATA && entry.value instanceof Uint8Array) {
if (entry.value.length > MAX_BLOB_SIZE_V2) {
errors.push({ message: `"${entry.key}" BLOB ${entry.value.length} bytes exceeds V2 limit ${MAX_BLOB_SIZE_V2}`, entryId: eid });
}
}
if (entry.type === NvsType.BLOB_IDX) {
errors.push({ message: `"${entry.key}" has internal-only type BLOB_IDX`, entryId: eid });
}
if (entry.type === NvsType.BLOB_DATA && partition.version === NvsVersion.V1) {
errors.push({ message: `"${entry.key}" has V2-only type BLOB_DATA in a V1 partition`, entryId: eid });
}
if (entry.type === NvsType.BLOB && partition.version === NvsVersion.V2) {
errors.push({ message: `"${entry.key}" has V1-only type BLOB in a V2 partition`, entryId: eid });
}
}
// Check for duplicate (namespace, key) pairs
const seen = new Map<string, string>(); // composite key → first entry ID
const alreadyFlagged = new Set<string>(); // first-entry IDs already given one error
for (const entry of partition.entries) {
const k = `${entry.namespace}::${entry.key}`;
if (seen.has(k)) {
errors.push({ message: `Duplicate key: ${entry.namespace}/${entry.key}`, entryId: entry.id });
const firstId = seen.get(k)!;
if (!alreadyFlagged.has(firstId)) {
errors.push({ message: `Duplicate key: ${entry.namespace}/${entry.key}`, entryId: firstId });
alreadyFlagged.add(firstId);
}
} else {
seen.set(k, entry.id);
}
}
return errors;
}
/** Sort entries by namespace, then by key */
export function sortEntries(partition: NvsPartition): NvsPartition {
const entries = [...partition.entries].sort((a, b) => {
const nsCmp = a.namespace.localeCompare(b.namespace);
return nsCmp !== 0 ? nsCmp : a.key.localeCompare(b.key);
});
return { ...partition, entries };
}
/**
* Coerce BLOB/BLOB_DATA types to match partition version.
* V1 partitions use monolithic BLOB (0x41); V2 partitions use chunked BLOB_DATA (0x42).
* Must be called at every import boundary (JSON, localStorage, binary parser).
*/
export function reconcileBlobTypes(entries: NvsEntry[], version: NvsVersion): NvsEntry[] {
return entries.map(e => {
if (version === NvsVersion.V1 && e.type === NvsType.BLOB_DATA) return { ...e, type: NvsType.BLOB };
if (version === NvsVersion.V2 && e.type === NvsType.BLOB) return { ...e, type: NvsType.BLOB_DATA };
return e;
});
}
/**
* Normalize and validate a raw deserialized object into a well-formed NvsPartition.
* Single gate for all deserialization paths (localStorage restore + JSON import/merge).
* Never throws. Regenerates missing/duplicate ids. Strips NUL bytes from keys and namespaces.
* Returns metadata about dropped and clamped entries for UI warnings.
*/
export function normalizePartition(raw: unknown): NormalizeResult {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { partition: createEmptyPartition(), dropped: 0, clamped: 0 };
}
const obj = raw as Record<string, unknown>;
const VALID_VERSIONS = new Set<number>([NvsVersion.V1, NvsVersion.V2]);
const version: NvsVersion =
typeof obj.version === 'number' && VALID_VERSIONS.has(obj.version)
? (obj.version as NvsVersion)
: NvsVersion.V2;
// BLOB_IDX (0x48) is synthesized internally by the serializer; it is never a valid
// user entry. All other NvsType values are acceptable user input.
const VALID_TYPES = new Set<number>([
NvsType.U8, NvsType.I8, NvsType.U16, NvsType.I16,
NvsType.U32, NvsType.I32, NvsType.U64, NvsType.I64,
NvsType.SZ, NvsType.BLOB, NvsType.BLOB_DATA,
]);
const rawEntries = Array.isArray(obj.entries) ? obj.entries : [];
const seenIds = new Set<string>();
const entries: NvsEntry[] = [];
let dropped = 0;
let clamped = 0;
for (const re of rawEntries) {
if (!re || typeof re !== 'object' || Array.isArray(re)) { dropped++; continue; }
const r = re as Record<string, unknown>;
if (typeof r.type !== 'number' || !VALID_TYPES.has(r.type)) { dropped++; continue; }
const type = r.type as NvsType;
const namespace = typeof r.namespace === 'string' ? r.namespace.replace(/\0/g, '') : '';
if (typeof r.key !== 'string') { dropped++; continue; }
const key = r.key.replace(/\0/g, '');
if (key.length === 0) { dropped++; continue; }
const result = _normalizeEntryValue(type, r.value);
if (result === null) { dropped++; continue; }
if (result.clamped) clamped++;
let id = typeof r.id === 'string' && r.id.length > 0 ? r.id : '';
if (!id || seenIds.has(id)) id = generateEntryId();
seenIds.add(id);
entries.push({ id, namespace, key, type, value: result.value });
}
const reconciledEntries = reconcileBlobTypes(entries, version);
// Rebuild namespaces: preserve stored order, deduplicate, add missing, drop unused
const rawNs = Array.isArray(obj.namespaces) ? obj.namespaces : [];
const orderedNs = (rawNs.filter((n): n is string => typeof n === 'string'))
.reduce<string[]>((acc, n) => { if (!acc.includes(n)) acc.push(n); return acc; }, []);
for (const e of reconciledEntries) {
if (e.namespace && !orderedNs.includes(e.namespace)) orderedNs.push(e.namespace);
}
const usedNs = new Set(reconciledEntries.map(e => e.namespace));
const namespaces = orderedNs.filter(n => usedNs.has(n));
return { partition: { entries: reconciledEntries, namespaces, version }, dropped, clamped };
}
/** Returns normalized value for type, or null if unsalvageable. `clamped` is true if the value was modified. */
function _normalizeEntryValue(type: NvsType, raw: unknown): { value: NvsEntry['value']; clamped: boolean } | null {
// U64/I64 MUST come before isPrimitiveType() check — isPrimitiveType includes them
// but they require BigInt to avoid Number() precision loss above 2^53.
if (type === NvsType.U64 || type === NvsType.I64) {
if (typeof raw === 'bigint') return _clampBigInt(type, raw);
if (typeof raw === 'number') return _clampBigInt(type, BigInt(Math.trunc(raw)));
if (typeof raw === 'string') {
try { return _clampBigInt(type, BigInt(raw)); } catch { return null; }
}
return null;
}
if (isPrimitiveType(type)) {
let n: number;
if (typeof raw === 'number') n = raw;
else if (typeof raw === 'string') { n = Number(raw); if (Number.isNaN(n)) return null; }
else return null;
return _clampPrimitive(type, Math.trunc(n));
}
if (type === NvsType.SZ) return typeof raw === 'string' ? { value: raw, clamped: false } : null;
// BLOB / BLOB_DATA / BLOB_IDX — already revived by partitionFromJson reviver
if (raw instanceof Uint8Array) return { value: raw, clamped: false };
return null; // malformed/missing blob payload — drop the entry
}
function _clampPrimitive(type: NvsType, n: number): { value: number; clamped: boolean } {
let v: number;
switch (type) {
case NvsType.U8: v = Math.max(0, Math.min(0xFF, n)); break;
case NvsType.I8: v = Math.max(-128, Math.min(127, n)); break;
case NvsType.U16: v = Math.max(0, Math.min(0xFFFF, n)); break;
case NvsType.I16: v = Math.max(-32768, Math.min(32767, n)); break;
case NvsType.U32: v = Math.max(0, Math.min(0xFFFFFFFF, n)); break;
case NvsType.I32: v = Math.max(-2147483648, Math.min(2147483647, n)); break;
default: v = n;
}
return { value: v, clamped: v !== n };
}
function _clampBigInt(type: NvsType, v: bigint): { value: bigint; clamped: boolean } {
let r: bigint;
if (type === NvsType.U64) {
r = v < 0n ? 0n : v > 0xFFFFFFFFFFFFFFFFn ? 0xFFFFFFFFFFFFFFFFn : v;
} else {
// I64
r = v < -9223372036854775808n ? -9223372036854775808n
: v > 9223372036854775807n ? 9223372036854775807n : v;
}
return { value: r, clamped: r !== v };
}
/**
* Check blob entries against the target version's size limit.
* Returns human-readable warnings for each oversized blob.
*/
export function checkBlobCompatibility(
entries: NvsEntry[],
targetVersion: NvsVersion,
): string[] {
const limit = targetVersion === NvsVersion.V1 ? MAX_BLOB_SIZE_V1 : MAX_BLOB_SIZE_V2;
const warnings: string[] = [];
for (const e of entries) {
if ((e.type === NvsType.BLOB || e.type === NvsType.BLOB_DATA) &&
e.value instanceof Uint8Array && e.value.length > limit) {
warnings.push(`"${e.key}" (${e.value.length}B) 超出限制 ${limit}B`);
}
}
return warnings;
}
/**
* Parse a hex byte string. Accepts:
* - "de ad be ef" (space-separated)
* - "deadbeef" (continuous)
* - "0xDE 0xAD" (0x-prefixed, comma/space separated)
* Rejects input containing non-hex content (letters from identifiers, brackets, etc.).
*/
export function parseHexString(text: string): { bytes: Uint8Array } | { error: string } {
const trimmed = text.trim();
if (!trimmed) return { bytes: new Uint8Array(0) };
// 0x-prefixed mode: each whitespace/comma-separated token is one byte (0x00xFF)
if (/0[xX]/.test(trimmed)) {
const tokens = trimmed.split(/[\s,]+/).filter(t => t.length > 0);
const bytes: number[] = [];
for (const token of tokens) {
if (!/^0[xX][0-9a-fA-F]{1,2}$/.test(token)) {
return { error: `无效的字节: "${token}"0x格式每个字节为 0x00xFF` };
}
bytes.push(parseInt(token.slice(2), 16));
}
return { bytes: new Uint8Array(bytes) };
}
// Raw hex mode: strip separators, parse as 2-char pairs
const cleaned = trimmed.replace(/[\s,]+/g, '');
if (!/^[0-9a-fA-F]+$/.test(cleaned)) {
return { error: '包含非十六进制字符' };
}
if (cleaned.length % 2 !== 0) {
return { error: '字节数不完整(剩余半字节)' };
}
const bytes = new Uint8Array(cleaned.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(cleaned.substring(i * 2, i * 2 + 2), 16);
}
return { bytes };
}